diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 000000000..2c4b8cb1a --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +version: 2 +updates: + # Maintain Rust / Cargo dependencies + - package-ecosystem: "cargo" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + # Consolidate updates into a single PR rather than one PR per crate + groups: + cargo-dependencies: + patterns: + - "*" + commit-message: + prefix: "chore(deps)" + + # Maintain GitHub Actions workflows + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "daily" + time: "06:00" + timezone: "America/New_York" + groups: + actions: + patterns: + - "*" + commit-message: + prefix: "ci(deps)" diff --git a/.github/workflows/freebsd.yml b/.github/workflows/freebsd.yml index c63fdeef5..5fc2092a9 100644 --- a/.github/workflows/freebsd.yml +++ b/.github/workflows/freebsd.yml @@ -1,16 +1,23 @@ -# Builds and tests the workspace on FreeBSD, inside a FreeBSD VM on a Linux runner -# (GitHub has no FreeBSD runners). GPU tests skip themselves when the VM has no adapter. +# FreeBSD build and package check in a QEMU VM. name: FreeBSD on: push: branches: [main] - pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - .github/workflows/freebsd.yml + - ".github/workflows/freebsd.yml" + - "packaging/freebsd/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + pull_request: + branches: [main] + paths: + - ".github/workflows/freebsd.yml" + - "packaging/freebsd/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" workflow_dispatch: concurrency: @@ -22,34 +29,8 @@ permissions: jobs: freebsd: - name: FreeBSD 14 (build + test) - runs-on: ubuntu-latest - timeout-minutes: 300 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Optional font build input, so the Japanese font tests run (they skip without it). - - 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.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Nothing to bring back; copying the target dir out of the VM fails on large builds. - copyback: false - prepare: | - pkg install -y git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal - run: | - . "$HOME/.cargo/env" - # The checkout is synced in under another owner; let tests that call git read it. - git config --global --add safe.directory "*" - rustc --version - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - CARGO_PROFILE_DEV_DEBUG=0 cargo build --workspace --all-targets --locked - CARGO_PROFILE_DEV_DEBUG=0 cargo test --workspace --locked + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/freebsd.yml@main + with: + require-craft-fonts: true + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 diff --git a/.github/workflows/nix.yml b/.github/workflows/nix.yml new file mode 100644 index 000000000..d992f7433 --- /dev/null +++ b/.github/workflows/nix.yml @@ -0,0 +1,28 @@ +# Evaluates the Nix flake (no build, about a minute): `nix build` / `nix develop` stop at evaluation +# when Cargo.lock gains something nix/package.nix can't vendor, and nothing else would notice. +name: Nix + +on: + push: + branches: [main] + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + pull_request: + paths: ["flake.nix", "flake.lock", "nix/**", "Cargo.lock", ".github/workflows/nix.yml"] + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: nix-${{ github.ref }} + cancel-in-progress: true + +jobs: + eval: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + - uses: cachix/install-nix-action@13d8dd58da0234aa297dedd986986ccb8e7f3e24 # v31.11.1 + - name: nix flake check --no-build + run: nix flake check --no-build --all-systems diff --git a/.github/workflows/packaging-lint.yml b/.github/workflows/packaging-lint.yml index 8953710ce..71d3b3063 100644 --- a/.github/workflows/packaging-lint.yml +++ b/.github/workflows/packaging-lint.yml @@ -1,7 +1,5 @@ -# Quick checks for the release pipeline (no Rust build): workflow syntax, packaging -# shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata (seconds), plus a -# Windows job that builds and installs throwaway per-user MSI probes for the install-folder -# searches (a few minutes). +# Quick static checks for the release pipeline (seconds, no Rust build): workflow syntax, +# packaging shell/PowerShell scripts, and the Linux desktop/AppStream/Flatpak metadata. name: Packaging lint on: @@ -21,63 +19,10 @@ concurrency: jobs: lint: - runs-on: ubuntu-latest - env: - ACTIONLINT_VERSION: 1.7.12 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - name: Install tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends shellcheck desktop-file-utils appstream libxml2-utils python3-yaml - curl -fsSL "https://github.com/rhysd/actionlint/releases/download/v${ACTIONLINT_VERSION}/actionlint_${ACTIONLINT_VERSION}_linux_amd64.tar.gz" \ - | tar -xz -C "$RUNNER_TEMP" actionlint - - name: actionlint - run: | - "$RUNNER_TEMP/actionlint" -color - - name: shellcheck - run: shellcheck packaging/*.sh packaging/*/*.sh - - name: PowerShell - shell: pwsh - run: | - foreach ($f in Get-ChildItem packaging/windows/*.ps1) { - $errors = $null - [System.Management.Automation.Language.Parser]::ParseFile($f.FullName, [ref]$null, [ref]$errors) | Out-Null - if ($errors) { $errors | ForEach-Object { Write-Output "::error file=$($f.Name)::$_" }; exit 1 } - Write-Output "ok $($f.Name)" - } - - name: XML, desktop entry, AppStream, Flatpak manifest - run: | - xmllint --noout packaging/windows/lightcraft.wxs packaging/macos/*.plist* packaging/linux/*.xml* - # Windows Installer needs Icon ids ending in .ico or .exe, or shortcuts show a blank icon. - 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 - # The completion page the Publish events point at must actually exist: a Publish alone - # does not pull the dialog fragment into the MSI, and without the DialogRef the - # installer finishes silently (issues #790, #892). - grep -qF '' packaging/windows/lightcraft.wxs \ - || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; } - 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" - python3 - <<'EOF' - import yaml - src = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.yml")) - bundle = yaml.safe_load(open("packaging/linux/flatpak/ai.storyteller.lightcraft.bundle.yml")) - for m in (src, bundle): - assert m["id"] == "ai.storyteller.lightcraft" and m["command"] == "lightcraft", m["id"] - for key in ("runtime", "runtime-version", "sdk", "finish-args"): - assert src[key] == bundle[key], f"Flatpak manifests disagree on {key}: {src[key]} vs {bundle[key]}" - print("Flatpak manifests ok") - EOF - - name: FreeBSD tarball layout (stub binaries) - run: bash packaging/freebsd/package.sh --dry-run + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/packaging-lint.yml@main + with: + extra-checks-script: packaging/check-app-specific.sh windows-install-folder: runs-on: windows-latest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index b7e3036d6..e24229356 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,13 +1,5 @@ -# Release pipeline: every push to `release` builds platform artifacts (signed where credentials -# are configured) and creates or updates the draft GitHub Release "LightCraft v". -# See docs/releasing.md. -# -# The signing jobs (macOS, Windows) and the draft-release job run in the `release` environment -# (restricted to the `release` branch), which holds the signing secrets. Every secret is optional: -# missing ones produce unsigned artifacts and a warning, never a failed build. The jobs that sign -# nothing (version, Linux, Linux riscv64, Flatpak, FreeBSD, web) run without it, so a dispatch on -# any branch is a dry run of them; the release job needs the signed jobs, so a branch run never -# drafts a release. +# Release pipeline: every push to `release` builds signed installers for every platform and +# creates or updates the draft GitHub Release "LightCraft v". See docs/releasing.md. name: Release on: @@ -21,471 +13,33 @@ on: default: "" permissions: - contents: read + contents: write concurrency: group: release-${{ github.ref }} - # Never cancel a run mid-notarization or mid-upload; a newer push waits its turn. cancel-in-progress: false -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - RUST_BACKTRACE: 1 - LIGHTCRAFT_BUILD_SHA: ${{ github.sha }} - INPUT_VERSION: ${{ inputs.version }} - jobs: - version: - name: version - runs-on: ubuntu-latest - # No `environment: release` here or on the other jobs that sign nothing: no release secrets. - outputs: - version: ${{ steps.v.outputs.version }} - date: ${{ steps.v.outputs.date }} - prerelease: ${{ steps.v.outputs.prerelease }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - id: v - name: Resolve version - run: | - . packaging/env.sh - v="${INPUT_VERSION#v}" - v="${v:-$VERSION}" - if ! printf '%s' "$v" | grep -Eq '^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)(-[0-9A-Za-z-]+(\.[0-9A-Za-z-]+)*)?$'; then - echo "::error::'$v' is not a version like 1.2.3 or 1.2.3-rc.1" - exit 1 - fi - case "$v" in *-*) pre=true ;; *) pre=false ;; esac - { - echo "version=$v" - echo "date=$(date -u +%Y-%m-%d)" - echo "prerelease=$pre" - } >>"$GITHUB_OUTPUT" - echo "### LightCraft v$v (\`${GITHUB_SHA::9}\`)" >>"$GITHUB_STEP_SUMMARY" - - macos: - name: macOS universal - needs: version - runs-on: macos-15-xlarge # M2 Pro larger runner; use only in release.yml (actions_macos_xl budget caps spend) - environment: release - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: aarch64-apple-darwin, x86_64-apple-darwin - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-macos-universal - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Import signing certificate - env: - APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} - APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} - KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} - run: packaging/macos/import-cert.sh - - name: Build, sign, notarize - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - APPLE_ID: ${{ secrets.APPLE_ID }} - APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} - APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} - run: packaging/macos/package.sh --arch universal - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-macos - path: dist/release/* - if-no-files-found: error - - name: Remove temporary keychain - if: always() - run: | - if [ -n "${MACOS_KEYCHAIN:-}" ]; then security delete-keychain "$MACOS_KEYCHAIN" || true; fi - - windows: - name: Windows ${{ matrix.arch }} - needs: version - runs-on: windows-latest - environment: release - strategy: - fail-fast: false - matrix: - include: - - { arch: x64, target: x86_64-pc-windows-msvc } - - { arch: x86, target: i686-pc-windows-msvc } - # Windows on ARM: cross-compiled on the x64 runner, so signing and WiX run as above. - # .github/workflows/windows-arm64.yml installs and runs it on ARM64 hardware. - - { arch: arm64, target: aarch64-pc-windows-msvc } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: ${{ matrix.target }} - - 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 - - name: Build, sign, package - shell: pwsh - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} - WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} - AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} - AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} - AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} - AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-windows-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - linux: - name: Linux ${{ matrix.arch }} - needs: version - # The oldest GitHub-hosted image, so the binaries only need glibc >= 2.35 (Ubuntu 22.04+, - # Debian 12+, Fedora 36+, RHEL 10). See docs/releasing.md. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-22.04, deb: amd64 } - - { arch: aarch64, runner: ubuntu-22.04-arm, deb: arm64 } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - NFPM_VERSION: 2.47.0 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately - path: craft-fonts - - name: Install build and packaging tools - run: | - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - libxkbcommon-dev libwayland-dev libx11-dev libxrandr-dev libxi-dev libgl1-mesa-dev \ - desktop-file-utils appstream python3-yaml zsync - 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@686976e191b89faba57d3206551f0f330d8cb249 # stable - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-${{ matrix.arch }} - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Build and package (AppImage, deb, rpm, tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/linux/package.sh - - name: Check the packages - run: | - dpkg-deb --info dist/release/*.deb - dpkg-deb --contents dist/release/*.deb | grep -E 'usr/bin/lightcraft$|metainfo|applications' - ldd target/release/lightcraft - python3 -c "import yaml; m = yaml.safe_load(open('packaging/linux/flatpak/ai.storyteller.lightcraft.yml')); assert m['id'] == 'ai.storyteller.lightcraft'" - for appimage in dist/release/*.AppImage; do - chmod +x "$appimage" - APPIMAGE_EXTRACT_AND_RUN=1 "$appimage" --version - # Update information is embedded, and the .zsync for delta updates sits beside it. - "$appimage" --appimage-updateinformation | grep -F 'gh-releases-zsync|' - test -s "$appimage.zsync" - done - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/release/* - if-no-files-found: error - - flatpak: - name: Flatpak ${{ matrix.arch }} - needs: [version, linux] - # Repackages the Linux job's tarball (the same binaries as the AppImage/deb/rpm); no Rust - # build here. 24.04 for a newer flatpak-builder (1.4) than 22.04 ships. - runs-on: ${{ matrix.runner }} - strategy: - fail-fast: false - matrix: - include: - - { arch: x86_64, runner: ubuntu-24.04 } - - { arch: aarch64, runner: ubuntu-24.04-arm } - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: release-linux-${{ matrix.arch }} - path: dist/linux - - name: Install flatpak-builder - run: | - sudo apt-get update - # librsvg2-common: the SVG pixbuf loader. flatpak-builder runs the host's - # `appstreamcli compose`, which renders the scalable icon and fails without it. - sudo apt-get install -y --no-install-recommends flatpak flatpak-builder librsvg2-common - # Ubuntu 24.04's AppArmor blocks the unprivileged user namespaces bubblewrap needs. - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-flatpak-${{ matrix.arch }} - path: dist/release/*.flatpak - if-no-files-found: error - - linux-riscv64: - name: Linux riscv64 - needs: version - # Cross-compiled on x86_64 (no hosted RISC-V runners) against the runner's own Ubuntu release - # from ports.ubuntu.com, so the binaries need that release's glibc (24.04: 2.39). 22.04's - # riscv64 ports lag its amd64 updates, which breaks the multiarch install. tar.gz only; the - # CLI is smoke-tested under QEMU. - runs-on: ubuntu-24.04 - # No `environment: release`: this job signs nothing, so it gets no release secrets. - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: riscv64gc-unknown-linux-gnu - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-linux-riscv64 - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Install cross-compilation dependencies - run: | - . /etc/os-release - sudo dpkg --add-architecture riscv64 - # Pin the existing (archive.ubuntu.com) sources to amd64; riscv64 comes from ports. - if [ -f /etc/apt/sources.list.d/ubuntu.sources ]; then - sudo sed -i '/^Types: deb/a Architectures: amd64' /etc/apt/sources.list.d/ubuntu.sources - fi - if [ -f /etc/apt/sources.list ]; then - sudo sed -i 's/^deb /deb [arch=amd64] /g' /etc/apt/sources.list - sudo sed -i 's/^deb-src /deb-src [arch=amd64] /g' /etc/apt/sources.list - fi - for suite in "$VERSION_CODENAME" "$VERSION_CODENAME-updates" "$VERSION_CODENAME-security"; do - echo "deb [arch=riscv64] http://ports.ubuntu.com/ubuntu-ports $suite main universe restricted multiverse" - done | sudo tee /etc/apt/sources.list.d/riscv64.list - sudo apt-get update - sudo apt-get install -y --no-install-recommends \ - gcc-riscv64-linux-gnu binutils-riscv64-linux-gnu libc6-dev-riscv64-cross libc6-dev:riscv64 qemu-user \ - libxkbcommon-dev:riscv64 libwayland-dev:riscv64 libx11-dev:riscv64 libxrandr-dev:riscv64 \ - libxi-dev:riscv64 libgl1-mesa-dev:riscv64 - - name: Build and package (tar.gz) - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - CARGO_TARGET_RISCV64GC_UNKNOWN_LINUX_GNU_LINKER: riscv64-linux-gnu-gcc - PKG_CONFIG_SYSROOT_DIR: / - PKG_CONFIG_PATH: /usr/lib/riscv64-linux-gnu/pkgconfig:/usr/share/pkgconfig - PKG_CONFIG_ALLOW_CROSS: "1" - CROSS_ARCH: riscv64 - CROSS_TARGET: riscv64gc-unknown-linux-gnu - CROSS_COMPILE: riscv64-linux-gnu- - EMULATOR: qemu-riscv64 - QEMU_LD_PREFIX: /usr/riscv64-linux-gnu - run: packaging/linux/package.sh --formats tar - - name: Check the tarball - run: | - tar -tzf dist/release/lightcraft-*-linux-riscv64.tar.gz \ - | grep -E '/bin/lightcraft$|/bin/lightcraft-cli$|/share/applications/ai\.storyteller\.lightcraft\.desktop$' - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-linux-riscv64 - path: dist/release/*.tar.gz - if-no-files-found: error - - freebsd: - name: FreeBSD x86_64 - needs: version - # GitHub has no FreeBSD runners: build in a FreeBSD 14.3 VM, the same image and packages as - # .github/workflows/freebsd.yml. - runs-on: ubuntu-latest - timeout-minutes: 180 - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - 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 # v1.5.9 - with: - release: "14.3" - usesh: true - cpu: 4 - mem: 12288 - # Passed into the VM by name; never templated into the script below. - envs: "LIGHTCRAFT_VERSION LIGHTCRAFT_BUILD_DATE LIGHTCRAFT_BUILD_SHA INPUT_VERSION" - # The FreeBSD CI packages, plus bash for the packaging script. - prepare: | - pkg install -y bash git curl pkgconf cmake python3 libxkbcommon wayland libX11 libXcursor libXrandr libXi libxcb mesa-libs vulkan-loader gtk3 fontconfig freetype2 alsa-lib - curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable - run: | - set -e - . "$HOME/.cargo/env" - git config --global --add safe.directory "$PWD" - # Build outside the checkout so only dist/ is copied back out of the VM (copying the - # target dir back fails on large builds). 4 jobs: more runs the VM out of memory. - export CARGO_TARGET_DIR=/var/tmp/lightcraft-target CARGO_BUILD_JOBS=4 - if [ -n "$INPUT_VERSION" ]; then cargo xtask version set "$LIGHTCRAFT_VERSION"; fi - # craft-fonts was checked out next to the sources and synced into the VM with them. - export CRAFT_FONTS_DIR="$PWD/craft-fonts" CRAFT_FONTS_REQUIRED=1 - 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@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-freebsd-x86_64 - path: dist/release/* - if-no-files-found: error - - web: - name: Web (wasm) - needs: version - runs-on: ubuntu-latest - env: - LIGHTCRAFT_VERSION: ${{ needs.version.outputs.version }} - LIGHTCRAFT_BUILD_DATE: ${{ needs.version.outputs.date }} - WASM_BINDGEN_VERSION: 0.2.129 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - # Fonts (optional build input; required for releases): see craftrules standards/fonts.md. - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: storytold/craft-fonts - ref: 8dcdacd5153e64560d109541a47d806f26f048c0 # bump deliberately - path: craft-fonts - - uses: dtolnay/rust-toolchain@686976e191b89faba57d3206551f0f330d8cb249 # stable - with: - targets: wasm32-unknown-unknown - - uses: Swatinem/rust-cache@6323deb102c322ba6fcbdcafc7e3dddab59af2b6 # v2.9.2 - with: - key: release-web - - name: Install wasm-bindgen CLI - run: | - # Must match the wasm-bindgen version in Cargo.lock exactly (cargo xtask web checks it). - if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -q "$WASM_BINDGEN_VERSION"; }; then - cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked - fi - wasm-bindgen --version - - name: Apply version override - if: inputs.version != '' - run: cargo xtask version set "$LIGHTCRAFT_VERSION" - - name: Build and zip - env: - CRAFT_FONTS_DIR: ${{ github.workspace }}/craft-fonts - CRAFT_FONTS_REQUIRED: "1" - run: packaging/web/package.sh - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: release-web - path: dist/release/* - if-no-files-found: error - release: - name: Draft GitHub Release - needs: [version, macos, windows, linux, linux-riscv64, flatpak, freebsd, web] - runs-on: ubuntu-latest - environment: release - permissions: - contents: write - env: - VERSION: ${{ needs.version.outputs.version }} - PRERELEASE: ${{ needs.version.outputs.prerelease }} - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - GH_REPO: ${{ github.repository }} - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - pattern: release-* - path: artifacts - merge-multiple: true - - name: Checksums - working-directory: artifacts - run: | - sha256sum -- * | tee SHA256SUMS.txt - - name: Create or update the draft release - working-directory: artifacts - run: | - tag="v$VERSION" - title="LightCraft v$VERSION" - pre=() - if [ "$PRERELEASE" = true ]; then pre=(--prerelease); fi - if draft="$(gh release view "$tag" --json isDraft --jq .isDraft 2>/dev/null)"; then - if [ "$draft" != "true" ]; then - echo "::error::Release $tag is already published. Bump the version (cargo xtask version set) before pushing to release." - exit 1 - fi - echo "Updating draft $tag: replacing its assets" - gh release view "$tag" --json assets --jq '.assets[].name' | while read -r asset; do - gh release delete-asset "$tag" "$asset" --yes - done - gh release edit "$tag" --draft --title "$title" --target "$GITHUB_SHA" --prerelease="$PRERELEASE" - gh release upload "$tag" --clobber -- * - else - echo "Creating draft $tag" - gh release create "$tag" --draft --title "$title" --target "$GITHUB_SHA" --generate-notes "${pre[@]}" -- * - fi - { - echo "Draft release **$title** at \`${GITHUB_SHA::9}\`:" - echo - sed 's/^/ /' SHA256SUMS.txt - } >>"$GITHUB_STEP_SUMMARY" + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/release.yml@main + with: + app-name: lightcraft + app-title: LightCraft + craft-fonts-ref: 3d9ab3a132ed0eff457e424226a435a901da30d3 + version: ${{ inputs.version }} + secrets: + APPLE_CERTIFICATE: ${{ secrets.APPLE_CERTIFICATE }} + APPLE_CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + KEYCHAIN_PASSWORD: ${{ secrets.KEYCHAIN_PASSWORD }} + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_PASSWORD: ${{ secrets.APPLE_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + WINDOWS_CERTIFICATE: ${{ secrets.WINDOWS_CERTIFICATE }} + WINDOWS_CERTIFICATE_PASSWORD: ${{ secrets.WINDOWS_CERTIFICATE_PASSWORD }} + AZURE_TENANT_ID: ${{ secrets.AZURE_TENANT_ID }} + AZURE_CLIENT_ID: ${{ secrets.AZURE_CLIENT_ID }} + AZURE_CLIENT_SECRET: ${{ secrets.AZURE_CLIENT_SECRET }} + AZURE_SIGNING_ENDPOINT: ${{ secrets.AZURE_SIGNING_ENDPOINT }} + AZURE_SIGNING_ACCOUNT: ${{ secrets.AZURE_SIGNING_ACCOUNT }} + AZURE_CERT_PROFILE: ${{ secrets.AZURE_CERT_PROFILE }} diff --git a/.github/workflows/windows-arm64.yml b/.github/workflows/windows-arm64.yml index 1cccd858b..0033346dc 100644 --- a/.github/workflows/windows-arm64.yml +++ b/.github/workflows/windows-arm64.yml @@ -1,22 +1,27 @@ -# Windows on ARM: package the ARM64 release the way release.yml does (cross-compiled on an x64 -# runner, unsigned here), then install that MSI on a Windows 11 ARM64 runner, check the installed -# binaries are ARM64, run the CLI natively, and uninstall. (A native ARM64 `cargo test` job is left -# out for now: PdfCraft saw an intermittent access violation in egui UI tests there.) +# Windows ARM64 build and smoke test. +# +# Release packages on arm64-windows are produced on x64 runners via cross-compilation +# (target: aarch64-pc-windows-msvc) using MSVC's ARM64 cross tools, then installed and run +# on GitHub Actions' native windows-11-arm runner. name: Windows ARM64 on: push: branches: [main] - pull_request: paths: - - Cargo.toml - - Cargo.lock - - "**/Cargo.toml" - - "**/build.rs" - - .github/workflows/windows-arm64.yml - - packaging/windows/** - schedule: - - cron: "41 4 * * 1" # weekly, so drift in the runner images shows up + - ".github/workflows/windows-arm64.yml" + - "packaging/windows/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" + pull_request: + branches: [main] + paths: + - ".github/workflows/windows-arm64.yml" + - "packaging/windows/**" + - "Cargo.toml" + - "Cargo.lock" + - "crates/**" workflow_dispatch: concurrency: @@ -26,70 +31,10 @@ concurrency: permissions: contents: read -env: - CARGO_TERM_COLOR: always - CARGO_INCREMENTAL: 0 - jobs: - package: - name: package (cross-compiled on x64) - runs-on: windows-latest - timeout-minutes: 90 - steps: - - 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.9.2 - with: - key: windows-arm64-package - - name: Install WiX - shell: pwsh - run: dotnet tool install --global wix --version 5.0.2 - - name: Build and package (unsigned) - shell: pwsh - env: - DIST: ${{ github.workspace }}\dist\arm64 - run: ./packaging/windows/package.ps1 -Arch arm64 - - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 - with: - name: lightcraft-windows-arm64 - path: dist/arm64/* - if-no-files-found: error - retention-days: 7 - - install: - name: install and run on ARM64 - needs: package - runs-on: windows-11-arm - timeout-minutes: 30 - steps: - - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2 - with: - name: lightcraft-windows-arm64 - path: dist - - name: Install the MSI, check it, run it, uninstall it - shell: pwsh - run: | - $ErrorActionPreference = 'Stop' - $msi = (Get-ChildItem dist/*.msi | Select-Object -First 1).FullName - $p = Start-Process msiexec.exe -ArgumentList '/i', "`"$msi`"", '/qn', '/l*v', 'install.log' -Wait -PassThru - if ($p.ExitCode -ne 0) { Get-Content install.log -Tail 60; throw "msiexec /i exited $($p.ExitCode)" } - $dir = Join-Path $env:ProgramFiles 'LightCraft' - foreach ($exe in 'lightcraft.exe', 'lightcraft-cli.exe') { - $path = Join-Path $dir $exe - if (-not (Test-Path $path)) { throw "$exe not installed in $dir" } - $bytes = [System.IO.File]::ReadAllBytes($path) - $machine = [BitConverter]::ToUInt16($bytes, [BitConverter]::ToInt32($bytes, 0x3C) + 4) - if ($machine -ne 0xAA64) { throw "$exe is for machine 0x$('{0:X}' -f $machine), not ARM64" } - Write-Output "ok $path (ARM64)" - } - # The installed CLI runs natively on ARM64. - $cli = Join-Path $dir 'lightcraft-cli.exe' - $out = & $cli --version 2>&1 | Out-String - if ($LASTEXITCODE -ne 0) { Write-Output $out; throw "lightcraft-cli --version exited $LASTEXITCODE" } - Write-Output "ok lightcraft-cli --version: $($out.Trim())" - $p = Start-Process msiexec.exe -ArgumentList '/x', "`"$msi`"", '/qn' -Wait -PassThru - if ($p.ExitCode -ne 0) { throw "msiexec /x exited $($p.ExitCode)" } - if (Test-Path (Join-Path $dir 'lightcraft.exe')) { throw 'uninstall left lightcraft.exe behind' } - Write-Output 'ok uninstalled' + windows-arm64: + # Intentionally tracked at @main (not SHA-pinned) so org-wide CI improvements, security fixes, and build optimizations propagate without PR churn across all craftapp repos. + uses: storytold/craft-common-actions/.github/workflows/windows-arm64.yml@main + with: + app-name: lightcraft + app-dir: LightCraft diff --git a/AGENTS.md b/AGENTS.md index 8cd199ff6..c9f54b71d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -125,14 +125,18 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the -desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not -in CI: it may lag behind the workspace; see README → Quick start. +desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only +evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. ## Contributor credits (About window) diff --git a/CLAUDE.md b/CLAUDE.md index 136e821bb..00a2314aa 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -133,11 +133,15 @@ of it. Full standard: `../craftrules/standards/never-crash.md` with `/usr/bin/time`. Record numbers in `plan/STATUS.md` → Metrics. Budgets: slider update ≤ 16 ms (draft) / loupe ≤ 60 ms on ~2.5 MP; export ≤ 1 s per 24 MP JPEG. - Never commit media: test images are procedural (`lightcraft-scenes`) or CC0 downloads in the gitignored `corpus/`. +- A test that can't run here (no GPU, corpus file, craft-fonts face, model) calls `lightcraft_testkit::skip!(Gpu, "why")` + before it returns, not a bare `eprintln!`. `cargo xtask ci` then lists the skips and the machine's GPU adapters and + data, and writes `target/ci/report.json` and `target/ci/summary.md` (Markdown for a PR). ## Map of the code `geom`, `color`, `raster`, `tiff` (L0) → `raw`, `codecs`, `meta`, `develop` (L1) → `pipeline` → `catalog` → `engine` → `ui-egui`, `mcp` (L5) → apps `lightcraft` (desktop), `lightcraft-cli` (render/commands/MCP). `scenes` generates demo -photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`). `flake.nix` + +photos. `xtask` = tooling (`ci`, `layers`, `assets`, `parity`, `wasm`, `corpus`, `stats`); `testkit` = test helpers +(dev-dependency only). `flake.nix` + `nix/package.nix` = the Nix package (`nix build` builds both binaries with the craft-fonts input, installs the -desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained and not -in CI: it may lag behind the workspace; see README → Quick start. +desktop file/icons/AppStream metadata and runs `cargo test --workspace`; `nix develop` = dev shell). Community-maintained; CI only +evaluates it (`.github/workflows/nix.yml`), so the build may lag behind the workspace; see README → Quick start. diff --git a/Cargo.lock b/Cargo.lock index bf2ac824f..5d7f5111a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1072,6 +1072,11 @@ dependencies = [ "libc", ] +[[package]] +name = "craft-docpath" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=82a24b006eefa036b43c09a67fb9a3fbd17ffe63#82a24b006eefa036b43c09a67fb9a3fbd17ffe63" + [[package]] name = "craft-ui" version = "0.1.0" @@ -3073,7 +3078,7 @@ dependencies = [ [[package]] name = "lightcraft" -version = "0.7.1" +version = "0.9.1" dependencies = [ "eframe", "egui", @@ -3081,6 +3086,7 @@ dependencies = [ "lightcraft-engine", "lightcraft-raster", "lightcraft-sysmem", + "lightcraft-testkit", "lightcraft-ui-egui", "log", "muda", @@ -3097,7 +3103,7 @@ dependencies = [ [[package]] name = "lightcraft-catalog" -version = "0.7.1" +version = "0.9.1" dependencies = [ "crc32fast", "lightcraft-develop", @@ -3114,7 +3120,7 @@ dependencies = [ [[package]] name = "lightcraft-cli" -version = "0.7.1" +version = "0.9.1" dependencies = [ "dhat", "lightcraft-codecs", @@ -3130,10 +3136,11 @@ dependencies = [ [[package]] name = "lightcraft-codecs" -version = "0.7.1" +version = "0.9.1" dependencies = [ "avif-serialize", "bytemuck", + "dhat", "image", "image-webp", "jixel", @@ -3144,6 +3151,7 @@ dependencies = [ "lightcraft-geom", "lightcraft-heif", "lightcraft-raster", + "lightcraft-testkit", "moxcms 0.9.1", "png", "proptest", @@ -3159,7 +3167,7 @@ dependencies = [ [[package]] name = "lightcraft-color" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-geom", "proptest", @@ -3168,7 +3176,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise" -version = "0.7.1" +version = "0.9.1" dependencies = [ "faer-core", "lightcraft-denoise-core", @@ -3185,7 +3193,7 @@ dependencies = [ [[package]] name = "lightcraft-denoise-core" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-raster", "miniz_oxide 0.8.9", @@ -3200,7 +3208,7 @@ dependencies = [ [[package]] name = "lightcraft-develop" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-color", "lightcraft-geom", @@ -3212,9 +3220,10 @@ dependencies = [ [[package]] name = "lightcraft-engine" -version = "0.7.1" +version = "0.9.1" dependencies = [ "ab_glyph", + "craft-docpath", "harfrust", "jxl-oxide", "libc", @@ -3239,6 +3248,8 @@ dependencies = [ "lightcraft-raw", "lightcraft-scenes", "lightcraft-segment", + "lightcraft-slideshow", + "lightcraft-testkit", "lightcraft-tiff", "log", "miniz_oxide 0.8.9", @@ -3252,8 +3263,9 @@ dependencies = [ [[package]] name = "lightcraft-faces" -version = "0.7.1" +version = "0.9.1" dependencies = [ + "lightcraft-testkit", "matrixmultiply", "ndarray", "proptest", @@ -3266,7 +3278,7 @@ dependencies = [ [[package]] name = "lightcraft-fetch" -version = "0.7.1" +version = "0.9.1" dependencies = [ "log", "rustls", @@ -3277,7 +3289,7 @@ dependencies = [ [[package]] name = "lightcraft-geocode" -version = "0.7.1" +version = "0.9.1" dependencies = [ "miniz_oxide 0.8.9", "serde", @@ -3285,7 +3297,7 @@ dependencies = [ [[package]] name = "lightcraft-geom" -version = "0.7.1" +version = "0.9.1" dependencies = [ "proptest", "serde", @@ -3293,17 +3305,19 @@ dependencies = [ [[package]] name = "lightcraft-gpu" -version = "0.7.1" +version = "0.9.1" dependencies = [ "bytemuck", "lightcraft-color", "lightcraft-denoise", "lightcraft-develop", + "lightcraft-faces", "lightcraft-geom", "lightcraft-pipeline", "lightcraft-raster", "lightcraft-raw", "lightcraft-scenes", + "lightcraft-testkit", "log", "pollster 1.0.1", "wgpu", @@ -3311,7 +3325,7 @@ dependencies = [ [[package]] name = "lightcraft-heif" -version = "0.7.1" +version = "0.9.1" dependencies = [ "heic-rs", "rayon", @@ -3319,14 +3333,14 @@ dependencies = [ [[package]] name = "lightcraft-inpaint" -version = "0.7.1" +version = "0.9.1" dependencies = [ "rayon", ] [[package]] name = "lightcraft-lensdb" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-develop", "lightcraft-geom", @@ -3335,7 +3349,7 @@ dependencies = [ [[package]] name = "lightcraft-mcp" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-codecs", "lightcraft-engine", @@ -3346,7 +3360,7 @@ dependencies = [ [[package]] name = "lightcraft-merge" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-codecs", "lightcraft-color", @@ -3362,7 +3376,7 @@ dependencies = [ [[package]] name = "lightcraft-meta" -version = "0.7.1" +version = "0.9.1" dependencies = [ "lightcraft-geom", "lightcraft-tiff", @@ -3374,7 +3388,7 @@ dependencies = [ [[package]] name = "lightcraft-pipeline" -version = "0.7.1" +version = "0.9.1" dependencies = [ "image", "lightcraft-color", @@ -3392,7 +3406,7 @@ dependencies = [ [[package]] name = "lightcraft-preview" -version = "0.7.1" +version = "0.9.1" dependencies = [ "jpeg-encoder", "lightcraft-raster", @@ -3404,7 +3418,7 @@ dependencies = [ [[package]] name = "lightcraft-raster" -version = "0.7.1" +version = "0.9.1" dependencies = [ "libc", "lightcraft-color", @@ -3416,14 +3430,16 @@ dependencies = [ [[package]] name = "lightcraft-raw" -version = "0.7.1" +version = "0.9.1" dependencies = [ + "dhat", "jpeg-encoder", "jxl-oxide", "lightcraft-color", "lightcraft-geom", "lightcraft-meta", "lightcraft-raster", + "lightcraft-testkit", "lightcraft-tiff", "miniz_oxide 0.8.9", "proptest", @@ -3438,7 +3454,7 @@ dependencies = [ [[package]] name = "lightcraft-scenes" -version = "0.7.1" +version = "0.9.1" dependencies = [ "image", "lightcraft-color", @@ -3449,24 +3465,38 @@ dependencies = [ [[package]] name = "lightcraft-segment" -version = "0.7.1" +version = "0.9.1" dependencies = [ "candle-core", "candle-nn", "lightcraft-fetch", + "lightcraft-testkit", "log", "serde", "serde_json", "thiserror 2.0.21", ] +[[package]] +name = "lightcraft-slideshow" +version = "0.9.1" +dependencies = [ + "lightcraft-raster", + "serde", + "serde_json", +] + [[package]] name = "lightcraft-sysmem" -version = "0.7.1" +version = "0.9.1" + +[[package]] +name = "lightcraft-testkit" +version = "0.9.1" [[package]] name = "lightcraft-tiff" -version = "0.7.1" +version = "0.9.1" dependencies = [ "proptest", "serde", @@ -3475,7 +3505,7 @@ dependencies = [ [[package]] name = "lightcraft-ui-egui" -version = "0.7.1" +version = "0.9.1" dependencies = [ "craft-ui", "egui", @@ -3493,6 +3523,8 @@ dependencies = [ "lightcraft-pipeline", "lightcraft-preview", "lightcraft-raster", + "lightcraft-slideshow", + "lightcraft-testkit", "log", "serde", "serde_json", @@ -3502,7 +3534,7 @@ dependencies = [ [[package]] name = "lightcraft-web" -version = "0.7.1" +version = "0.9.1" dependencies = [ "crc32fast", "eframe", @@ -6972,7 +7004,7 @@ checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" [[package]] name = "xtask" -version = "0.7.1" +version = "0.9.1" dependencies = [ "brotli", "flate2", diff --git a/Cargo.toml b/Cargo.toml index 7effbe0d9..cf21739d8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ resolver = "3" members = ["crates/*", "apps/*", "xtask"] [workspace.package] -version = "0.7.1" +version = "0.9.1" edition = "2024" license = "MIT OR Apache-2.0" rust-version = "1.90" @@ -11,6 +11,7 @@ repository = "https://github.com/storytold/lightcraft" [workspace.dependencies] craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "5c874d51eeb3de70e735c8ce87a59a7dedd47675", features = ["serde"] } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "82a24b006eefa036b43c09a67fb9a3fbd17ffe63" } # internal lightcraft-denoise-core = { path = "crates/denoise-core" } lightcraft-denoise = { path = "crates/denoise", default-features = false } @@ -39,6 +40,8 @@ lightcraft-fetch = { path = "crates/fetch" } lightcraft-heif = { path = "crates/heif" } lightcraft-segment = { path = "crates/segment" } lightcraft-geocode = { path = "crates/geocode" } +lightcraft-slideshow = { path = "crates/slideshow" } +lightcraft-testkit = { path = "crates/testkit" } # foundation thiserror = "2" @@ -52,7 +55,7 @@ bytemuck = { version = "1", features = ["derive"] } crc32fast = "1" siphasher = "1" libc = "0.2" # dev-only: process CPU time in benches -dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap); never in default builds +dhat = "0.3" # opt-in heap profiling (lightcraft-cli --features dhat-heap) and heap tests; never in product builds # formats quick-xml = "0.38" diff --git a/NOTICE b/NOTICE index c9d650bb1..8b16eeba0 100644 --- a/NOTICE +++ b/NOTICE @@ -51,9 +51,10 @@ no darktable, RawSpeed, LibRaw or other GPL decoder/runtime code is included. Builds made with craft-fonts (the optional CRAFT_FONTS_DIR build input; all official releases) also embed the fonts listed in https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md -(the Japanese fonts BIZ UDPGothic, BIZ UDMincho and Shippori Mincho; the web build embeds only BIZ -UDPGothic Regular), under the SIL Open Font License 1.1. Release packages include each font's -licence as OFL-.txt. +(BIZ UDPGothic, BIZ UDMincho, Shippori Mincho, Noto Sans CJK SC, Noto Sans Arabic and Noto Sans +Thai), under the SIL Open Font License 1.1. The web build embeds BIZ UDPGothic Regular and Noto +Sans Thai Regular, and loads Noto Sans CJK SC separately. Release packages include each font's +licence as OFL-.txt; Windows installers include their texts in FONT-LICENCES.txt. Place names for reverse geocoding (Photo > Fill Location from GPS) are not part of LightCraft and are never distributed with it. On the user's request (Photo > Download Place Names) the app diff --git a/README.md b/README.md index f041111a1..38ff85898 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@

- Photo library and raw development; an open-source, clean-room reimplementation of Adobe Lightroom, rebuilt in pure Rust.
+ Photo library and raw development; an open-source, non-destructive photo library and raw developer, built in pure Rust.
Native on macOS, Windows and Linux. In the browser via WebAssembly. Drivable end to end by AI agents over MCP.

@@ -129,7 +129,7 @@ Split-tone shadows, midtones and highlights independently with drag-anywhere col ## Before & after -Hold \\ to peek at the original, press Y for side by side, or Shift+Y for a +Press \\ to toggle the Before view, Y for side by side, or Shift+Y for a split view. Every image below is a real screenshot of LightCraft, captured automatically by an agent through the [control channel](#built-for-agents). @@ -150,9 +150,13 @@ split view. Every image below is a real screenshot of LightCraft, captured autom Paint with a **Brush** (size, feather, flow, density, erase), drop **Linear** and **Radial Gradients** with draggable pins, or select by **Luminance Range**, **Color Range**, **Sky**, **Subject** and **Background**. Combine components -with **Add / Subtract / Intersect**, invert any of them, and dial in 15 local adjustments per mask (Temp, Tint, -Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise) -plus an overall Amount. +with **Add / Subtract / Intersect**, invert any of them, and dial in 17 local adjustments per mask (Temp, Tint, +Exposure, Contrast, Highlights, Shadows, Whites, Blacks, Texture, Clarity, Dehaze, Hue, Saturation, Sharpness, Noise, +Moiré, Defringe), plus an overall Amount. + +Desktop **Object** and **Describe** masks use SAM 3 for click-based and text-prompt selection, with a separately +installed model; see [model setup and terms](docs/ai-masks.md). Subject, Sky and Background selections still use +classical heuristics. Existing model-generated masks render and export without the model installed.

Masking panel with a linear Sky mask and a radial Sun glow mask; the radial gradient is drawn as a red overlay around the sun on a lake scene @@ -164,7 +168,7 @@ plus an overall Amount. ## Presets, profiles & the Color Mixer -Eighteen hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp +Forty-one hand-built presets ship in the box (*Golden Hour, Teal & Orange, Faded Matte, Selenium Tone, Crisp Landscape* and more), each with an **Amount** slider from 0 to 200 %. Save your own from any group of settings, mark favourites, and copy, paste or sync edits across a whole selection with exactly the groups you choose. @@ -209,6 +213,10 @@ A library that stays out of your way: **All Photos**, **Recently Added**, **Pick import date, edit date, name, rating, size or at random (a stable shuffle; View → Sort → Reshuffle for a new one). The justified **Photo Grid** and **Square Grid** views are virtualized, so they stay smooth whether you have forty photos or forty thousand. +The **People** view groups named faces, including names read from XMP; face boxes can be edited in the loupe. +On desktop, automatic detection and recognition use optional models installed through **Settings → Faces**; no weights are +bundled. See [face models and setup](docs/faces.md). + @@ -268,6 +276,26 @@ lightcraft --control 7980 ~/Pictures/trip with the same font coverage as watermarks (CJK requires craft-fonts), with long names shortened in the middle. This is a contact-sheet workflow, not a printer-driver or custom picture-package layout editor. +- **Print layouts (native app / CLI):** **File → Print…** opens the Print module's layout editor: Single Image, + Contact Sheet, Picture Package (Auto Layout packs cells onto pages) and Custom Package (drag and resize cells, + stacking order, snap to cells or a grid), Zoom to Fill / Rotate to Fit, Photo Border and Inner Stroke, paper + sizes from 4 × 6 to 13 × 19 and A3 to A5 or custom, margins, rulers in in/cm/mm/pt, page background, 72–720 ppi, + and saved templates. **Print to PDF…** renders in the background. Everything is a `print.*` command + (`print.get`, `print.set`, `print.cell.add`, `print.layout.autoLayout`, `print.exportPdf`, + `print.template.save`…), so the CLI and MCP drive it too: + + ```sh + lightcraft-cli run --import ~/Pictures/shoot print.set 'layout={"style":"picturePackage","page":{"paper":"8x10"}}' \ + print.cell.add width=5 height=7 print.cell.add width=2.5 height=3.5 print.layout.autoLayout print.exportPdf path=Package.pdf + ``` + + The Print Job panel supports printer/paper ICC profiles, rendering intent, black point compensation, + print adjustment, sharpening and 8/16-bit PDF output. Direct printing through an OS print dialog and page + overlays (identity plate, watermark, page numbers) are not there yet. +- **Slideshows:** **View → Slideshow** plays the photos in view full screen, with fades, repeat and random order. + **Settings → Slideshow** configures layouts, templates, backgrounds, overlays and title screens. Native exports + support PDF, JPEG sequences and AV1 video in MP4 or WebM; music and a dedicated Slideshow module editor remain + missing. See [docs/slideshow.md](docs/slideshow.md). - **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. @@ -279,7 +307,7 @@ lightcraft --control 7980 ~/Pictures/trip - **Pure Rust, no C.** Our own RAW decoders (DNG, Canon CR2/CR3, Sony ARW, Nikon NEF, Fujifilm RAF incl. X-Trans, Panasonic RW2 / Leica RWL, Pentax PEF, Olympus ORF), our own colour science, our own pipeline. JPEG, PNG, TIFF, WebP, - PSD composites and JPEG XL open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, + PSD composites, JPEG XL, GIF (first frame) and BMP open today; HEIC/HEIF photos too with `--features heif` (HEVC is a build-time choice, as in PhotoCraft). - **Scene-referred & wide-gamut.** Linear Rec.2020 float internally, Bradford-adapted white balance, gamut mapping instead of clipping, a filmic shoulder for raw and pixel-exact pass-through for JPEGs you haven't touched. @@ -305,17 +333,21 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (details in the [roadmap](ROADMAP.md) and [docs/target-app-parity.md](docs/target-app-parity.md)): -- **By feature count we're at ~81%** of Lightroom (core features 98%), tracked row by row in +- **By feature count we're at ~82%** (core features 98%), tracked row by row in [docs/parity-checklist.md](docs/parity-checklist.md). -- **As a day-to-day Lightroom replacement we're nearer 61% for the whole product, ~49% for a typical working photographer and ~64% for +- **As a day-to-day tool we're nearer 61% for the whole product, ~51% for a typical working photographer and ~65% for casual use.** It's great for JPEG/DNG and most Sony / Panasonic / Fujifilm / Nikon / older-Canon raws on one machine; see [docs/raw-parity.md](docs/raw-parity.md) for your camera. - **The biggest gaps:** - - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCE-7CR, ILCE-7M4, X-H2S and X-T4 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; + - **camera colour calibration:** Sony, Nikon, Panasonic, Fujifilm and Canon CR3 raws have guarded estimates from their camera JPEGs, with built-in ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4, Nikon Z 6II, Z 8 and D5300 profiles; measured calibration is missing, and other raws or rejected fits retain a neutral matrix; - **compressed Olympus raws and unsupported CR3 variants:** these use embedded JPEG previews when present. Fujifilm lossless/lossy compressed RAF now decodes sensor data; [verification and existing-library reload instructions](docs/raf-compression.md); - - **AI masks and denoise:** subject and sky selection are classical heuristics; + - **AI quality and model setup:** Subject and Sky selection are classical heuristics; Object/Describe use an + optional SAM 3 model. AI Denoise supports Bayer RAW with separately installed weights, while X-Trans and + linear-RGB denoise and Super Resolution remain missing; see [AI masks](docs/ai-masks.md) and [AI Denoise](docs/denoise.md); - **network libraries:** a preview reads the raw's header — or, for a CR3 or a TIFF raw whose preview tag sits past it, exactly the ranges the sparse planner names — and its embedded JPEG only (56.5 MB → 5.9 MB, 10 s → 1 s on the test share), but the *developed* picture still needs the sensor data over the wire; see [docs/raw-preview-reads.md](docs/raw-preview-reads.md); - - **on-screen HDR display, video and the Classic Print / Book / Map modules.** + - **on-screen HDR display, video import/editing, and the Book / Map module views.** Print layouts and + colour-managed PDF output work; direct printer output and page overlays remain missing. Slideshow playback + and export work, with a dedicated module editor and music still missing. - **What's next:** see [the ranked gaps](docs/gaps.md) and [the roadmap](docs/roadmap.md). | Area | Status | @@ -323,16 +355,16 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Library: albums, folders, smart albums, stacks (incl. auto-stack), virtual copies, ratings, flags, labels, filter bar, search, sort, grids, filmstrip | ✅ | | Culling: Compare (synced zoom) and Survey views, auto-advance, instant previews | ✅ | | Light, Color, Effects (vignette styles), Tone Curve (+ refine saturation, targeted adjustment), Color Mixer (+ targeted), Point Color, Color Grading, Calibration, B&W | ✅ | -| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect | ✅ (AI subject/sky use classical heuristics for now) | +| Masking: brush, linear, radial, luminance/colour range, add/subtract/intersect; Object and Describe selections | ✅ · Object/Describe require optional SAM 3; Subject/Sky remain heuristic ([details](docs/ai-masks.md)) | | Crop, straighten tool + auto straighten, flip, rotate, aspect ratios, overlays | ✅ | | Profiles (Color, Neutral, Vivid, Landscape, Portrait, Monochrome: our own looks), presets, versions, history, copy/paste/sync settings | ✅ | -| Camera colour: DNG files use their own matrices | ✅ DNG · 🚧 own Sony/Fujifilm profiles; measured calibration database missing | +| Camera colour: DNG matrices, guarded camera-JPEG look fits and built-in profiles for selected cameras | 🚧 measured calibration and per-model coverage remain incomplete ([raw parity](docs/raw-parity.md)) | | Native macOS menu bar (generated from the command registry), control channel + every widget addressable, headless UI snapshots | ✅ | | RAW: DNG, CR2, CR3 (lossless CRX Bayer and version 0x100/0x200 C-RAW), ARW, NEF (uncompressed + lossless/lossy compressed, incl. lossy after split), 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, 12.8 bpp), Samsung SRW (uncompressed); embedded previews for every format incl. CR3 | 🚧 · other CR3 variants, compressed ORF / SRW and Nikon HE NEF decode ⬜ ([raw parity](docs/raw-parity.md)) | -| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights), 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: 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 ⬜ | +| Detail: sharpening, luminance + colour noise reduction | ✅ · AI Denoise 🚧 (Bayer, user-installed weights; [setup](docs/denoise.md)), Super Resolution ⬜ | +| Remove / Heal / Clone spots (auto source), non-generative Fill, editable spot pins, Visualize Spots, Red Eye and Pet Eye | ✅ · Fill uses PatchMatch; no generative fill | +| Export: JPEG / PNG / TIFF / flat PSD / WebP / AVIF / JPEG XL / raw or rendered 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; gain map JPEG, PQ/HLG AVIF, PQ PNG/JPEG XL and float TIFF export | ✅ · 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 | ✅ | @@ -340,7 +372,11 @@ LightCraft is young and moving fast: **alpha**. **Where we honestly stand** (det | Optics (distortion, vignetting, auto + manual CA, defringe, lens corrections embedded in DNG files, Panasonic / Leica RW2 / RWL distortion data and Nikon Z 6II / Z 8 / Z f 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; video | 🚧 (see [gaps](docs/gaps.md)) | +| People view, XMP face names and editable face boxes; automatic face detection and recognition | ✅ · detection/recognition require optional models ([setup](docs/faces.md)) | +| GPX tracklog auto-tagging and offline reverse geocoding | ✅ · place names require the GeoNames dataset; Map view ⬜ | +| Print layouts, picture packages, templates and colour-managed PDF export | ✅ layouts/PDF · 🚧 direct printer output and page overlays | +| Slideshow playback, layouts, overlays, templates and PDF/JPEG/AV1 export | ✅ playback · 🚧 dedicated module editor and music ([details](docs/slideshow.md)) | +| Video import/editing and Book module | ⬜ | | 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 @@ -359,7 +395,7 @@ cargo run --release -p lightcraft -- --memory # a throwaway in-memory cargo run --release -p lightcraft -- --control 7980 # with the automation channel cargo xtask web --serve # the same app in the browser: http://127.0.0.1:8080/ cargo run --release -p lightcraft-cli -- render photo.jpg -o out.jpg --set light.exposure=0.5 -cargo xtask ci # fmt, clippy, tests, layering, wasm checks +cargo xtask ci # fmt, clippy, HEIF, tests, parity, layers, assets, WASM ``` CI defaults to line-table debug information and one build job per 1.5 GB of RAM @@ -395,6 +431,13 @@ information combined with LTO and one codegen unit on low-memory builders. A PKG its own profile settings can override inherited variables; adjust the packaging recipe locally or contact its maintainer rather than assuming an environment prefix on `makepkg` will take precedence. +Tests that need a GPU, the corpus, the craft-fonts faces or a model pass without checking anything when +those are missing (libtest has no "skipped" status), so `cargo xtask ci` lists them: tests record a skip +with `lightcraft_testkit::skip!`, and the run ends with what the machine offered (OS, CPUs, RAM, GPU +adapters per backend, corpus, fonts) and every skip by reason. It writes `target/ci/report.json` and +`target/ci/summary.md`, a block to paste into a PR (`--summary-md` prints it); `--hardware` also runs the +GPU tests on every backend that has a hardware adapter. Nothing is uploaded. + **Chinese and Japanese text** need the shared font repo, an optional build input (official releases always include it): ```sh @@ -405,7 +448,7 @@ CRAFT_FONTS_DIR=../craft-fonts cargo run --release -p lightcraft Without it LightCraft builds and runs the same, but Chinese and Japanese text have no glyphs. Fonts are never committed to this repo — the standard for what goes there lives with it: see the [craft-fonts README](https://github.com/storytold/craft-fonts#readme). -**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français) or **Settings → General → +**Interface language:** **Edit → Language** (English, 简体中文, 繁體中文(台灣), 日本語, Português (Brasil), Español, Deutsch, Русский, Français, Українська, Polski, Čeština, Italiano, ქართული) 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 @@ -470,9 +513,9 @@ egui frontend is one swappable crate; nothing below it knows a UI exists. `mcp` Humans and agents follow the same rules, so read [AGENTS.md](AGENTS.md) first. The short version: -- **Clean-room.** Never read Adobe binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); +- **Clean-room.** Never read proprietary binaries or GPL raw/photo code (darktable, RawTherapee, LibRaw, rawspeed, dcraw…); work from public specs and black-box observation. -- **No Adobe assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from Adobe products. Every +- **No proprietary assets, ever:** no icons, screenshots, presets, profiles, LUTs or fonts from commercial products. Every image, icon and font in the repo is original, public domain, Creative Commons, OFL or permissively licensed, and has an entry in [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) added in the same commit. New fonts go to [storytold/craft-fonts](https://github.com/storytold/craft-fonts), not here. @@ -515,7 +558,7 @@ option to start it. For unattended installs: `msiexec /i lightcraft--window | Format | x86_64 | aarch64 (ARM64) | Notes | |---|---|---|---| -| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Runs anywhere; updates itself with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | +| AppImage | `lightcraft--linux-x86_64.AppImage` | `lightcraft--linux-aarch64.AppImage` | Portable; updates with [AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) (`.zsync` files) | | Flatpak | `lightcraft--linux-x86_64.flatpak` | `lightcraft--linux-aarch64.flatpak` | Sandboxed; `flatpak install --user ` | | Debian/Ubuntu | `lightcraft--linux-x86_64.deb` | `lightcraft--linux-aarch64.deb` | | | Fedora/RHEL/openSUSE | `lightcraft--linux-x86_64.rpm` | `lightcraft--linux-aarch64.rpm` | | @@ -602,8 +645,6 @@ ArtCraft Team and are not covered by this license. They may be used only unmodif part of this repository and LightCraft, under [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt). Forks and modified versions must remove them. -Adobe, Photoshop, Illustrator, Premiere Pro, Lightroom, Acrobat, After Effects and InDesign are trademarks or registered trademarks of Adobe Inc. in the United States and/or other countries. LightCraft is an independent, open-source project and is not affiliated with, sponsored by or endorsed by Adobe Inc.; these names are used only to describe the workflows it is compatible with. -

ArtCraft
Made by the ArtCraft team and community. diff --git a/README.zh-hans.md b/README.zh-hans.md index 0a7feb32c..58e1d627f 100644 --- a/README.zh-hans.md +++ b/README.zh-hans.md @@ -18,7 +18,7 @@

- 照片图库与 raw 显影;Adobe Lightroom 的开源净室实现,用纯 Rust 重建。
+ 照片图库与 raw 显影;开源、非破坏性的照片图库与 raw 显影工具,用纯 Rust 构建。
在 macOS、Windows 和 Linux 上原生运行,也可通过 WebAssembly 在浏览器中使用,并能由 AI 智能体经 MCP 端到端驱动。

@@ -299,12 +299,12 @@ lightcraft --control 7980 ~/Pictures/trip LightCraft 很年轻,而且进展很快。**我们如实所处的位置**(细节见[路线图](ROADMAP.md#where-we-stand)): -- **按功能数量算,我们达到了 Lightroom 的约 79%**(核心功能 98%),逐行记录在 +- **按功能数量算,我们达到了约 79%**(核心功能 98%),逐行记录在 [docs/parity.md](docs/parity.md)。 -- **作为日常替代 Lightroom 的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ +- **作为日常使用的工具,我们更接近 60–70%。** 在单台机器上处理 JPEG/DNG 以及大多数尼康/索尼/ 较老佳能的 raw 都很出色。 - **最大的缺口:** - - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCE-7CR、ILCE-7M4、X-H2S 和 X-T4 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; + - **相机色彩校准:** 索尼、尼康、松下、富士和佳能 CR3 的 raw 会从各自的相机 JPEG 得到受保护的估计值,内置 ILCA-99M2、ILCE-7CR、ILCE-7M4、X-H2S、X-T4、尼康 Z 6II、Z 8 和 D5300 配置文件;实测校准仍然缺失,其他 raw 或被拒绝的拟合会保留中性矩阵; - **压缩的奥林巴斯 raw 和不受支持的 CR3 变体:** 这些在有内嵌 JPEG 预览时使用预览。富士无损/有损压缩 RAF 现在能解码传感器数据;[验证与已有图库重新加载说明](docs/raf-compression.md); - **AI 蒙版与降噪:** 主体和天空选择目前是经典启发式算法; - **屏幕上的 HDR 显示、视频,以及经典版式中的打印/画册/地图模块。** @@ -453,9 +453,9 @@ egui 前端是一个可替换的 crate;它下面没有任何东西知道 UI 人和智能体遵循同样的规则,所以请先读 [AGENTS.md](AGENTS.md)。简短版本: -- **净室。** 绝不阅读 Adobe 的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); +- **净室。** 绝不阅读专有软件的二进制文件或 GPL 的 raw/照片代码(darktable、RawTherapee、LibRaw、rawspeed、dcraw……); 只依据公开规范和黑盒观察来工作。 -- **绝不使用 Adobe 资源:** 不使用 Adobe 产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 +- **绝不使用专有资源:** 不使用商业产品的图标、截图、预设、配置文件、LUT 或字体。仓库中的每一个 图像、图标和字体都是原创、公有领域、知识共享、OFL 或宽松许可的,并在同一提交中 于 [assets/ATTRIBUTION.md](assets/ATTRIBUTION.md) 添加条目。新字体提交到 [storytold/craft-fonts](https://github.com/storytold/craft-fonts),而不是这里。 @@ -579,8 +579,6 @@ ArtCraft 团队的商标,不在本许可范围内。它们只能在未修改 本仓库和 LightCraft 的一部分,按 [`docs/brand/LICENSE-brand.txt`](docs/brand/LICENSE-brand.txt) 使用。 分支和修改版本必须移除它们。 -Adobe、Photoshop、Illustrator、Premiere Pro、Lightroom、Acrobat、After Effects 和 InDesign 是 Adobe Inc. 在美国和/或其他国家的商标或注册商标。LightCraft 是一个独立的开源项目,与 Adobe Inc. 无隶属关系,也未获其赞助或认可;这些名称仅用于说明它所兼容的工作流。 -

ArtCraft
由 ArtCraft 团队和社区制作。 diff --git a/ROADMAP.md b/ROADMAP.md index eefb39b45..f142cd226 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: alpha** · next: beta, ~14 points and ~340–600 h away -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (full-resolution zoom done) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 One-page summary of how close LightCraft is to Adobe Lightroom. Agents: read this, then pick work from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are in @@ -12,11 +12,11 @@ from [docs/gaps.md](docs/gaps.md). The full assessment, method and evidence are | | Value | Kind | |---|---:|---| -| **Feature breadth** (checklist) | **81.1%** of 517 rows (P0 98.0%, P1 95.1%, P2 46.9%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | +| **Feature breadth** (checklist) | **81.6%** of 532 rows (P0 98.0%, P1 95.2%, P2 50.0%) | measured: [parity-checklist.md](docs/parity-checklist.md) recount | | **Feature breadth** (weighted by use) | **~77%** | estimated | | **Ready for real work** (full target) | **~61%** (56–66%) | estimated: weighted sum over the dimensions | -| **Mainstream practitioner** | **~49%** (44–54%) | estimated | -| **Essentials user** | **~64%** (59–69%) | estimated | +| **Mainstream practitioner** | **~51%** (46–56%) | estimated | +| **Essentials user** | **~65%** (60–70%) | estimated | | Remaining to **beta** | **~340–600 h** (Opus 5.5 agent hours, ~60% parallel) | estimated | | Remaining to **full parity** | **~1,000–1,800 h** (~70% parallel) | estimated | @@ -25,15 +25,15 @@ Each audience with its own hours (Opus 5.5 agent wall-clock hours to ~95%): | Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---:|---:|---| | Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | -| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | -| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | +| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | The gap between breadth and readiness is quality and coverage, not missing buttons: camera colour is -estimated rather than measured, CR3 decodes on four verified Canon bodies, compressed ORF and HE NEF +estimated rather than measured, CR3 decodes on 26 verified Canon bodies (rarer CRX variants still open as previews), compressed ORF and HE NEF open as previews, rendering is tuned by eye, AI masks are heuristics, there is no lens database, and tests run in CI on FreeBSD only. **Why alpha:** core workflows run end to end, but readiness is below -the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (Canon, ~30% of -raw users, decodes CR3 on four verified bodies). It passes the alpha gate: all six core workflows +the ~75% beta bar (it is above the ~40% alpha bar) and Lightroom's main inputs, raw files, still have blocking gaps (compressed ORF and HE NEF open as previews; Canon, +~30% of raw users, decodes CR3 on 26 verified bodies). It passes the alpha gate: all six core workflows (import, cull, develop, save and reopen, batch looks, export) work end to end on macOS ([docs/roadmap.md](docs/roadmap.md#alpha-gate)). The three readiness numbers and their weights and discounts are in [target-app-parity.md](docs/target-app-parity.md#ready-for-real-work-mainstream-practitioner-and-essentials-user). @@ -45,11 +45,11 @@ Hours are calibrated on this repo's PR history; see | Dimension | Ready | Breadth | Remaining | Doc | |---|---:|---:|---:|---| | Features | ~61% | ~77% (checklist 81.1%) | 600–1,100 h | [target-app-parity](docs/target-app-parity.md#features) | -| ↳ RAW cameras, colour, lens profiles | ~56% | 81% of 1,446 Adobe models decode, 16% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | -| UI/UX fidelity | ~70% | ~85% | 34–58 h | [ui-parity](docs/ui-parity.md) | +| ↳ RAW cameras, colour, lens profiles | ~60% | 83% of 1,446 Adobe models decode, 38% verified | 255–480 h | [raw-parity](docs/raw-parity.md) | +| UI/UX fidelity | ~70% | ~85% | 32–54 h | [ui-parity](docs/ui-parity.md) | | File formats (non-raw) | ~70% | ~85% | 30–55 h | [file-format-parity](docs/file-format-parity.md) | | Hardware | ~40% | ~50% | 40–80 h | [hardware-parity](docs/hardware-parity.md) | -| Localization | ~52% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | +| Localization | ~55% of the 12 key languages | 7 of 12 present | 130–210 h (+145–255 h new scripts) | [localization-parity](docs/localization-parity.md) | | Performance | ~70% | ~80% | 30–50 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Stability | ~60% | — | 30–60 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | | Platforms | ~60% | macOS, Windows, Linux, FreeBSD, web | 25–45 h | [target-app-parity](docs/target-app-parity.md#by-dimension) | @@ -61,7 +61,7 @@ Hours are calibrated on this repo's PR history; see | Area | Ready | Breadth | Remaining | |---|---:|---:|---:| | Import and library | 80% | 90% | 25–45 h | -| RAW decoding and camera coverage | 77% | 81% | 120–220 h | +| RAW decoding and camera coverage | 77% | 82% | 120–220 h | | Colour, camera profiles, render fidelity | 35% | 65% | 80–150 h | | Global develop adjustments | 70% | 93% | 30–60 h | | Optics, lens profiles, crop and geometry | 50% | 80% | 40–80 h | @@ -96,7 +96,7 @@ while lower-layer errors stay English. Detail: [localization-parity.md](docs/loc | Korean | ko | 0% | none (no Hangul face) | | Vietnamese | vi | 0% | none | -Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%). +Also shipped: Traditional Chinese (87%), Russian (74%), Ukrainian (99%), Italian (92%), Georgian (100%). ## Upcoming @@ -114,8 +114,26 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ ## Progress log +- 2026-10-14: Camera-coverage sweep verifies 30 more models on 40 CC0 files, with file and sensor + hashes pinned (`docs/raw/coverage.py`: 515 → 545 verified). Eleven candidates excluded after rechecking + identity, provenance and camera-image agreement. Phone and drone DNG tone differences remain tracked in #1277. +- 2026-10-14: 97 Canon CR2, Pentax and Ricoh models verified, one CC0 raw.pixls.us file each pinned and + checked against the camera JPEG (`crates/raw/tests/canon_pentax_corpus.rs`). 334 models verified, ~50% of + photographers on a verified body; headline numbers unchanged. +- 2026-10-12: Mamiya MEF (ZD) decodes from sensor data and is imported; verified on a CC0 file. 241 models + verified, 35 unsupported; headline numbers unchanged. +- 2026-10-12: Epson ERF (R-D1, R-D1s, R-D1x) decodes from sensor data, white balance measured from each + file's preview; 3 bodies verified on CC0 files. 240 models verified; headline numbers unchanged. - 2026-10-11: Leica Q3 verified by a recorded check of 38 private DNGs (all decode and develop at full size; mean ΔE to the camera JPEG 6.3–13.9). 236 models verified; headline numbers unchanged. +- 2026-10-12: 22 more Canon CR3 bodies verified (EOS R, RP, R3, R5, R6, R6 Mark II / III, R7, R10, R50, + R50 V, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, 1D X Mark III, PowerShot G5 X II, G7 X III, SX70, + V1): one CC0 file each pinned and checked against the camera JPEG; all 110 CC0 CR3s on raw.pixls.us + decode. The EOS R6 Mark III gets its as-shot white balance (`ColorData` 66). 259 models verified; RAW + decoding 77% → 83%; mainstream ~49% → ~51%, essentials ~64% → ~65%, full ~61%. +- 2026-10-12: 117 Nikon and Fujifilm bodies verified, one CC0 raw.pixls.us file each pinned and checked + against the camera JPEG (`crates/raw/tests/body_corpus.rs`); the sweep found COOLPIX 5700 / 8400 / 8800 + decoding wrongly. 354 models verified, ~58% of photographers on a verified body; headline numbers unchanged. - 2026-10-11: Canon EOS R5 Mark II C-RAW decodes from sensor data (57% of its files opened as a preview): the CRX QP map's first-symbol literal and limited-length escape; four CC0 R5 Mark II files pinned, the QP maps of 33,665 private C-RAW files decode exactly ([cr3.md](docs/cr3.md)). 235 models @@ -132,6 +150,8 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ / localization parity); `docs/parity.md` renamed `docs/parity-checklist.md`. Landed today: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map JPEG / PQ AVIF / float TIFF export (#646), Lightroom-like Auto (#628), activity stack. +- 2026-10-11: Sony ILCA-99M2 (α99 II) camera colour: bundled profile from 144 photos; on 30 withheld photos + mean ΔE vs the camera JPEG 5.11 → 4.87, 25 of them closer. - 2026-10-09: Sony ILCE-7CR camera colour: bundled profile from 125 photos; on 26 withheld photos mean ΔE vs the camera JPEG 4.07 → 3.29. HEIC in release builds (#609), Samsung uncompressed SRW, contact-sheet PDF export, Ukrainian and French UI, release v0.5.0. @@ -163,8 +183,20 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface (`it`, #1192): 2,201 of 2,398 messages, locale, menu command and tests; headline numbers unchanged | +| 2026-10-13 | minor | Sony ILCA-99M2 camera colour profile in the log; numbers unchanged | +| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | +| 2026-10-14 | minor | 21 Sony and Leica models verified (258 models, 18%); headline numbers unchanged | +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | +| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models verified, 18%); headline numbers unchanged | +| 2026-10-12 | minor | Mamiya MEF decoded and imported: ZD verified (241); headline numbers unchanged | +| 2026-10-12 | minor | Epson ERF decoded: 3 more verified models (240); headline numbers unchanged | +| 2026-10-12 | minor | Georgian (`ka`) interface language added (every catalog message) | | 2026-10-14 | minor | Full-resolution zoom (gap 4) closed: soft proofing gets zoom windows; headline numbers unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed); headline numbers unchanged | +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); headline numbers unchanged | +| 2026-10-12 | minor | 22 more CR3 bodies verified, EOS R6 Mark III white balance: RAW decoding 77% → 83%, RAW ~56% → ~60%; mainstream 49% → 51%, essentials 64% → 65%; full unchanged at ~61% | +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (354 models, 24%); headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified: CR3 on four verified bodies, RAW decoding 76% → 77%; headline numbers unchanged | | 2026-10-10 | minor | RAW measured model by model; RAW decode and colour as explicit rows: full 59% → 61%, mainstream 51% → 49%, essentials 63% → 64%; beta ~14 points | | 2026-10-10 | minor | Full ready restored to the additive weighted sum (~59%; method aligned with the standard, no new evidence); hours per audience; beta ~16 points away | @@ -174,3 +206,5 @@ Ranked toward beta; detail in [docs/roadmap.md](docs/roadmap.md), gaps in [docs/ | 2026-10-08 | minor | *Where we stand* by dimension and by kind of user | | 2026-10-02 | major | Parity estimate and effort table | | 2026-09-30 | major | Created | +| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (334 models, 23%); headline numbers unchanged | +| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36's CC0 per-model checks) | diff --git a/apps/lightcraft-cli/src/main.rs b/apps/lightcraft-cli/src/main.rs index b6cad57e0..01dae3055 100644 --- a/apps/lightcraft-cli/src/main.rs +++ b/apps/lightcraft-cli/src/main.rs @@ -3,9 +3,9 @@ //! ```text //! lightcraft-cli run [--demo | --library DIR | --connect [ADDR]] [--import PATH]… CMD [key=value…]… //! lightcraft-cli mcp [--connect [ADDR]] [--demo] [--compact] [FILES/FOLDERS…] -//! lightcraft-cli render -o [--set control=value]… [--settings FILE.json] [--preset ID] [--size N] [--quality Q] +//! lightcraft-cli render -o [--set control=value]… [--settings FILE.json|FILE.lcpreset] [--preset ID] [--size N] [--quality Q] //! lightcraft-cli snapshot [--library DIR | --demo] [--script FILE.jsonl] [-o OUT.png] [--size WxH] [--scale S] [FILES…] -//! lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… +//! lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… //! lightcraft-cli synth-merge hdr|panorama -o DIR //! lightcraft-cli denoise-serve [--model ID] [--models DIR] [--listen ADDR] [--token-env VAR] [--run-on auto|gpu|cpu] //! lightcraft-cli commands [--json] @@ -17,7 +17,7 @@ mod alloc_release; -use std::io::{BufReader, Write}; +use std::io::Write; use std::path::Path; use std::process::ExitCode; @@ -55,7 +55,8 @@ USAGE: Develop one file and export it (.jpg, .png, .tif, .webp, .avif, .jxl, .psd or .dng by extension) with the same encoder as the app's Export dialog. Options: --set CONTROL=VALUE set a develop slider, repeatable (e.g. --set light.exposure=0.5) - --settings FILE merge a partial develop-settings JSON file + --settings FILE merge a partial develop-settings JSON file, or apply the one preset of a + .lcpreset (preset.export) or XMP preset --preset ID apply a preset (see `commands`/presets.list) --size N long edge in pixels (default: full size, cropped) --quality Q JPEG/AVIF/JPEG XL quality 1..100 (default 92) @@ -79,9 +80,10 @@ USAGE: -o, --output OUT PNG path (a final screenshot is written here if the script took none) --size WxH window size in points (default 1600x1000) --scale S pixels per point (default 1) - lightcraft-cli merge hdr|panorama|hdr-panorama [OPTIONS] FILES… + lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [OPTIONS] FILES… Photo Merge: writes -HDR.dng / -Pano.dng / -HDR-Pano.dng next to the first file and - prints the result as JSON. Options: + prints the result as JSON. hdr-batch merges each bracket set among the files (photos taken + less than a second apart at different exposures), e.g. a whole folder. Options: --deghost none|low|medium|high --no-align --bracket N (HDR panorama) --projection auto|spherical|cylindrical|perspective --boundary-warp 0..100 --auto-crop --fill-edges --no-auto-settings @@ -364,8 +366,7 @@ fn mcp(args: &[String]) -> Result<(), String> { }; eprintln!("lightcraft-cli mcp: serving MCP on stdio ({})", backend.describe()); let mut server = Server::new(backend).with_command_tools(!compact); - let stdin = std::io::stdin(); - server.serve(BufReader::new(stdin), std::io::stdout()).map_err(|e| e.to_string()) + server.serve_stdio(std::io::stdout()).map_err(|e| e.to_string()) } fn merge(args: &[String]) -> Result<(), String> { @@ -373,7 +374,8 @@ fn merge(args: &[String]) -> Result<(), String> { Some("hdr") => "merge.hdr", Some("panorama" | "pano") => "merge.panorama", Some("hdr-panorama") => "merge.hdrPanorama", - _ => return Err("merge: expected hdr, panorama or hdr-panorama".into()), + Some("hdr-batch") => "merge.hdrBatch", + _ => return Err("merge: expected hdr, panorama, hdr-panorama or hdr-batch".into()), }; let mut p = serde_json::Map::new(); let mut files = Vec::new(); @@ -635,11 +637,41 @@ fn connect_port(addr: &str) -> &str { addr.rsplit_once(':').map_or(addr, |(_, port)| port) } +/// What `render --settings FILE` applies: a partial develop-settings object, or the one preset of +/// a preset file (`.lcpreset` from `preset.export`, XMP), applied as the app applies it. Merging a +/// `.lcpreset` as settings dropped its wrapper and applied nothing (#1730). +enum SettingsFile { + Partial(Value), + Preset(lightcraft_engine::develop::Preset), +} + +fn read_settings(p: &str) -> Result { + use lightcraft_engine::presets::{LCPRESET_EXT, parse_preset_file}; + let bytes = std::fs::read(p).map_err(|e| format!("{p}: {e}"))?; + let ext = Path::new(p).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); + let json = serde_json::from_slice::(&bytes); + // a settings object never has a top-level `presets` or `settings` key; a preset file always does + let preset_file = + ext == LCPRESET_EXT || ext == "xmp" || json.as_ref().is_ok_and(|v| v.is_array() || v.get("presets").is_some() || v.get("settings").is_some()); + if preset_file { + let mut list = parse_preset_file(p, &bytes).map_err(|e| format!("{p}: {e}"))?; + return match (list.pop(), list.len()) { + (Some(preset), 0) => Ok(SettingsFile::Preset(preset)), + (None, _) => Err(format!("{p}: no presets in this file")), + (Some(_), n) => Err(format!("{p}: {} presets in this file; --settings applies one, export it on its own", n + 1)), + }; + } + match json.map_err(|e| format!("{p}: {e}"))? { + v @ Value::Object(_) => Ok(SettingsFile::Partial(v)), + _ => Err(format!("{p}: not a develop-settings object or a preset file")), + } +} + fn render(args: &[String]) -> Result<(), String> { let mut input = None; let mut output = None; let mut values = serde_json::Map::new(); - let mut settings: Option = None; + let mut settings = None; let mut preset = None; let mut size: Option = None; let mut quality = 92u8; @@ -654,11 +686,7 @@ fn render(args: &[String]) -> Result<(), String> { let v: f64 = v.trim().parse().map_err(|_| format!("--set {k}: `{v}` is not a number"))?; values.insert(k.trim().to_string(), json!(v)); } - "--settings" => { - let p = take_value(args, &mut i, "--settings")?; - let text = std::fs::read_to_string(p).map_err(|e| format!("{p}: {e}"))?; - settings = Some(serde_json::from_str(&text).map_err(|e| format!("{p}: {e}"))?); - } + "--settings" => settings = Some(read_settings(take_value(args, &mut i, "--settings")?)?), "--preset" => preset = Some(take_value(args, &mut i, "--preset")?.to_string()), "--size" => size = Some(take_value(args, &mut i, "--size")?.parse().map_err(|_| "--size expects a number")?), "--quality" => quality = take_value(args, &mut i, "--quality")?.parse().map_err(|_| "--quality expects 1..100")?, @@ -684,8 +712,14 @@ fn render(args: &[String]) -> Result<(), String> { if let Some(p) = preset { run(&mut s, "preset.apply", json!({"id": p}))?; } - if let Some(st) = settings { - run(&mut s, "develop.merge", json!({"settings": st}))?; + match settings { + Some(SettingsFile::Partial(st)) => run(&mut s, "develop.merge", json!({"settings": st}))?, + Some(SettingsFile::Preset(mut p)) => { + p.id = "cli.settings".into(); + s.presets.push(p); + run(&mut s, "preset.apply", json!({"id": "cli.settings"}))?; + } + None => {} } if !values.is_empty() { run(&mut s, "develop.set", json!({"values": values}))?; @@ -763,12 +797,15 @@ fn snapshot(args: &[String]) -> Result<(), String> { let t0 = Instant::now(); let mut session = match &library { Some(dir) => { - let mut s = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); + let mut s = + Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(); s.open_library(dir, false).map_err(|e| library_error(dir, e))?; s } - None if files.is_empty() => Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), - None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(), + None if files.is_empty() => { + Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates() + } + None => Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models().with_default_slide_templates(), }; // Apply compute policy before imports or the first scripted query can discover an adapter. session.execute("app.gpu", &json!({"enabled": false})).map_err(|e| e.to_string())?; @@ -791,6 +828,8 @@ fn snapshot(args: &[String]) -> Result<(), String> { // before the first frame can start background adapter discovery. // This UI state is session-local: the CLI neither loads nor saves desktop ui.json. app.ui.settings.gpu = false; + // as the desktop app opens it: an empty library on the grid, where Import is offered + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); let mut h = Headless::new(app, size, scale); let timeout = Duration::from_secs(60); let mut shots = 0usize; @@ -832,7 +871,17 @@ fn snapshot(args: &[String]) -> Result<(), String> { match next_path(&mut shots) { Some(p) => { wrote_output = true; - params["path"] = json!(p); + // `params` is untrusted script JSON: `params["path"] = …` panics on + // an array or a string. Only an object (or null, like an absent + // `params`) may take the path. + if params.is_null() { + params = json!({}); + } + if let Some(o) = params.as_object_mut() { + o.insert("path".into(), json!(p)); + } else { + return Err(format!("{script}:{}: ui.screenshot: `params` must be a JSON object", n + 1)); + } } None => return Err(format!("{script}:{}: ui.screenshot needs a `path` (or give -o)", n + 1)), } diff --git a/apps/lightcraft-cli/tests/cli.rs b/apps/lightcraft-cli/tests/cli.rs index bf56b02ab..8f56bafe6 100644 --- a/apps/lightcraft-cli/tests/cli.rs +++ b/apps/lightcraft-cli/tests/cli.rs @@ -47,6 +47,48 @@ fn contact_sheet_exports_pdf_and_protects_originals() { assert_eq!(std::fs::read(&out).unwrap(), prior, "failed export changed prior output"); } +#[test] +fn print_commands_chain_into_a_picture_package_pdf() { + let input = tmp("print-input.png"); + gradient_png(&input); + let out = tmp("print.pdf"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "print.set", + r#"layout={"style":"picturePackage","page":{"paper":"4x6","orientation":"landscape"}}"#, + r#"image={"zoomToFill":true,"borderEnabled":true,"borderWidth":0.05}"#, + "job={\"ppi\":72}", + "print.cell.add", + "width=1.5", + "height=1", + "print.cell.add", + "width=1.5", + "height=1", + "print.layout.autoLayout", + "print.exportPdf", + &format!("path={}", out.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let export = lines.last().unwrap(); + assert_eq!((export["result"]["pages"].as_u64(), export["result"]["photos"].as_u64()), (Some(1), Some(1))); + let bytes = std::fs::read(&out).unwrap(); + assert!(bytes.starts_with(b"%PDF-")); + let text = String::from_utf8_lossy(&bytes); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (432.0, 288.0), "6 x 4 in"); + // a bad setting fails the run and writes nothing new + let before = std::fs::read(&out).unwrap(); + let (ok, _, _) = + run_cli(&["--import", input.to_str().unwrap(), "print.set", "job={\"ppi\":5}", "print.exportPdf", &format!("path={}", out.display())], None); + assert!(!ok); + assert_eq!(std::fs::read(&out).unwrap(), before); +} + fn tmp(name: &str) -> std::path::PathBuf { let d = std::env::temp_dir().join(format!("lightcraft-cli-test-{}", std::process::id())); std::fs::create_dir_all(&d).unwrap(); @@ -139,6 +181,51 @@ fn render_subcommand() { assert!(String::from_utf8_lossy(&o.stderr).contains("unknown control")); } +/// Issue #1730: `render --settings` took a `.lcpreset` from `preset.export`, ignored its wrapper +/// and rendered the photo unedited. +#[test] +fn render_settings_applies_an_lcpreset_and_refuses_unknown_files() { + let input = tmp("lcpreset-in.png"); + gradient_png(&input); + let preset = tmp("bright.lcpreset"); + let (ok, lines, err) = run_cli( + &[ + "--import", + input.to_str().unwrap(), + "develop.set", + "control=light.exposure", + "value=1", + "preset.create", + "name=Bright", + "preset.export", + &format!("path={}", preset.display()), + ], + None, + ); + assert!(ok, "{err}: {lines:?}"); + let plain = tmp("lcpreset-plain.png"); + let st = Command::new(BIN).args(["render", input.to_str().unwrap(), "-o", plain.to_str().unwrap()]).status().unwrap(); + assert!(st.success()); + let bright = tmp("lcpreset-bright.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", bright.to_str().unwrap(), "--settings", preset.to_str().unwrap()]) + .output() + .unwrap(); + assert!(o.status.success(), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(mean(&bright) > mean(&plain) + 10.0, "the preset's exposure was not applied"); + + let other = tmp("other.lcpreset"); + std::fs::write(&other, r#"{"format": "other", "version": 1, "presets": []}"#).unwrap(); + let out = tmp("lcpreset-other.png"); + let o = Command::new(BIN) + .args(["render", input.to_str().unwrap(), "-o", out.to_str().unwrap(), "--settings", other.to_str().unwrap()]) + .output() + .unwrap(); + assert!(!o.status.success(), "an unreadable settings file was accepted"); + assert!(String::from_utf8_lossy(&o.stderr).contains("other.lcpreset"), "{}", String::from_utf8_lossy(&o.stderr)); + assert!(!out.exists(), "nothing is written when the settings can't be read"); +} + /// Issue #93: `render IMG -o IMG` replaced its own input with the render. #[test] fn render_refuses_to_overwrite_its_input() { @@ -273,6 +360,24 @@ fn snapshot_script_failure_exits_non_zero() { assert_eq!((d.width, d.height), (320, 200)); } +/// A script line whose `params` is not an object must be an error, never a panic: the +/// `ui.screenshot`-without-a-path branch used to index (`params["path"] = …`) an array and abort +/// the whole script. +#[test] +fn snapshot_script_with_non_object_params_is_an_error() { + let script = tmp("snap-bad-params.jsonl"); + let out = tmp("snap-bad-params.png"); + std::fs::write(&script, format!("{}\n", json!({"method": "ui.screenshot", "params": [1]}))).unwrap(); + let o = Command::new(BIN) + .args(["snapshot", "--demo", "--script", script.to_str().unwrap(), "-o", out.to_str().unwrap(), "--size", "320x200"]) + .output() + .unwrap(); + let stderr = String::from_utf8_lossy(&o.stderr); + assert!(!o.status.success(), "{stderr}"); + assert!(!stderr.contains("panicked"), "a bad `params` must be an error, not a panic: {stderr}"); + assert!(stderr.contains("`params` must be a JSON object"), "{stderr}"); +} + #[test] fn snapshot_ui_zoom_keeps_requested_pixel_dimensions() { let script = tmp("snap-ui-zoom.jsonl"); diff --git a/apps/lightcraft-cli/tests/mcp_shutdown.rs b/apps/lightcraft-cli/tests/mcp_shutdown.rs new file mode 100644 index 000000000..29d1ace93 --- /dev/null +++ b/apps/lightcraft-cli/tests/mcp_shutdown.rs @@ -0,0 +1,38 @@ +//! A broken protocol output must not require the client to close its input pipe. + +use std::io::{Read, Write}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +#[test] +fn broken_stdout_exits_with_stdin_still_open() { + let mut child = Command::new(env!("CARGO_BIN_EXE_lightcraft-cli")) + .arg("mcp") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + drop(child.stdout.take()); + input.write_all(b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n{\"jsonrpc\":").unwrap(); + input.flush().unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("MCP did not exit after stdout failed while stdin stayed open"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stderr = String::new(); + child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); + assert!(stderr.contains("serving MCP on stdio"), "CLI must reach the MCP transport: {stderr}"); + assert!(!status.success(), "broken protocol output must be reported: {stderr}"); + assert!(stderr.contains("lightcraft-cli:"), "CLI must report the transport error: {stderr}"); + drop(input); +} diff --git a/apps/lightcraft-web/src/web.rs b/apps/lightcraft-web/src/web.rs index 18c896367..6a4487d09 100644 --- a/apps/lightcraft-web/src/web.rs +++ b/apps/lightcraft-web/src/web.rs @@ -219,6 +219,8 @@ fn services(originals: Originals, backend: Option, files: Files, frozen save_keyword_list: None, pick_curve_preset_files: None, save_curve_preset_file: None, + pick_slideshow_templates: None, + save_slideshow_template: None, write: Some(Box::new(download)), // downloads happen on the main thread: exports run in the foreground on the web write_shared: None, diff --git a/apps/lightcraft-web/src/wire.rs b/apps/lightcraft-web/src/wire.rs index b3d685f5f..08f9e898d 100644 --- a/apps/lightcraft-web/src/wire.rs +++ b/apps/lightcraft-web/src/wire.rs @@ -41,6 +41,9 @@ pub struct WireJob { pub as_shot_temp: f64, pub as_shot_tint: f64, pub lens: Option, + /// The original's long edge (`SourceInfo::native_long`). + #[serde(default)] + pub native_long: Option, pub settings: Arc, pub max_w: usize, pub max_h: usize, @@ -80,6 +83,7 @@ impl WireJob { as_shot_temp: job.info.as_shot_temp, as_shot_tint: job.info.as_shot_tint, lens: job.info.lens, + native_long: job.info.native_long, settings: job.settings.clone(), max_w: job.request.max_w, max_h: job.request.max_h, @@ -101,6 +105,7 @@ impl WireJob { lens: self.lens, relative_wb: self.relative_wb, legacy_relative_wb: self.legacy_relative_wb, + native_long: self.native_long, ..Default::default() } } @@ -469,6 +474,7 @@ mod tests { as_shot_temp: 6500.0, as_shot_tint: 0.0, lens: None, + native_long: None, settings: Arc::new(DevelopSettings::default()), max_w: 32, max_h: 32, diff --git a/apps/lightcraft-web/src/workers.rs b/apps/lightcraft-web/src/workers.rs index f1523c377..a5e64886e 100644 --- a/apps/lightcraft-web/src/workers.rs +++ b/apps/lightcraft-web/src/workers.rs @@ -161,6 +161,8 @@ struct Busy { level: SourceLevel, key: u64, cache: Option<(Arc, Hash128)>, + /// A slideshow slide: the worker renders the photo, it is composed here when it comes back. + slide: Option>, } struct W { @@ -370,6 +372,13 @@ impl Workers { }); } g.remote_done += 1; + let rendered = match (rendered, &b.slide) { + (Ok(mut r), Some(slide)) => { + r.image = slide.compose(&r.image); + Ok(r) + } + (r, _) => r, + }; g.done.push(( b.slot, RenderResult { @@ -493,6 +502,7 @@ impl RenderOffload for Workers { level: job.level, key: job.key, cache: job.cache, + slide: job.slide, }); None } diff --git a/apps/lightcraft/Cargo.toml b/apps/lightcraft/Cargo.toml index bbe495c7d..26122c3e6 100644 --- a/apps/lightcraft/Cargo.toml +++ b/apps/lightcraft/Cargo.toml @@ -48,3 +48,7 @@ muda = { workspace = true } objc2 = { workspace = true } objc2-app-kit = { workspace = true } objc2-foundation = { workspace = true } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/apps/lightcraft/src/appearance.rs b/apps/lightcraft/src/appearance.rs index 74333a301..223dbc560 100644 --- a/apps/lightcraft/src/appearance.rs +++ b/apps/lightcraft/src/appearance.rs @@ -51,7 +51,7 @@ fn decode(code: u32) -> Option { /// absolute path, and is never repeated. #[cfg(target_os = "linux")] fn gtk_theme() -> Option { - let exe = ["/usr/bin/gsettings", "/bin/gsettings", "/usr/local/bin/gsettings"].into_iter().find(|p| std::path::Path::new(p).is_file())?; + let exe = gsettings_exe(std::env::var_os("PATH").as_deref())?; let output = std::process::Command::new(exe).args(["get", "org.gnome.desktop.interface", "color-scheme"]).output().ok()?; if !output.status.success() { return None; @@ -59,6 +59,15 @@ fn gtk_theme() -> Option { parse_gtk_scheme(std::str::from_utf8(&output.stdout).ok()?) } +/// `gsettings` by absolute path: the absolute `PATH` entries first (NixOS has no /usr/bin/gsettings), +/// then the usual places for a sparse `PATH`. Relative entries are skipped, so nothing runs from +/// the working folder. +#[cfg(target_os = "linux")] +fn gsettings_exe(path: Option<&std::ffi::OsStr>) -> Option { + let listed = path.map(std::env::split_paths).into_iter().flatten().filter(|dir| dir.is_absolute()); + listed.chain(["/usr/bin", "/bin", "/usr/local/bin"].map(std::path::PathBuf::from)).map(|dir| dir.join("gsettings")).find(|exe| exe.is_file()) +} + #[cfg(target_os = "linux")] fn parse_gtk_scheme(s: &str) -> Option { match s.trim().trim_matches('\'') { @@ -174,6 +183,24 @@ mod tests { assert_eq!(calls.load(Ordering::Relaxed), after_settle); } + /// NixOS keeps `gsettings` in /run/current-system/sw/bin or the per-user profile: it is found + /// on `PATH`, but never through a relative entry, even one that leads to a real file. + #[cfg(target_os = "linux")] + #[test] + fn gsettings_is_found_on_absolute_path_entries_only() { + let dir = std::env::temp_dir().join(format!("lightcraft-gsettings-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("gsettings"), "").unwrap(); + let path = std::env::join_paths([std::path::Path::new("relative/bin"), &dir]).unwrap(); + assert_eq!(super::gsettings_exe(Some(&path)), Some(dir.join("gsettings"))); + let cwd = std::env::current_dir().unwrap(); + let relative = std::path::PathBuf::from("../".repeat(cwd.components().count() - 1)).join(dir.strip_prefix("/").unwrap()); + assert!(relative.join("gsettings").is_file()); + let found = super::gsettings_exe(Some(relative.as_os_str())); + assert!(found.as_ref().is_none_or(|p| p.is_absolute()), "{found:?}"); + let _ = std::fs::remove_dir_all(&dir); + } + #[cfg(target_os = "linux")] #[test] fn portal_values_and_gsettings_output_decode() { diff --git a/apps/lightcraft/src/main.rs b/apps/lightcraft/src/main.rs index 1c70e19ea..95739b3e9 100644 --- a/apps/lightcraft/src/main.rs +++ b/apps/lightcraft/src/main.rs @@ -76,10 +76,13 @@ impl eframe::App for App { fn raw_input_hook(&mut self, _ctx: &egui::Context, raw: &mut egui::RawInput) { self.0.raw_input_hook(raw); } - fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) { + fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) { + self.0.perf.host_frame_ms = frame.info().cpu_usage.map(|s| f64::from(s) * 1000.0); self.0.ui(ui); } fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) { + // (a size the window was just given is remembered too) + self.0.ui.window = self.1.window_at_exit(self.0.ui.window); if let Err(e) = self.1.save(&self.0) { log::error!("{e}"); } @@ -162,6 +165,46 @@ fn gpu_crash_notice(what: &str) -> String { ) } +/// Written when the window stopped drawing after the GPU device was lost (issue #1337): the next +/// launch renders on the CPU and says why. +fn gpu_lost_marker_path() -> Option { + gpu_marker_path().map(|p| p.with_file_name("gpu-lost.marker")) +} + +/// Why the GPU was lost, when the GPU pipeline stopped after a device failure in this process (a +/// driver reset, a hang, the Windows watchdog): `None` for other reasons it is off, or when it +/// never failed. +fn gpu_lost_reason() -> Option { + lightcraft_engine::gpu::unavailable_reason().and_then(|r| r.strip_prefix("stopped after a GPU failure: ").map(str::to_string)) +} + +/// Record that LightCraft stopped after the GPU was lost, for the next launch. +fn record_gpu_lost(marker: Option<&std::path::Path>, reason: &str) { + let Some(m) = marker else { return }; + if let Some(d) = m.parent() { + let _ = std::fs::create_dir_all(d); + } + if let Err(e) = std::fs::write(m, format!("{reason}\n")) { + log::warn!("could not write {}: {e}", m.display()); + } +} + +/// A GPU loss the last launch stopped on: its notice, once (the marker is removed, except by a +/// `--memory` session, which leaves the disk as it found it). +fn gpu_lost_check_at(marker: Option<&std::path::Path>, in_memory: bool) -> Option { + let m = marker?; + let reason = std::fs::read_to_string(m).ok()?; + if !in_memory { + let _ = std::fs::remove_file(m); + } + Some(format!( + "LightCraft stopped last time after the graphics driver reset the GPU ({}) while photos rendered on it, so GPU rendering \ + is now off and photos render on the CPU. To try the GPU again, turn on Settings ▸ Performance ▸ Use the GPU for rendering; \ + an up-to-date graphics driver may help.", + reason.trim() + )) +} + /// Where the system keeps monitor profiles, for the Choose Profile dialog to start in: the user's /// own profiles first (Linux: colord / DisplayCAL; macOS: ColorSync), then the system's. fn display_profile_dir() -> Option { @@ -183,6 +226,16 @@ fn config_dir() -> Option { lightcraft_engine::config::config_dir() } +/// The window at the size it was last used at, zoomed if it was (`window`), or at the usual size +/// when nothing is remembered. A zoomed window is given the size it goes back to. Where it +/// opens is the system's to say. +fn as_remembered(builder: egui::ViewportBuilder, window: Option) -> egui::ViewportBuilder { + let builder = builder.with_inner_size(window.map_or(lightcraft_ui_egui::window_place::DEFAULT_SIZE, |w| w.size)); + // (only ever asked to zoom: asked not to, macOS un-zooms a window whose size fills the screen + // to a size of its own choosing) + if window.is_some_and(|w| w.zoomed) { builder.with_maximized(true) } else { builder } +} + /// `/ui.json`, the saved UI state and app settings — `None` when nothing is read or /// written: `LIGHTCRAFT_NO_PREFS` (tests, scripts) or no home folder. fn prefs_path() -> Option { @@ -301,6 +354,11 @@ struct PrefsWriter { failing: bool, /// `ui.json` couldn't be read at launch: never overwrite it this session. keep_file: bool, + /// Follows the window, to remember the size it rests at (`UiState::window`). + window: lightcraft_ui_egui::window_place::WindowWatch, + /// When the window was first looked at: its watch goes by the clock from then on, not by + /// the interface's time, which stands still while the window is hidden. + window_since: Option, } impl PrefsWriter { @@ -331,8 +389,33 @@ impl PrefsWriter { Ok(()) } + /// What to remember of the window as the app quits ([`WindowWatch::at_exit`]). + /// + /// [`WindowWatch::at_exit`]: lightcraft_ui_egui::window_place::WindowWatch::at_exit + fn window_at_exit( + &mut self, + remembered: Option, + ) -> Option { + match self.window_since { + Some(since) => self.window.at_exit(remembered, since.elapsed().as_secs_f64()), + None => remembered, + } + } + fn tick(&mut self, app: &mut LightcraftApp, ctx: &egui::Context) { - let now = ctx.input(|i| i.time); + // (asked for before the input is read: the context is not asked from inside its own lock) + let zoom = ctx.zoom_factor(); + let (now, seen) = ctx.input(|i| (i.time, lightcraft_ui_egui::window_place::Seen::of(i.viewport(), i.content_rect(), zoom))); + // how the window is, to open like that next time (written with the settings below). What + // the settings hold is checked first: a script may have set it (`ui.set`) + let remembered = app.ui.window.and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); + let at = self.window_since.get_or_insert_with(std::time::Instant::now).elapsed().as_secs_f64(); + app.ui.window = self.window.note(remembered, &seen, at); + if let Some(wait) = self.window.waits(at) { + // (a size is only remembered once the window has rested at it: look again then, though + // nothing else asks for a frame) + ctx.request_repaint_after(wait); + } let moved = app.ui.settings.library_path != self.library; if !moved && now - self.checked < 3.0 { return; @@ -603,6 +686,24 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> .save_file() .map(|p| p.to_string_lossy().to_string()) })), + pick_slideshow_templates: Some(Box::new(|| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Import Slideshow Templates")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .pick_files() + .unwrap_or_default() + .into_iter() + .map(|p| p.to_string_lossy().to_string()) + .collect() + })), + save_slideshow_template: Some(Box::new(|name: &str| { + rfd::FileDialog::new() + .set_title(lightcraft_ui_egui::i18n::tr("Export Slideshow Template")) + .add_filter_nocase(lightcraft_ui_egui::i18n::tr("Slideshow Templates"), &["json"]) + .set_file_name(name) + .save_file() + .map(|p| p.to_string_lossy().to_string()) + })), // atomic (temp file + sync + rename): a failed write never leaves a truncated file write_shared: Some(std::sync::Arc::new(lightcraft_engine::export::write_file)), write: Some(Box::new(lightcraft_engine::export::write_file)), @@ -616,10 +717,12 @@ fn services(cc: &eframe::CreationContext<'_>, log_file: Option<&std::path::Path> } } -/// The library session with the folder face models are kept in (`/models`). +/// The library session with the folders face models (`/models`) and slideshow templates +/// (`/slideshow-templates`) are kept in. fn open_session(in_memory: bool, dir: Option, seed_demo: bool) -> (Session, Option) { let (mut s, problem) = open_library_session(in_memory, dir, seed_demo); s.face_models_dir = lightcraft_engine::config::default_face_models_dir(); + s.slide_templates_dir = lightcraft_engine::config::config_dir().map(|d| d.join(lightcraft_engine::slide_template::DIR_NAME)); // Control requests are answered on the UI thread: Add to Apple Photos must not wait there s.apple_photos_background = true; (s, problem) @@ -808,7 +911,7 @@ fn main() -> eframe::Result { let library_dir = library_dir.or_else(lightcraft_engine::library::default_dir); // GPU compute: off if the preference says so, or if the last launch died creating the device // (issue #136) — before anything can create it - let gpu_crash = gpu_crash_check(in_memory); + let gpu_crash = gpu_crash_check(in_memory).or_else(|| gpu_lost_check_at(gpu_lost_marker_path().as_deref(), in_memory)); let gpu_on = prefs.as_ref().is_none_or(|u| u.settings.gpu) && gpu_crash.is_none(); lightcraft_engine::gpu::set_enabled(gpu_on); // the window's renderer: probed (Windows) and with a fallback chain, so a driver that can't @@ -817,12 +920,15 @@ fn main() -> eframe::Result { let window_gpu::Chosen { renderer, wgpu_options, which: window_renderer, notice: window_notice } = window_gpu::choose(&win_files); win_files.arm(window_renderer); let win_files_app = win_files.clone(); + // the window came up: a failure after this is LightCraft stopping, not a start that failed + let came_up = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let came_up_app = came_up.clone(); + // how the window was when the app was last used (checked: the settings file is a file) + let opens_as = prefs.as_ref().and_then(|ui| ui.window).and_then(lightcraft_ui_egui::window_place::WindowPlace::checked); let options = eframe::NativeOptions { renderer, - viewport: egui::ViewportBuilder::default() - .with_title("LightCraft") - .with_inner_size([1600.0, 1000.0]) - .with_min_inner_size([900.0, 560.0]) + viewport: as_remembered(egui::ViewportBuilder::default().with_title("LightCraft"), opens_as) + .with_min_inner_size(lightcraft_ui_egui::window_place::MIN_SIZE) .with_drag_and_drop(true) .with_fullsize_content_view(true) .with_titlebar_shown(false) @@ -854,6 +960,7 @@ fn main() -> eframe::Result { Box::new(move |cc| { // the window's renderer is up (eframe calls this after creating it) win_files_app.started(window_renderer); + came_up_app.store(true, std::sync::atomic::Ordering::Relaxed); let (mut session, problem) = open_session(in_memory, library_dir, seed_demo && files.is_empty()); // AI masks: the SAM 3 checkpoint (facebook/sam3) in /models/sam3, or LIGHTCRAFT_SAM3_DIR // (never required: without it, AI masks offer to download it; see docs/ai-masks.md) @@ -871,6 +978,11 @@ fn main() -> eframe::Result { app.notices.extend(launch_notices); app.notices.extend(window_notice); app.gpu_notice = true; + // an empty library opens where Import is offered (a grid, with the sidebar); before + // the snapshot below, so this alone isn't written to the settings file + if problem.is_none() && files.is_empty() { + lightcraft_ui_egui::panels::empty_library::open_on_empty_library(&mut app); + } // what's on disk now: only changes are written let writer = PrefsWriter { path: if in_memory { None } else { prefs_path() }, @@ -915,6 +1027,31 @@ fn main() -> eframe::Result { }), ) })); + if came_up.load(std::sync::atomic::Ordering::Relaxed) { + // the window was up: a panic that escaped it now is LightCraft stopping, typically the + // window's renderer after the graphics driver reset the GPU (issue #1337: egui-wgpu panics + // on the lost device). Not a failed start: no other renderer is tried, and the message + // says what happened. + let stopped = match started { + Ok(r) => r, + Err(_) => Err(eframe::Error::AppCreation("its window stopped drawing (the renderer panicked)".into())), + }; + if let Err(e) = &stopped { + let lost = gpu_lost_reason(); + if let Some(reason) = &lost + && !in_memory + { + record_gpu_lost(gpu_lost_marker_path().as_deref(), reason); + } + // (not "app creation error: …": the app had been created) + let what = match e { + eframe::Error::AppCreation(m) => m.to_string(), + e => e.to_string(), + }; + stopped_while_running(&stopped_text(&what, lost.as_deref()), log_file.as_deref()); + } + return stopped; + } // a panic that still escaped the renderer's start (the probe and error handler should // prevent it): an error like any other failed start let started = started.unwrap_or_else(|_| Err(eframe::Error::AppCreation("the window's renderer panicked while starting".into()))); @@ -929,6 +1066,34 @@ fn main() -> eframe::Result { started } +/// What to tell the user when LightCraft stopped while running (`error`), and why when the GPU +/// was lost (`lost`): GPU rendering is off from the next start. +fn stopped_text(error: &str, lost: Option<&str>) -> String { + match lost { + Some(reason) => format!( + "{error}. The graphics driver reset the GPU ({reason}). LightCraft renders on the CPU from its next start; \ + Settings ▸ Performance ▸ Use the GPU for rendering turns the GPU back on" + ), + None => error.to_string(), + } +} + +/// LightCraft stopped while its window was up: say so in the log and in a native message box +/// naming the log file (as [`startup_failed`] does for a start that failed). +fn stopped_while_running(text: &str, log_file: Option<&std::path::Path>) { + log::error!("LightCraft stopped: {text}"); + if std::env::var_os("LIGHTCRAFT_NO_PREFS").is_some() { + return; // tests and scripts: no dialog to click away + } + let (title, text) = lightcraft_ui_egui::i18n::stopped_message(text, log_file.map(|p| p.display().to_string()).as_deref()); + let _ = rfd::MessageDialog::new() + .set_level(rfd::MessageLevel::Error) + .set_title(title) + .set_description(text) + .set_buttons(rfd::MessageButtons::Ok) + .show(); +} + /// The window could not start (e.g. no usable graphics device): say so in the log and in a native /// message box naming the log file, instead of exiting without a trace — the release builds have /// no console on Windows, and an app started from Finder shows no standard error (issue #260). @@ -1131,6 +1296,37 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Issue #1337: the window stopped drawing after the graphics driver reset the GPU during an + /// export, and the dialog said the window "could not open". The stop is recorded; the next + /// launch says why once and renders on the CPU (a `--memory` session leaves the record). + #[test] + fn a_gpu_lost_while_running_turns_the_gpu_off_next_time() { + let d = dir("gpu-lost"); + let m = d.join("gpu-lost.marker"); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "nothing recorded"); + assert_eq!(gpu_lost_check_at(None, false), None, "no settings folder"); + record_gpu_lost(Some(&m), "device lost (Unknown): Device is lost"); + let notice = gpu_lost_check_at(Some(&m), true).unwrap(); + assert!( + notice.contains("(device lost (Unknown): Device is lost)") + && notice.contains("GPU rendering is now off") + && notice.contains("Use the GPU for rendering"), + "{notice}" + ); + assert!(m.exists(), "a --memory session leaves it"); + assert!(gpu_lost_check_at(Some(&m), false).is_some()); + assert!(!m.exists()); + assert_eq!(gpu_lost_check_at(Some(&m), false), None, "said once"); + // the message box: LightCraft stopped (not "could not open its window"), and why + let text = stopped_text("its window stopped drawing (the renderer panicked)", Some("device lost (Unknown): Device is lost")); + assert!(text.contains("reset the GPU (device lost (Unknown)") && text.contains("renders on the CPU from its next start"), "{text}"); + assert_eq!(stopped_text("its window stopped drawing", None), "its window stopped drawing"); + let (title, body) = lightcraft_ui_egui::i18n::stopped_message(&text, Some("/logs/lightcraft.log")); + assert_eq!(title, "LightCraft stopped"); + assert!(body.starts_with("LightCraft stopped unexpectedly: its window stopped drawing") && body.ends_with("/logs/lightcraft.log"), "{body}"); + let _ = std::fs::remove_dir_all(&d); + } + /// Issue #136: a GPU init marker left by a crashed launch is reported once and removed. #[test] fn gpu_crash_marker_is_reported_once() { @@ -1237,6 +1433,96 @@ mod tests { let _ = std::fs::remove_dir_all(&d); } + /// Given what the settings remember of the window, when the app's window is built, then it + /// opens at that size, zoomed if it was, where the system puts it; with nothing remembered it + /// opens at the usual size. + #[test] + fn the_window_opens_as_it_was() { + use lightcraft_ui_egui::window_place::{DEFAULT_SIZE, WindowPlace}; + let fresh = as_remembered(egui::ViewportBuilder::default(), None); + assert_eq!((fresh.inner_size, fresh.position, fresh.maximized), (Some(egui::Vec2::from(DEFAULT_SIZE)), None, None)); + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: true })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, Some(true))); + // (not asked to un-zoom: macOS would then resize a window that fills its screen) + let b = as_remembered(egui::ViewportBuilder::default(), Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + assert_eq!((b.inner_size, b.position, b.maximized), (Some(egui::vec2(1300.0, 800.0)), None, None)); + } + + /// Given a window that rests at a size, when the settings are written and read again, then + /// they hold how it was. A window in the file that is not one (a hand edit, another version's) + /// reads as nothing remembered and costs no other setting; and a window resized just before + /// the app quits is remembered at that size. + #[test] + fn the_settings_keep_how_the_window_was() { + use lightcraft_ui_egui::window_place::{Seen, WindowPlace}; + let d = dir("window"); + let path = d.join("ui.json"); + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.ui.settings.library_path = "/pictures/library".into(); + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + writer.save(&app).unwrap(); + assert!(!std::fs::read_to_string(&path).unwrap().contains("\"window\""), "nothing to remember yet"); + let window = WindowPlace { size: [1300.0, 800.0], zoomed: true }; + app.ui.window = Some(window); + writer.save(&app).unwrap(); + let (restored, warning, _) = load_prefs_at(&path, false); + assert_eq!((restored.unwrap().window, warning), (Some(window), None)); + + // a window that is no window: the file is read all the same, with its other settings + let text = std::fs::read_to_string(&path).unwrap(); + for odd in [r#""window": {}"#, r#""window": "wide""#, r#""window": {"size": [1, 2, 3]}"#, r#""window": {"size": [null, 800.0]}"#] { + let start = text.find("\"window\"").unwrap(); + let end = start + text[start..].find('}').unwrap() + 1; + std::fs::write(&path, format!("{}{odd}{}", &text[..start], &text[end..])).unwrap(); + let (restored, warning, keep) = load_prefs_at(&path, true); + let restored = restored.unwrap_or_else(|| panic!("{odd}: {warning:?}")); + assert_eq!((restored.window, restored.settings.library_path.as_str(), warning, keep), (None, "/pictures/library", None, false), "{odd}"); + } + + // resized, and the app quits a moment later: the new size is what is written + let mut writer = PrefsWriter { path: Some(path.clone()), ..Default::default() }; + let rest = Some(WindowPlace { size: [1300.0, 800.0], zoomed: false }); + writer.window.note(rest, &Seen { size: Some([1500.0, 900.0]), ..Default::default() }, 0.0); + writer.window_since = std::time::Instant::now().checked_sub(std::time::Duration::from_millis(400)); + assert!(writer.window_since.is_some()); + assert_eq!(writer.window_at_exit(rest), Some(WindowPlace { size: [1500.0, 900.0], zoomed: false })); + assert_eq!(PrefsWriter::default().window_at_exit(rest), rest, "a window that was never seen"); + let _ = std::fs::remove_dir_all(&d); + } + + /// Given the app's window at a size, when the settings writer looks at it frame after frame, + /// then it asks to be woken while the size has still to rest, remembers the size once it has, + /// and asks for nothing more. A window in the settings that is not one (a script set it) is + /// put right before it is gone by. + #[test] + fn the_writer_follows_the_window_from_frame_to_frame() { + use lightcraft_ui_egui::window_place::WindowPlace; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut writer = PrefsWriter::default(); + let ctx = egui::Context::default(); + let pass = |look: &mut dyn FnMut(&egui::Context)| { + let raw = + egui::RawInput { screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1300.0, 800.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| look(ui.ctx())); + out.textures_delta.clear(); + out.viewport_output.get(&egui::ViewportId::ROOT).map(|v| v.repaint_delay) + }; + // (egui's own first frames ask for another: let them pass) + for _ in 0..3 { + pass(&mut |_| {}); + } + app.ui.window = Some(WindowPlace { size: [f32::NAN, 800.0], zoomed: true }); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, None, "no window, and not yet at rest"); + assert!(asked.is_some_and(|wait| wait <= std::time::Duration::from_secs(1)), "{asked:?}"); + // a second later + writer.window_since = writer.window_since.and_then(|since| since.checked_sub(std::time::Duration::from_secs(1))); + pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert_eq!(app.ui.window, Some(WindowPlace { size: [1300.0, 800.0], zoomed: false })); + let asked = pass(&mut |ctx| writer.tick(&mut app, ctx)); + assert!(asked.is_none_or(|wait| wait > std::time::Duration::from_secs(3600)), "at rest and remembered: {asked:?}"); + } + /// #1331: on macOS the process ends inside `on_exit`, before eframe would tear the window down. /// The check runs in a child (this test binary again), since on macOS it ends the process. #[test] diff --git a/apps/lightcraft/src/native_menu.rs b/apps/lightcraft/src/native_menu.rs index 256faa1f8..4ac40bcac 100644 --- a/apps/lightcraft/src/native_menu.rs +++ b/apps/lightcraft/src/native_menu.rs @@ -26,8 +26,9 @@ const QUIT: &str = "app.quit"; const SETTINGS: &str = "app.settings"; const SETTINGS_KEY: &str = "Cmd+,"; /// Keys whose meaning depends on the panel (X = reject or swap crop aspect, ⌫ = delete the photo -/// or the active mask): left to the egui handler, which knows the context. -const CONTEXTUAL: &[&str] = &["X", "Delete"]; +/// or the active mask; ⌘↑/⌘↓ flag status follows Compare/Survey's active photo): left to the egui +/// handler, which knows the context. +const CONTEXTUAL: &[&str] = &["X", "Delete", "Cmd+Up", "Cmd+Down"]; /// Shortcuts text fields need while they have focus. const TEXT_EDIT: &[&str] = &["Cmd+A", "Cmd+C", "Cmd+V", "Cmd+X", "Cmd+Z", "Cmd+Shift+Z"]; @@ -126,6 +127,7 @@ fn accelerator(sc: &str) -> Option { "=" => Code::Equal, "-" => Code::Minus, "'" => Code::Quote, + "`" => Code::Backquote, "," => Code::Comma, "Delete" => Code::Backspace, // with modifiers only: a bare Enter belongs to text fields and tools diff --git a/apps/lightcraft/src/window_gpu.rs b/apps/lightcraft/src/window_gpu.rs index 66e5a78d7..1f84ec477 100644 --- a/apps/lightcraft/src/window_gpu.rs +++ b/apps/lightcraft/src/window_gpu.rs @@ -278,11 +278,11 @@ mod tests { ..wgpu::InstanceDescriptor::new_without_display_handle() }); let Ok(adapter) = pollster::block_on(instance.request_adapter(&wgpu::RequestAdapterOptions::default())) else { - eprintln!("no GPU adapter: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; }; let Ok((device, _queue)) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor::default())) else { - eprintln!("no GPU device: skipped"); + lightcraft_testkit::skip!(Gpu, "no GPU device"); return; }; install_error_handler(&device); diff --git a/assets/ATTRIBUTION.md b/assets/ATTRIBUTION.md index 7ade9dac2..7f8a8a719 100644 --- a/assets/ATTRIBUTION.md +++ b/assets/ATTRIBUTION.md @@ -12,20 +12,28 @@ covers a whole directory), or if a licence file referenced in the Licence column | `assets/app-icon/` (`lightcraft.svg` and the PNG/ICNS/ICO/SVG files derived from it) | LightCraft app icon (lynx) | LightCraft project owner (drawn in ArtCraft) | original work (vectorised from the owner's drawing; see `assets/app-icon/README.md`) | MIT OR Apache-2.0 (`assets/app-icon/LICENSE.txt`) | 2026-10-02 | vectorised to SVG; rendered to PNG/ICNS/ICO by `packaging/icons.sh` | | `assets/camera-profiles/*.json` | Camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | ILCE-7M4: Harald Hoyer | original work: fitted by `lightcraft-cli calibrate` to the contributor's own raw photos and their in-camera JPEGs (597 ILCE-7M4 photos, 2026); aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-07 | none | | `packaging/macos/dmg/` (`background.svg`, the `background.tiff` rendered from it by `generate.py`, and the `dmg-layout.DS_Store` layout file) | LightCraft macOS DMG window background | @XusBadia | original work for this repository, derived from the LightCraft app icon (`assets/app-icon/lightcraft.svg`, referenced via ``, not copied); text is outlined Inter / JetBrains Mono glyph paths | MIT OR Apache-2.0 | 2026-10-08 | none (original); used for the Finder window of the macOS DMG, `packaging/macos/dmg/generate.py` renders it to `background.tiff` | +| `assets/camera-profiles/ILCA-99M2.json` | Sony ILCA-99M2 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @PiMinusMax | original work: fitted by `lightcraft-cli calibrate` to 144 usable ILCA-99M2 ARWs (of 150, seven shooting days, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-11 | none | | `assets/camera-profiles/ILCE-7CR.json` | Sony ILCE-7CR camera colour profile (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by @AlexChambers | original work: fitted by `lightcraft-cli calibrate` to 125 usable ILCE-7CR ARWs (of 131, September 2025 to September 2026, Standard creative style) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-09 | none | | `assets/camera-profiles/X-H2S.json`, `assets/camera-profiles/X-T4.json` | Fujifilm camera colour profiles (matrix + hue/saturation/value table), built into LightCraft | LightCraft contributors; source photographs supplied by Sebastian Pfluegelmeier | original work: fitted by `lightcraft-cli calibrate` to 201 X-H2S and 545 X-T4 RAFs and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-08 | none | +| `assets/camera-profiles/NIKON_Z_6_2.json` | Nikon Z 6II camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 296 usable Z 6II NEFs of my own (of 299, 2024 to 2026, Standard and Auto picture controls; NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_Z_8.json` | Nikon Z 8 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 261 usable Z 8 NEFs of my own (of 278, 2023 and 2026, nearly all Standard picture control, lossless 14-bit; NIKKOR Z 24-120mm f/4 S and AF-S 70-200mm f/2.8G on the FTZ) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | +| `assets/camera-profiles/NIKON_D5300.json` | Nikon D5300 camera colour profile (matrix + hue/saturation/value table), built into LightCraft | Michael Anderson (@endthestart) | original work: fitted by `lightcraft-cli calibrate` to 264 usable D5300 NEFs of my own (of 273, 2014 to 2017, mostly Standard picture control, lossy 12-bit, mostly the AF-S DX 35mm f/1.8G) and their in-camera JPEGs; aggregate colour statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 | 2026-10-13 | none | | `crates/raw/src/spectral_table.rs` | Camera colour matrices (DNG ColorMatrix1/2 and ForwardMatrix1/2 for Standard Illuminant A and D65) for 52 camera models, and the model alias list | Derived by LightCraft contributors from rawtoaces-data (Academy Software Foundation): camera spectral sensitivities measured by Weta Digital (44 cameras), scitech (7) and Jonathon Irons at the National Physical Laboratory (Nikon D5100); training reflectances; CIE 1931 colour-matching functions (CIE, CC-BY-4.0, as shipped in rawtoaces-data) | https://github.com/AcademySoftwareFoundation/rawtoaces-data, tag v1.1.0, commit 5cfa53707b053594b2d0d48e8019fb56e6582d6c | Apache-2.0 (`LICENSE-APACHE`); the colour-matching functions CC-BY-4.0 | 2026-10-08 | generated by `tools/spectral_camera_table.py`: a 3×3 matrix per illuminant fitted to the spectral data (no measurement copied verbatim); aliases copied from `data/aliases.json`; no Adobe or third-party camera matrix | | `assets/camera-tone/sony-dro-off.json` | Sony global tone curve with the Dynamic Range Optimizer off (32 knots), built into LightCraft for ARWs shot with DRO on | LightCraft contributors; source photographs by raw.pixls.us contributors | original work: tone curve quantile-matched over the pooled pixel pairs of 12 CC0 raw.pixls.us ARWs (DRO off, Standard creative style; ILCE-3500, -5000, -6600, -7CR, -7M3 ×3, -7S, -9M2, ILCA-77M2, SLT-A57, NEX-F3) and their embedded camera JPEGs; the file lists each source URL and SHA-256 and how the curve was derived (see also `docs/camera-preview-colour.md`); aggregate tone statistics only, no image content; no Adobe or third-party profile data | MIT OR Apache-2.0 (sources CC0) | 2026-10-09 | none | | `crates/scenes/` (generated images) | Procedural demo photographs | LightCraft contributors | original work (generated at runtime, no source imagery) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `crates/ui-egui/src/icons.rs` | UI icons drawn as vector paths in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-09-30 | n/a | +| `crates/ui-egui/src/panels/person.rs` | Up arrow on the People page bulk-confirm control, drawn in code | LightCraft contributors | original work | MIT OR Apache-2.0 | 2026-10-13 | n/a | | `docs/images/*-tetons.jpg`, `docs/images/grid-pd.jpg` (screenshots containing it) | "The Tetons and the Snake River" | Ansel Adams (1942), U.S. National Archives | https://commons.wikimedia.org/wiki/File:Adams_The_Tetons_and_the_Snake_River.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-migrant-mother.jpg`, `docs/images/grid-pd.jpg` | "Migrant Mother" | Dorothea Lange (1936), Farm Security Administration / Library of Congress | https://commons.wikimedia.org/wiki/File:Lange-MigrantMother02.jpg | Public domain (U.S. federal government work) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-earthrise.jpg`, `docs/images/grid-pd.jpg` | "Earthrise" (AS08-14-2383) | NASA / Bill Anders, Apollo 8 (1968) | https://commons.wikimedia.org/wiki/File:NASA-Apollo8-Dec24-Earthrise.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*-blue-marble.jpg`, `docs/images/grid-pd.jpg` | "The Blue Marble" (AS17-148-22727) | NASA, Apollo 17 crew (1972) | https://commons.wikimedia.org/wiki/File:The_Earth_seen_from_Apollo_17.jpg | Public domain (NASA) | 2026-09-30 | developed in LightCraft; shown inside app screenshots | | `docs/images/*.jpg` (UI) | LightCraft application screenshots | LightCraft contributors | original work (captured with `docs/showcase/`) | MIT OR Apache-2.0 | 2026-09-30 | n/a | | `docs/brand/` (artcraft-logo and artcraft-mark, SVG and PNG) | ArtCraft wordmark and mark | ArtCraft Team | original work (https://getartcraft.com/) | ArtCraft trademark, see `docs/brand/LICENSE-brand.txt` (not open source) | 2026-10-01 | none | -| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular | Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and ships Noto Sans CJK SC Regular as a separate font file | +| craft-fonts: optional build input, not files in this repository | BIZ UDPGothic (Regular, Bold), BIZ UDMincho Regular, Shippori Mincho Regular, Noto Sans CJK SC Regular, Noto Sans Arabic Regular, Noto Sans Thai Regular | The Noto Project Authors (Arabic and Thai); Morisawa Inc. / The BIZ UDGothic and BIZ UDMincho Project Authors; The Shippori Mincho Project Authors; Google and Adobe (Noto Sans CJK, maintainer-approved exception; see AGENTS.md) (per craft-fonts' attribution) | https://github.com/storytold/craft-fonts/blob/main/ATTRIBUTION.md | SIL Open Font License 1.1 (each font's OFL.txt in craft-fonts; release packages ship it as OFL-.txt) | 2026-10-06 | none; native builds made with `CRAFT_FONTS_DIR` embed the manifest fonts (all official releases); the web build embeds BIZ UDPGothic Regular and Noto Sans Thai Regular and ships Noto Sans CJK SC Regular as a separate font file | | `crates/ui-egui/locales/uk.json`, `crates/ui-egui/locales/uk-formats.json` | Ukrainian interface catalogs | @dmatviichuk with Codex; PhotoCraft, FilmCraft and VectorCraft translation contributors for reused labels | original translations from English source; 337 initial English-key matches reviewed from [PhotoCraft 69c41f3](https://github.com/storytold/photocraft/blob/69c41f3523e147eb215da8c7b4c29b18b35d1c63/crates/ui-egui/src/i18n/uk.tsv), [FilmCraft 4854c27](https://github.com/storytold/filmcraft/blob/4854c277536fe8118257362304b6a7d82d8e704b/crates/ui-egui/src/i18n/uk.tsv), [VectorCraft 4b44265](https://github.com/storytold/vectorcraft/blob/4b44265965fdd6ffedfd6182a892c1a6c360d344/crates/ui-egui/src/i18n/uk.tsv) | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-09 | adapted to LightCraft photo-development context, completed catalogs, reviewed spelling and placeholders | +| `crates/ui-egui/locales/cs.json`, `crates/ui-egui/locales/cs-formats.json` | Czech interface catalogs | @Losminek with AI assistance (Codex) | original work: translations of English source phrases, reviewed against the app UI conventions; no external translation material | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-11 | catalog coverage, Czech dates and count-neutral wording; independent scope/terminology review; native-speaker proofreading pending | +| `crates/ui-egui/locales/it.json`, `crates/ui-egui/locales/it-formats.json` | Italian interface catalogs | @GabrielePecchioli with Claude Code; PhotoCraft, VectorCraft, PdfCraft and WordCraft translation contributors for reused labels | original translations from English source; 410 initial English-key matches reviewed from [PhotoCraft da3da09](https://github.com/storytold/photocraft/blob/da3da0942c14c1159f40c652afcf33584332c733/crates/ui-egui/src/i18n/it.tsv), [VectorCraft 5432821](https://github.com/storytold/vectorcraft/blob/543282148007bcb5633c493a5374cd26b0339c8d/crates/ui-egui/src/i18n/it.tsv), [PdfCraft e8e275c](https://github.com/storytold/pdfcraft/blob/e8e275cbd40ccd0dc6b56f82b16a575571043702/crates/ui-egui/src/i18n/it.tsv) and the Italian WordCraft catalog ([storytold/wordcraft#598](https://github.com/storytold/wordcraft/pull/598)); 46 of them retranslated where the sibling's meaning didn't fit a photo app | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`, also the source repositories' licences) | 2026-10-12 | adapted to LightCraft photo-development context, count-neutral labels for Italian plural agreement, reviewed placeholders | +| `crates/ui-egui/locales/ka.json`, `crates/ui-egui/locales/ka-formats.json` | Georgian interface catalogs | Davit Matchakhelidze with Claude; WordCraft, GridCraft, PhotoCraft, FilmCraft and VectorCraft Georgian catalogs for reused labels | original translations from the English source (meaning-based, no vendor localization used); 450 English-key matches reused from the sibling Craft apps' Georgian catalogs | MIT OR Apache-2.0 (`LICENSE-MIT`, `LICENSE-APACHE`) | 2026-10-12 | adapted to LightCraft photo-library and raw-development context; count-neutral numerals; placeholders checked | Notes - Fonts authored or published by Adobe (Source Sans/Serif/Code, Source Han, …) are not used, even though some are diff --git a/assets/camera-profiles/ILCA-99M2.json b/assets/camera-profiles/ILCA-99M2.json new file mode 100644 index 000000000..ff18a63c1 --- /dev/null +++ b/assets/camera-profiles/ILCA-99M2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "ILCA-99M2", + "files": 144, + "samples": 501511, + "matrix": [ + [1.1432775892083846, -0.27864150084892714, 0.13786480817829982], + [-0.05589204732762987, 1.1699605558717394, -0.11694884297799035], + [0.004313413220749851, -0.7088386949439993, 1.7263782534882033] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [2.804254, 1.2235245, 1.0], + [5.0115886, 1.2228547, 1.0], + [5.1745224, 1.1897815, 1.0], + [3.4398973, 1.0871476, 1.0], + [0.0, 1.0, 1.0], + [2.1009562, 1.231483, 1.0], + [3.2371128, 1.1828748, 1.0], + [3.6905267, 1.1272595, 1.0], + [3.2581418, 1.0763286, 1.0], + [0.0, 1.0, 1.0], + [1.3609977, 1.2428942, 1.0], + [1.1722605, 1.1633604, 1.0], + [1.2722441, 1.0881792, 1.0], + [0.99433255, 1.026622, 1.0], + [0.0, 1.0, 1.0], + [-0.12375347, 1.2171124, 1.0], + [-0.6004144, 1.1662252, 1.0], + [-1.2418907, 1.1001159, 1.0], + [-1.3929275, 1.017488, 1.0], + [0.0, 1.0, 1.0], + [-1.4741973, 1.1985689, 1.0], + [-2.3110185, 1.1587616, 1.0], + [-3.2606716, 1.1098144, 1.0], + [-3.9140587, 1.0290464, 1.0], + [0.0, 1.0, 1.0], + [-2.748514, 1.1680661, 1.0], + [-3.8273644, 1.1325561, 1.0], + [-4.7215014, 1.0942773, 1.0], + [-5.6006517, 1.0109093, 1.0], + [0.0, 1.0, 1.0], + [-3.6385696, 1.1548748, 1.0], + [-4.8762474, 1.106731, 1.0], + [-5.845225, 1.0598869, 1.0], + [-6.7829485, 0.9773247, 1.0], + [0.0, 1.0, 1.0], + [-4.4043136, 1.1618558, 1.0], + [-5.663378, 1.1124074, 1.0], + [-6.676991, 1.0622271, 1.0], + [-7.2388797, 0.9792954, 1.0], + [0.0, 1.0, 1.0], + [-4.5636735, 1.1730139, 1.0], + [-5.5787325, 1.131743, 1.0], + [-6.434246, 1.0818648, 1.0], + [-7.713659, 0.977632, 1.0], + [0.0, 1.0, 1.0], + [-4.1642222, 1.1961858, 1.0], + [-5.135349, 1.1508327, 1.0], + [-5.9561267, 1.1025041, 1.0], + [-7.8126693, 0.9789329, 1.0], + [0.0, 1.0, 1.0], + [-3.3540795, 1.2313552, 1.0], + [-4.0874486, 1.1859667, 1.0], + [-5.090505, 1.1065466, 1.0], + [-6.980868, 0.94789493, 1.0], + [0.0, 1.0, 1.0], + [-3.2277284, 1.2495877, 1.0], + [-3.6526108, 1.1937634, 1.0], + [-3.5733712, 1.0832378, 1.0], + [-5.550715, 0.9253031, 1.0], + [0.0, 1.0, 1.0], + [-3.2758887, 1.2372755, 1.0], + [-2.8500462, 1.1516474, 1.0], + [-1.8038367, 1.0081928, 1.0], + [-2.1356683, 0.93026036, 1.0], + [0.0, 1.0, 1.0], + [-2.842822, 1.1995343, 1.0], + [-1.5608833, 1.0880193, 1.0], + [-0.6414115, 0.96593964, 1.0], + [-0.8170321, 0.9045082, 1.0], + [0.0, 1.0, 1.0], + [-1.8589126, 1.1368302, 1.0], + [-0.5850774, 1.0399925, 1.0], + [-0.4096108, 0.94941473, 1.0], + [-0.67716926, 0.89125115, 1.0], + [0.0, 1.0, 1.0], + [-0.50222045, 1.0790738, 1.0], + [0.13412993, 1.0091311, 1.0], + [0.04161173, 0.94268256, 1.0], + [0.21809827, 0.89096487, 1.0], + [0.0, 1.0, 1.0], + [0.5395973, 1.0523564, 1.0], + [0.68198884, 0.9942523, 1.0], + [0.8921194, 0.93612367, 1.0], + [1.5682732, 0.8875503, 1.0], + [0.0, 1.0, 1.0], + [1.4230179, 1.0399433, 1.0], + [1.7522293, 0.98858124, 1.0], + [2.120758, 0.93895113, 1.0], + [2.7880068, 0.8917508, 1.0], + [0.0, 1.0, 1.0], + [2.485209, 1.0362785, 1.0], + [3.2553241, 0.9826427, 1.0], + [3.6980822, 0.9420671, 1.0], + [3.6589043, 0.9088622, 1.0], + [0.0, 1.0, 1.0], + [3.8520021, 1.0283107, 1.0], + [4.4029727, 0.97790647, 1.0], + [4.6192904, 0.941655, 1.0], + [3.3324108, 0.9242873, 1.0], + [0.0, 1.0, 1.0], + [4.579471, 1.0396363, 1.0], + [5.1236196, 0.99359185, 1.0], + [4.4844737, 0.9637591, 1.0], + [0.93364024, 0.9805732, 1.0], + [0.0, 1.0, 1.0], + [5.6343756, 1.0391579, 1.0], + [5.9764285, 0.9963135, 1.0], + [4.2140217, 0.97128063, 1.0], + [0.31541073, 0.99498296, 1.0], + [0.0, 1.0, 1.0], + [6.3622503, 1.0628132, 1.0], + [5.916998, 1.0203458, 1.0], + [1.3126858, 0.9978764, 1.0], + [-0.040236503, 0.99987936, 1.0], + [0.0, 1.0, 1.0], + [6.1092844, 1.1148107, 1.0], + [5.2341003, 1.0724294, 1.0], + [0.57583106, 1.0097842, 1.0], + [-0.004625028, 1.0000128, 1.0], + [0.0, 1.0, 1.0], + [5.6949396, 1.1773212, 1.0], + [3.8311253, 1.1038187, 1.0], + [-0.11847352, 1.0086719, 1.0], + [-0.0105433175, 1.000084, 1.0], + [0.0, 1.0, 1.0], + [4.546873, 1.1809658, 1.0], + [0.6606819, 1.0607728, 1.0], + [-1.8319762, 0.98954993, 1.0], + [-0.11198713, 0.9991004, 1.0], + [0.0, 1.0, 1.0], + [2.975616, 1.171921, 1.0], + [-2.6058009, 1.0177505, 1.0], + [-5.031273, 0.9486056, 1.0], + [-0.71531844, 0.99169016, 1.0], + [0.0, 1.0, 1.0], + [2.3296447, 1.1538781, 1.0], + [-4.7061315, 0.9983956, 1.0], + [-7.1538033, 0.9327583, 1.0], + [-1.329432, 0.9859865, 1.0], + [0.0, 1.0, 1.0], + [1.33782, 1.1317362, 1.0], + [-5.738668, 0.9846136, 1.0], + [-8.032541, 0.93113464, 1.0], + [-1.414409, 0.9868347, 1.0], + [0.0, 1.0, 1.0], + [1.1102281, 1.1464978, 1.0], + [-4.347359, 1.0098535, 1.0], + [-5.8042417, 0.9558565, 1.0], + [-0.65224177, 0.99453866, 1.0], + [0.0, 1.0, 1.0], + [1.3978106, 1.1282454, 1.0], + [-2.5084882, 1.0191262, 1.0], + [-2.5297656, 0.9832681, 1.0], + [-0.15988652, 0.99876547, 1.0], + [0.0, 1.0, 1.0], + [1.6285329, 1.1082805, 1.0], + [-1.8376639, 1.0320127, 1.0], + [-1.5057008, 0.99416333, 1.0], + [-0.07763386, 0.99956375, 1.0], + [0.0, 1.0, 1.0], + [0.47474164, 1.1118255, 1.0], + [-2.2814848, 1.0398426, 1.0], + [-0.96154535, 0.9967108, 1.0], + [-0.038478848, 0.99972326, 1.0], + [0.0, 1.0, 1.0], + [-0.28353742, 1.1260961, 1.0], + [-2.6729789, 1.0359904, 1.0], + [-0.76547706, 0.9970432, 1.0], + [-0.02355436, 0.99970925, 1.0], + [0.0, 1.0, 1.0], + [0.65706646, 1.1279969, 1.0], + [-2.8462512, 1.0391037, 1.0], + [-0.5868237, 1.0005401, 1.0], + [-0.010716562, 0.9999053, 1.0], + [0.0, 1.0, 1.0], + [0.12108033, 1.1502995, 1.0], + [-1.9704113, 1.0370626, 1.0], + [-0.63179135, 1.0001434, 1.0], + [-0.015800219, 0.99995714, 1.0], + [0.0, 1.0, 1.0], + [1.0309358, 1.1655595, 1.0], + [0.13250555, 1.0445937, 1.0], + [-0.07715364, 1.0009365, 1.0], + [-0.0021050358, 0.99999684, 1.0], + [0.0, 1.0, 1.0], + [-0.2827152, 1.1324676, 1.0], + [0.014774211, 1.0343506, 1.0], + [-0.019916466, 1.0006523, 1.0], + [-0.00040274212, 1.0000006, 1.0], + [0.0, 1.0, 1.0], + [-2.2018666, 1.1418259, 1.0], + [-1.4933577, 1.0408006, 1.0], + [-0.3667708, 1.0002662, 1.0], + [-0.007263095, 0.9999904, 1.0], + [0.0, 1.0, 1.0], + [-2.64397, 1.1194679, 1.0], + [-1.6423713, 1.0442007, 1.0], + [-0.20484583, 1.0007143, 1.0], + [-0.0025689714, 0.99998134, 1.0], + [0.0, 1.0, 1.0], + [-3.8773065, 1.110005, 1.0], + [-2.6916754, 1.0417831, 1.0], + [-0.33602908, 1.0002605, 1.0], + [-0.0021417958, 0.9999703, 1.0], + [0.0, 1.0, 1.0], + [-3.714574, 1.0977588, 1.0], + [-3.5595996, 1.0386114, 1.0], + [-1.5827396, 1.0060165, 1.0], + [-0.028114408, 1.0000618, 1.0], + [0.0, 1.0, 1.0], + [-3.2299962, 1.0955467, 1.0], + [-3.3268821, 1.0478108, 1.0], + [-1.9395999, 1.0131217, 1.0], + [-0.075512886, 1.0002799, 1.0], + [0.0, 1.0, 1.0], + [-1.336435, 1.0960432, 1.0], + [-0.8794556, 1.0795935, 1.0], + [-0.29321554, 1.0271318, 1.0], + [0.00032340753, 1.0008185, 1.0], + [0.0, 1.0, 1.0], + [0.09420732, 1.1007514, 1.0], + [0.45092306, 1.0983796, 1.0], + [1.0769465, 1.0420003, 1.0], + [0.22761619, 1.0022283, 1.0], + [0.0, 1.0, 1.0], + [0.71772265, 1.1304002, 1.0], + [1.5783058, 1.1197016, 1.0], + [2.8271558, 1.0629567, 1.0], + [1.0452774, 1.0075884, 1.0], + [0.0, 1.0, 1.0], + [1.7700194, 1.1402828, 1.0], + [2.267522, 1.1383678, 1.0], + [2.3750477, 1.0799156, 1.0], + [0.46060455, 0.99580747, 1.0], + [0.0, 1.0, 1.0], + [2.9440982, 1.1368023, 1.0], + [3.6898718, 1.1418967, 1.0], + [3.8923314, 1.1259586, 1.0], + [1.2024251, 1.0325387, 1.0], + [0.0, 1.0, 1.0], + [4.2370567, 1.1504492, 1.0], + [4.6764765, 1.1490715, 1.0], + [3.910422, 1.1321807, 1.0], + [0.89606607, 1.0308274, 1.0], + [0.0, 1.0, 1.0], + [4.9599085, 1.134546, 1.0], + [5.1401796, 1.1103181, 1.0], + [3.7372687, 1.0921954, 1.0], + [0.55078083, 1.021753, 1.0], + [0.0, 1.0, 1.0], + [5.7153788, 1.1273357, 1.0], + [5.978767, 1.1330243, 1.0], + [3.9994862, 1.1207391, 1.0], + [0.36665595, 1.0121281, 1.0], + [0.0, 1.0, 1.0], + [6.2697597, 1.1095688, 1.0], + [5.6365104, 1.1205149, 1.0], + [3.4179032, 1.1214768, 1.0], + [0.26656342, 1.0103098, 1.0], + [0.0, 1.0, 1.0], + [6.1428804, 1.0995386, 1.0], + [3.956236, 1.1270815, 1.0], + [1.9768763, 1.1226226, 1.0], + [0.11463216, 1.0099398, 1.0], + [0.0, 1.0, 1.0], + [6.929935, 1.1041147, 1.0], + [3.3182452, 1.1096137, 1.0], + [1.2072796, 1.0818946, 1.0], + [-0.01571625, 1.005663, 1.0], + [0.0, 1.0, 1.0], + [7.265804, 1.1154059, 1.0], + [3.7104347, 1.0477185, 1.0], + [1.056846, 0.9974326, 1.0], + [0.03417005, 0.9998331, 1.0], + [0.0, 1.0, 1.0], + [7.849703, 1.1269237, 1.0], + [4.3993697, 1.0325269, 1.0], + [1.3606595, 0.99489033, 1.0], + [0.047941446, 0.99993676, 1.0], + [0.0, 1.0, 1.0], + [8.439311, 1.1298556, 1.0], + [3.303848, 1.0538055, 1.0], + [0.59302783, 1.0085934, 1.0], + [0.025294233, 1.0004109, 1.0], + [0.0, 1.0, 1.0], + [8.608997, 1.1400498, 1.0], + [3.3591669, 1.0461109, 1.0], + [0.40004572, 1.0048742, 1.0], + [0.01262109, 1.0001965, 1.0], + [0.0, 1.0, 1.0], + [6.761172, 1.1502578, 1.0], + [2.1108382, 1.0369791, 1.0], + [0.11530293, 1.0012623, 1.0], + [0.001693561, 1.0000176, 1.0], + [0.0, 1.0, 1.0], + [5.5255957, 1.1716747, 1.0], + [1.0459789, 1.037805, 1.0], + [0.09180857, 1.001637, 1.0], + [0.003279748, 1.0000362, 1.0], + [0.0, 1.0, 1.0], + [5.1823792, 1.1748279, 1.0], + [1.723147, 1.0416057, 1.0], + [0.2180456, 1.0030037, 1.0], + [0.005272273, 1.0000591, 1.0], + [0.0, 1.0, 1.0], + [5.7087555, 1.1780843, 1.0], + [2.619079, 1.0446409, 1.0], + [0.35640785, 1.0032725, 1.0], + [0.010388461, 1.0001107, 1.0], + [0.0, 1.0, 1.0], + [6.8262243, 1.1712428, 1.0], + [3.972946, 1.0097475, 1.0], + [0.6286015, 0.9970032, 1.0], + [0.017907226, 1.0001063, 1.0], + [0.0, 1.0, 1.0], + [6.524165, 1.182861, 1.0], + [3.200896, 1.0579331, 1.0], + [0.28860277, 1.0027157, 1.0], + [0.0036479658, 1.0000447, 1.0], + [0.0, 1.0, 1.0], + [5.831539, 1.1791148, 1.0], + [2.774682, 1.0702703, 1.0], + [0.35563686, 1.0059416, 1.0], + [0.0069925115, 1.0001086, 1.0], + [0.0, 1.0, 1.0], + [7.6593747, 1.1839095, 1.0], + [2.9386632, 1.0513804, 1.0], + [0.23482445, 0.999349, 1.0], + [0.0063177217, 1.0000361, 1.0], + [0.0, 1.0, 1.0], + [7.268381, 1.1659219, 1.0], + [2.5044994, 1.0421095, 1.0], + [0.20415363, 0.99924254, 1.0], + [0.0059488467, 1.0000399, 1.0], + [0.0, 1.0, 1.0], + [7.236816, 1.1485709, 1.0], + [4.5540986, 1.06799, 1.0], + [1.4449328, 1.0170963, 1.0], + [0.0639702, 1.0006945, 1.0], + [0.0, 1.0, 1.0], + [6.453048, 1.1589189, 1.0], + [6.3102107, 1.1038384, 1.0], + [3.1707234, 1.032528, 1.0], + [0.19751447, 1.00155, 1.0], + [0.0, 1.0, 1.0], + [6.372379, 1.202752, 1.0], + [7.4304466, 1.1593263, 1.0], + [5.6761837, 1.0836742, 1.0], + [0.6670318, 1.0070612, 1.0], + [0.0, 1.0, 1.0], + [5.3508396, 1.1789645, 1.0], + [7.0611863, 1.1524564, 1.0], + [7.2240868, 1.1157393, 1.0], + [2.2516217, 1.0274429, 1.0], + [0.0, 1.0, 1.0], + [4.5282054, 1.1895584, 1.0], + [6.615412, 1.2008698, 1.0], + [7.1562805, 1.1773871, 1.0], + [3.693685, 1.0640011, 1.0], + [0.0, 1.0, 1.0], + [2.989149, 1.1666261, 1.0], + [5.0449367, 1.163257, 1.0], + [5.3947783, 1.1525968, 1.0], + [4.8617563, 1.1140602, 1.0], + [0.0, 1.0, 1.0], + [2.4345527, 1.1707076, 1.0], + [4.030044, 1.1360824, 1.0], + [4.400234, 1.1141994, 1.0], + [3.9981327, 1.0895209, 1.0], + [0.0, 1.0, 1.0], + [1.6249478, 1.1449721, 1.0], + [2.3648932, 1.0950501, 1.0], + [2.5787578, 1.0738068, 1.0], + [1.557968, 1.048907, 1.0], + [0.0, 1.0, 1.0], + [1.1732466, 1.1023636, 1.0], + [1.5519327, 1.0694385, 1.0], + [0.8835925, 1.0562818, 1.0], + [-0.95382243, 1.0234519, 1.0], + [0.0, 1.0, 1.0], + [0.54519135, 1.0728073, 1.0], + [0.7171778, 1.047036, 1.0], + [-0.4340524, 1.0440289, 1.0], + [-3.1891012, 1.0054655, 1.0], + [0.0, 1.0, 1.0], + [-0.81599987, 1.0807294, 1.0], + [-1.1246754, 1.0611808, 1.0], + [-2.2497585, 1.0415246, 1.0], + [-4.411367, 0.973935, 1.0], + [0.0, 1.0, 1.0], + [-1.7861705, 1.0760409, 1.0], + [-2.835779, 1.0522164, 1.0], + [-3.9953759, 1.0148282, 1.0], + [-4.4476047, 0.9319428, 1.0], + [0.0, 1.0, 1.0], + [-2.4466016, 1.0813212, 1.0], + [-3.5611944, 1.0451691, 1.0], + [-4.5130477, 1.011627, 1.0], + [-4.224515, 0.92788553, 1.0], + [0.0, 1.0, 1.0], + [-3.003385, 1.1208798, 1.0], + [-4.1261635, 1.0943259, 1.0], + [-5.189995, 1.0589299, 1.0], + [-6.5665016, 0.95204496, 1.0], + [0.0, 1.0, 1.0], + [-2.5737576, 1.1543715, 1.0], + [-3.583895, 1.1235094, 1.0], + [-4.758327, 1.0856714, 1.0], + [-6.4036903, 0.95784587, 1.0], + [0.0, 1.0, 1.0], + [-1.783091, 1.1985654, 1.0], + [-2.344906, 1.1536651, 1.0], + [-3.4222968, 1.0852684, 1.0], + [-5.1206603, 0.9148598, 1.0], + [0.0, 1.0, 1.0], + [-1.8261324, 1.2225533, 1.0], + [-2.3288019, 1.1683235, 1.0], + [-1.7856505, 1.0476049, 1.0], + [-4.6820045, 0.88435495, 1.0], + [0.0, 1.0, 1.0], + [-1.9707233, 1.2195381, 1.0], + [-0.81782085, 1.122641, 1.0], + [0.9111474, 0.9622587, 1.0], + [-1.1497722, 0.88565606, 1.0], + [0.0, 1.0, 1.0], + [-2.09956, 1.2140585, 1.0], + [0.056621242, 1.0911676, 1.0], + [1.0300432, 0.9330744, 1.0], + [-0.18233444, 0.8674556, 1.0], + [0.0, 1.0, 1.0], + [-0.7154876, 1.144233, 1.0], + [1.4851263, 1.0231369, 1.0], + [0.555909, 0.9251492, 1.0], + [-0.18147656, 0.8636336, 1.0], + [0.0, 1.0, 1.0], + [1.3398509, 1.0599889, 1.0], + [1.6690949, 0.9969751, 1.0], + [0.37584245, 0.92953223, 1.0], + [0.07826649, 0.87178516, 1.0], + [0.0, 1.0, 1.0], + [2.508692, 1.0410949, 1.0], + [1.8833909, 0.99179685, 1.0], + [1.2507737, 0.9335844, 1.0], + [1.6161711, 0.8835863, 1.0], + [0.0, 1.0, 1.0], + [3.1758716, 1.0253911, 1.0], + [2.4910228, 0.9809797, 1.0], + [2.236299, 0.9303089, 1.0], + [2.6537087, 0.8825234, 1.0], + [0.0, 1.0, 1.0], + [3.6103837, 1.0229514, 1.0], + [3.1707804, 0.97286725, 1.0], + [3.3409152, 0.9399243, 1.0], + [3.5829213, 0.9052561, 1.0], + [0.0, 1.0, 1.0], + [4.3082023, 1.0349113, 1.0], + [4.284679, 0.9839242, 1.0], + [4.3494177, 0.9484854, 1.0], + [3.8492424, 0.9255855, 1.0], + [0.0, 1.0, 1.0], + [4.793538, 1.0573108, 1.0], + [5.6492386, 0.98787755, 1.0], + [6.214179, 0.91949385, 1.0], + [3.9530585, 0.92670876, 1.0], + [0.0, 1.0, 1.0], + [5.384946, 1.070319, 1.0], + [6.3971586, 0.98128706, 1.0], + [6.9482284, 0.89684695, 1.0], + [4.2139463, 0.9239156, 1.0], + [0.0, 1.0, 1.0], + [5.509941, 1.109065, 1.0], + [4.31208, 1.0552181, 1.0], + [-1.2347931, 0.9985642, 1.0], + [-0.86304265, 0.9993037, 1.0], + [0.0, 1.0, 1.0], + [5.1984997, 1.162435, 1.0], + [3.29675, 1.1169462, 1.0], + [-2.0880382, 1.0190989, 1.0], + [-0.5265214, 0.99782705, 1.0], + [0.0, 1.0, 1.0], + [4.2004547, 1.2332976, 1.0], + [2.4406395, 1.1688915, 1.0], + [-0.54659694, 1.020866, 1.0], + [-0.039971404, 0.99981636, 1.0], + [0.0, 1.0, 1.0], + [2.9126167, 1.2407047, 1.0], + [-0.152128, 1.1377726, 1.0], + [-2.4389071, 0.9923629, 1.0], + [-0.18998405, 0.99835, 1.0], + [0.0, 1.0, 1.0], + [0.832136, 1.2248999, 1.0], + [-4.439477, 1.0757953, 1.0], + [-6.5581284, 0.94083136, 1.0], + [-1.3619823, 0.98418033, 1.0], + [0.0, 1.0, 1.0], + [-0.8086253, 1.2119304, 1.0], + [-6.9719944, 1.0509604, 1.0], + [-8.899021, 0.9207292, 1.0], + [-2.5299528, 0.97328454, 1.0], + [0.0, 1.0, 1.0], + [-1.2520319, 1.1828909, 1.0], + [-7.6938405, 1.0309292, 1.0], + [-9.733912, 0.9186155, 1.0], + [-2.4881692, 0.97676903, 1.0], + [0.0, 1.0, 1.0], + [-0.6646547, 1.1841325, 1.0], + [-6.6125364, 1.0378011, 1.0], + [-7.26035, 0.942406, 1.0], + [-1.0572171, 0.99080205, 1.0], + [0.0, 1.0, 1.0], + [0.598152, 1.1774639, 1.0], + [-4.97536, 1.0513587, 1.0], + [-3.3090515, 0.9811577, 1.0], + [-0.20941074, 0.9982669, 1.0], + [0.0, 1.0, 1.0], + [0.010846901, 1.1766325, 1.0], + [-5.117737, 1.0593848, 1.0], + [-2.631352, 0.99419326, 1.0], + [-0.11850837, 0.9993512, 1.0], + [0.0, 1.0, 1.0], + [-1.5248753, 1.1829028, 1.0], + [-6.0299883, 1.0577121, 1.0], + [-2.215714, 0.99519426, 1.0], + [-0.067606665, 0.9995176, 1.0], + [0.0, 1.0, 1.0], + [-1.1450118, 1.1907917, 1.0], + [-5.886881, 1.0472964, 1.0], + [-2.5883603, 0.98812264, 1.0], + [-0.085475355, 0.9989431, 1.0], + [0.0, 1.0, 1.0], + [0.39547902, 1.2007595, 1.0], + [-4.610833, 1.0562892, 1.0], + [-1.7052215, 0.99749553, 1.0], + [-0.037703, 0.9996055, 1.0], + [0.0, 1.0, 1.0], + [-0.01975878, 1.2410866, 1.0], + [-1.2528956, 1.080659, 1.0], + [-0.68467104, 1.0020955, 1.0], + [-0.019976415, 0.99990904, 1.0], + [0.0, 1.0, 1.0], + [-0.2921033, 1.2522528, 1.0], + [0.5268386, 1.0997523, 1.0], + [0.03676964, 1.0044748, 1.0], + [-0.0015876454, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-2.6588416, 1.1782904, 1.0], + [-0.8304472, 1.0511743, 1.0], + [-0.09593859, 1.0008658, 1.0], + [-0.0012349889, 0.9999909, 1.0], + [0.0, 1.0, 1.0], + [-5.5003, 1.1401259, 1.0], + [-2.4005525, 1.002213, 1.0], + [-0.7121907, 0.98226595, 1.0], + [-0.017461486, 0.999597, 1.0], + [0.0, 1.0, 1.0], + [-5.994106, 1.0949768, 1.0], + [-2.85081, 1.0083107, 1.0], + [-0.30721804, 0.9763625, 1.0], + [-0.0013189864, 0.9991703, 1.0], + [0.0, 1.0, 1.0], + [-5.196782, 1.1068348, 1.0], + [-3.2161317, 1.0495526, 1.0], + [-1.099006, 0.99147415, 1.0], + [-0.024919514, 0.9993193, 1.0], + [0.0, 1.0, 1.0], + [-4.84915, 1.05568, 1.0], + [-3.9007847, 1.03011, 1.0], + [-2.599225, 1.0099269, 1.0], + [-0.11461259, 0.99996513, 1.0], + [0.0, 1.0, 1.0], + [-4.721844, 1.055208, 1.0], + [-3.9854348, 1.0393229, 1.0], + [-2.5744975, 1.0209645, 1.0], + [-0.293057, 1.001174, 1.0], + [0.0, 1.0, 1.0], + [-4.059898, 1.0536826, 1.0], + [-2.5768554, 1.0555562, 1.0], + [-1.39879, 1.0136101, 1.0], + [-0.50610787, 0.9911003, 1.0], + [0.0, 1.0, 1.0], + [-2.9069445, 1.0556097, 1.0], + [-1.5781095, 1.0518695, 1.0], + [-0.8278498, 1.001524, 1.0], + [-0.32421526, 0.98277986, 1.0], + [0.0, 1.0, 1.0], + [-1.4363952, 1.0812076, 1.0], + [0.5487326, 1.0899016, 1.0], + [2.4110415, 1.0404066, 1.0], + [2.9115984, 1.0175672, 1.0], + [0.0, 1.0, 1.0], + [0.008678005, 1.1017222, 1.0], + [1.2356163, 1.1221837, 1.0], + [2.8750088, 1.0839373, 1.0], + [3.1348855, 1.032868, 1.0], + [0.0, 1.0, 1.0], + [0.88217217, 1.091444, 1.0], + [2.0927439, 1.1262348, 1.0], + [3.240285, 1.1331868, 1.0], + [2.67113, 1.0834583, 1.0], + [0.0, 1.0, 1.0], + [2.428248, 1.095952, 1.0], + [3.390544, 1.1449028, 1.0], + [3.5510561, 1.1565588, 1.0], + [2.31717, 1.0987086, 1.0], + [0.0, 1.0, 1.0], + [4.1580663, 1.0814137, 1.0], + [4.4028983, 1.1175768, 1.0], + [3.174666, 1.1427947, 1.0], + [2.0205822, 1.1122999, 1.0], + [0.0, 1.0, 1.0], + [6.2129626, 1.0714386, 1.0], + [6.590155, 1.0948668, 1.0], + [4.619345, 1.1303223, 1.0], + [1.1349491, 1.0543766, 1.0], + [0.0, 1.0, 1.0], + [6.7700667, 1.0417291, 1.0], + [6.674038, 1.0694815, 1.0], + [3.9405415, 1.1325624, 1.0], + [0.40215716, 1.0195748, 1.0], + [0.0, 1.0, 1.0], + [7.373421, 1.0177879, 1.0], + [5.4952292, 1.081058, 1.0], + [2.1721451, 1.1446264, 1.0], + [0.14383824, 1.0215358, 1.0], + [0.0, 1.0, 1.0], + [8.597993, 1.0280006, 1.0], + [4.9888263, 1.084475, 1.0], + [1.9800822, 1.1122874, 1.0], + [-0.23990147, 1.0166304, 1.0], + [0.0, 1.0, 1.0], + [9.2620325, 1.0467869, 1.0], + [6.4645605, 1.0722194, 1.0], + [2.8058643, 1.069191, 1.0], + [0.13071911, 1.005048, 1.0], + [0.0, 1.0, 1.0], + [9.7541, 1.079291, 1.0], + [7.8130198, 1.0582371, 1.0], + [3.0989928, 1.0383492, 1.0], + [0.14017454, 1.0023274, 1.0], + [0.0, 1.0, 1.0], + [9.04703, 1.1110121, 1.0], + [6.10701, 1.0926124, 1.0], + [2.378649, 1.0378062, 1.0], + [0.111506626, 1.001809, 1.0], + [0.0, 1.0, 1.0], + [9.323562, 1.1182009, 1.0], + [7.539196, 1.0773741, 1.0], + [2.3685758, 1.0214494, 1.0], + [0.07044682, 1.0008357, 1.0], + [0.0, 1.0, 1.0], + [8.691661, 1.143956, 1.0], + [7.088831, 1.0745311, 1.0], + [1.8496133, 1.0144777, 1.0], + [0.05676793, 1.0005342, 1.0], + [0.0, 1.0, 1.0], + [7.4111695, 1.2071966, 1.0], + [4.6049232, 1.1096628, 1.0], + [1.9098912, 1.0257343, 1.0], + [0.07917395, 1.0008571, 1.0], + [0.0, 1.0, 1.0], + [7.0088325, 1.2360815, 1.0], + [5.181472, 1.1226107, 1.0], + [2.5577261, 1.0353444, 1.0], + [0.112243205, 1.0013376, 1.0], + [0.0, 1.0, 1.0], + [8.824198, 1.2390603, 1.0], + [7.472616, 1.1490262, 1.0], + [3.6435897, 1.0531034, 1.0], + [0.21466114, 1.00276, 1.0], + [0.0, 1.0, 1.0], + [9.237296, 1.2194712, 1.0], + [7.4227743, 1.131169, 1.0], + [3.8712175, 1.049929, 1.0], + [0.27166268, 1.0031942, 1.0], + [0.0, 1.0, 1.0], + [8.786514, 1.1667371, 1.0], + [6.6639266, 1.1076087, 1.0], + [2.2446363, 1.0388349, 1.0], + [0.102016255, 1.0017875, 1.0], + [0.0, 1.0, 1.0], + [7.9329085, 1.1376165, 1.0], + [6.6409307, 1.1026622, 1.0], + [3.2540584, 1.0458134, 1.0], + [0.23406105, 1.0032705, 1.0], + [0.0, 1.0, 1.0], + [9.150625, 1.1396258, 1.0], + [6.7835217, 1.1053689, 1.0], + [3.3122694, 1.0424184, 1.0], + [0.22282204, 1.0027751, 1.0], + [0.0, 1.0, 1.0], + [9.179195, 1.1203728, 1.0], + [6.1122427, 1.0689247, 1.0], + [2.574998, 1.0149101, 1.0], + [0.17865337, 1.001089, 1.0], + [0.0, 1.0, 1.0], + [8.981582, 1.1198573, 1.0], + [6.162663, 1.0758042, 1.0], + [3.49672, 1.0282632, 1.0], + [0.5296274, 1.0024091, 1.0], + [0.0, 1.0, 1.0], + [7.104293, 1.1063842, 1.0], + [6.60601, 1.0895218, 1.0], + [6.026096, 1.0566804, 1.0], + [1.4251214, 1.0060695, 1.0], + [0.0, 1.0, 1.0], + [5.400258, 1.118055, 1.0], + [8.1627245, 1.1168847, 1.0], + [8.838523, 1.1060567, 1.0], + [3.2699623, 1.0280458, 1.0], + [0.0, 1.0, 1.0], + [4.0916386, 1.0858877, 1.0], + [7.020515, 1.0801034, 1.0], + [8.994645, 1.1054409, 1.0], + [6.3794594, 1.070257, 1.0], + [0.0, 1.0, 1.0], + [2.8969727, 1.0963963, 1.0], + [5.3678546, 1.111281, 1.0], + [7.492073, 1.145503, 1.0], + [6.806583, 1.107631, 1.0], + [0.0, 1.0, 1.0], + [11.097742, 0.7495534, 1.0], + [4.302149, 1.0255402, 1.0], + [4.3949976, 1.078089, 1.0], + [3.0446813, 1.0549035, 1.0], + [0.0, 1.0, 1.0], + [8.670632, 0.7986941, 1.0], + [4.084819, 1.0083629, 1.0], + [4.0668464, 1.0512977, 1.0], + [3.5106099, 1.0471594, 1.0], + [0.0, 1.0, 1.0], + [6.4860106, 0.8172128, 1.0], + [3.818937, 0.93780375, 1.0], + [3.2241101, 1.0080932, 1.0], + [2.3703701, 1.0238551, 1.0], + [0.0, 1.0, 1.0], + [4.833141, 0.8377232, 1.0], + [4.498794, 0.8643455, 1.0], + [3.6683908, 0.9057964, 1.0], + [1.7416582, 0.96007955, 1.0], + [0.0, 1.0, 1.0], + [4.6399913, 0.80761904, 1.0], + [4.839282, 0.8098061, 1.0], + [4.2472334, 0.8426727, 1.0], + [2.3996296, 0.87981254, 1.0], + [0.0, 1.0, 1.0], + [4.0833488, 0.78954774, 1.0], + [4.703831, 0.781375, 1.0], + [4.1265993, 0.8191039, 1.0], + [3.244208, 0.82794446, 1.0], + [0.0, 1.0, 1.0], + [2.2156487, 0.8334128, 1.0], + [2.5546548, 0.82133436, 1.0], + [2.4130013, 0.82352406, 1.0], + [2.1992364, 0.81915104, 1.0], + [0.0, 1.0, 1.0], + [1.719304, 0.8250702, 1.0], + [1.4982271, 0.8221971, 1.0], + [1.3897603, 0.840134, 1.0], + [1.7225292, 0.8336163, 1.0], + [0.0, 1.0, 1.0], + [1.0925283, 0.85481983, 1.0], + [0.7631879, 0.85975116, 1.0], + [0.027124116, 0.87992114, 1.0], + [0.60234094, 0.84217334, 1.0], + [0.0, 1.0, 1.0], + [1.077557, 0.872631, 1.0], + [1.0173641, 0.8593242, 1.0], + [0.46943918, 0.87838364, 1.0], + [2.6384852, 0.79771537, 1.0], + [0.0, 1.0, 1.0], + [0.7770302, 0.9353661, 1.0], + [1.0596069, 0.8860215, 1.0], + [1.2889328, 0.8582148, 1.0], + [4.9600816, 0.74249977, 1.0], + [0.0, 1.0, 1.0], + [0.34926006, 0.98829997, 1.0], + [0.18424937, 0.9600296, 1.0], + [1.1720486, 0.8844167, 1.0], + [2.579216, 0.7862993, 1.0], + [0.0, 1.0, 1.0], + [0.30250847, 0.9603346, 1.0], + [1.107201, 0.928601, 1.0], + [1.9961823, 0.8556583, 1.0], + [-0.3471245, 0.82866865, 1.0], + [0.0, 1.0, 1.0], + [-0.7629411, 1.0180708, 1.0], + [0.610268, 0.974038, 1.0], + [1.429175, 0.8520721, 1.0], + [0.48019677, 0.8095613, 1.0], + [0.0, 1.0, 1.0], + [-0.3008022, 0.9407405, 1.0], + [1.6219236, 0.9161009, 1.0], + [0.49270624, 0.8484004, 1.0], + [-0.12723985, 0.791222, 1.0], + [0.0, 1.0, 1.0], + [0.25840467, 0.85001826, 1.0], + [1.2374158, 0.87433594, 1.0], + [-0.47591653, 0.8426007, 1.0], + [-0.9091751, 0.79707766, 1.0], + [0.0, 1.0, 1.0], + [1.1872596, 0.8374762, 1.0], + [1.1351508, 0.8393883, 1.0], + [-0.09448363, 0.82199603, 1.0], + [-0.22021097, 0.80633336, 1.0], + [0.0, 1.0, 1.0], + [0.99161613, 0.82922834, 1.0], + [0.5485594, 0.83473396, 1.0], + [0.39220774, 0.826323, 1.0], + [0.58646864, 0.81625366, 1.0], + [0.0, 1.0, 1.0], + [0.25968486, 0.823715, 1.0], + [-0.11242166, 0.8438889, 1.0], + [0.41922796, 0.8506193, 1.0], + [1.028925, 0.8475653, 1.0], + [0.0, 1.0, 1.0], + [1.0410757, 0.82169753, 1.0], + [1.1827178, 0.83744586, 1.0], + [1.5406011, 0.8407932, 1.0], + [1.5877901, 0.88981646, 1.0], + [0.0, 1.0, 1.0], + [1.7911158, 0.83056414, 1.0], + [4.6160154, 0.8464025, 1.0], + [5.6431475, 0.8491971, 1.0], + [2.9428084, 0.91466653, 1.0], + [0.0, 1.0, 1.0], + [2.4177535, 0.8561214, 1.0], + [6.020544, 0.8638199, 1.0], + [6.9461846, 0.8605439, 1.0], + [3.500521, 0.92217606, 1.0], + [0.0, 1.0, 1.0], + [2.851688, 0.89191425, 1.0], + [1.421044, 0.9390341, 1.0], + [-1.1085083, 0.9700407, 1.0], + [-0.36786354, 0.9936673, 1.0], + [0.0, 1.0, 1.0], + [2.3683667, 0.9257322, 1.0], + [-0.26451036, 0.95317423, 1.0], + [-2.130682, 0.9368306, 1.0], + [-0.4093729, 0.986178, 1.0], + [0.0, 1.0, 1.0], + [1.5891942, 0.99163103, 1.0], + [0.045109738, 0.966062, 1.0], + [-0.53449297, 0.92987776, 1.0], + [-0.033470135, 0.9932046, 1.0], + [0.0, 1.0, 1.0], + [-0.27134877, 0.99536645, 1.0], + [0.0898188, 0.98402214, 1.0], + [0.27262244, 0.96843773, 1.0], + [0.009789813, 0.9986736, 1.0], + [0.0, 1.0, 1.0], + [-0.7730278, 1.0203122, 1.0], + [-2.9028432, 1.0235066, 1.0], + [-1.0447694, 0.9868708, 1.0], + [-0.04624136, 0.99908286, 1.0], + [0.0, 1.0, 1.0], + [-1.3631408, 1.0156548, 1.0], + [-3.9609292, 1.0409077, 1.0], + [-1.4876856, 0.9928084, 1.0], + [-0.095956564, 0.998979, 1.0], + [0.0, 1.0, 1.0], + [-1.7949396, 0.9771055, 1.0], + [-3.3644142, 1.0089597, 1.0], + [-0.6663962, 0.98575324, 1.0], + [-0.060724735, 0.9990037, 1.0], + [0.0, 1.0, 1.0], + [-2.6404126, 0.9683573, 1.0], + [-3.7188525, 0.9884891, 1.0], + [-0.33517227, 0.9891578, 1.0], + [-0.01776004, 0.99959093, 1.0], + [0.0, 1.0, 1.0], + [-1.5392375, 0.9850047, 1.0], + [-1.9149097, 0.9972289, 1.0], + [0.076658234, 0.99591476, 1.0], + [0.0015469929, 0.99989164, 1.0], + [0.0, 1.0, 1.0], + [-1.5000389, 0.9821583, 1.0], + [-2.8312242, 0.9990609, 1.0], + [-0.28693938, 0.9993521, 1.0], + [-0.003116089, 0.9999806, 1.0], + [0.0, 1.0, 1.0], + [-2.261437, 0.9956475, 1.0], + [-4.1936493, 0.99516, 1.0], + [-0.5251263, 0.99886537, 1.0], + [-0.0044811945, 0.99998087, 1.0], + [0.0, 1.0, 1.0], + [-2.1491625, 1.0218415, 1.0], + [-4.5739403, 1.0102247, 1.0], + [-0.68651015, 0.99985665, 1.0], + [-0.0075566974, 0.99995035, 1.0], + [0.0, 1.0, 1.0], + [-1.2349107, 0.9898649, 1.0], + [-1.9306059, 1.0036258, 1.0], + [-0.31590265, 0.99970144, 1.0], + [-0.0033171063, 0.9999775, 1.0], + [0.0, 1.0, 1.0], + [-1.0307009, 0.99630994, 1.0], + [0.30616513, 1.0119231, 1.0], + [0.06396701, 1.0005364, 1.0], + [0.00027660295, 0.9999968, 1.0], + [0.0, 1.0, 1.0], + [-2.346574, 0.9880379, 1.0], + [-0.8791935, 1.0093088, 1.0], + [-0.016242307, 1.0002874, 1.0], + [0.00025250288, 0.99999845, 1.0], + [0.0, 1.0, 1.0], + [-3.0779517, 0.97170013, 1.0], + [-1.4663464, 0.99664164, 1.0], + [-0.10389895, 0.9996869, 1.0], + [-0.00044551404, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-4.567538, 0.9528182, 1.0], + [-1.9175193, 0.96272826, 1.0], + [-0.24028678, 0.988517, 1.0], + [-0.0031621323, 0.9997933, 1.0], + [0.0, 1.0, 1.0], + [-5.301078, 0.9462786, 1.0], + [-2.0612185, 0.9623441, 1.0], + [-0.22937998, 0.98045844, 1.0], + [-0.0035447287, 0.99954635, 1.0], + [0.0, 1.0, 1.0], + [-5.17369, 0.9347734, 1.0], + [-2.6256938, 0.9702652, 1.0], + [-0.9779252, 0.98185503, 1.0], + [-0.025275169, 0.999532, 1.0], + [0.0, 1.0, 1.0], + [-5.6190476, 0.87789106, 1.0], + [-4.1578727, 0.9216868, 1.0], + [-2.5072808, 0.9612179, 1.0], + [-0.089652225, 0.9986668, 1.0], + [0.0, 1.0, 1.0], + [-6.76994, 0.87418616, 1.0], + [-6.514886, 0.907811, 1.0], + [-4.9078856, 0.9322168, 1.0], + [-0.41067597, 0.99400187, 1.0], + [0.0, 1.0, 1.0], + [-6.1820183, 0.9030464, 1.0], + [-4.7838874, 0.9592428, 1.0], + [-3.901864, 0.9493777, 1.0], + [-2.8153791, 0.9527283, 1.0], + [0.0, 1.0, 1.0], + [-5.788914, 0.91208506, 1.0], + [-3.994215, 0.97003484, 1.0], + [-3.385323, 0.9577372, 1.0], + [-2.9413586, 0.9510517, 1.0], + [0.0, 1.0, 1.0], + [-5.824406, 0.83820003, 1.0], + [-3.1810389, 0.9660887, 1.0], + [-1.5774505, 0.9790124, 1.0], + [-0.8217753, 0.9740097, 1.0], + [0.0, 1.0, 1.0], + [-4.3065467, 0.80589384, 1.0], + [-2.6072268, 0.93019086, 1.0], + [-0.21090473, 0.9885001, 1.0], + [0.43375567, 0.9852358, 1.0], + [0.0, 1.0, 1.0], + [-2.482041, 0.80521226, 1.0], + [-1.9464252, 0.89081764, 1.0], + [-1.3067281, 0.97063005, 1.0], + [-0.08558672, 1.0085713, 1.0], + [0.0, 1.0, 1.0], + [0.005971689, 0.79016274, 1.0], + [0.35732928, 0.85779244, 1.0], + [0.1754206, 0.99188244, 1.0], + [0.22379632, 1.037684, 1.0], + [0.0, 1.0, 1.0], + [2.7745373, 0.8123556, 1.0], + [2.660167, 0.8928497, 1.0], + [1.9056137, 1.0349722, 1.0], + [1.3743136, 1.0741935, 1.0], + [0.0, 1.0, 1.0], + [5.84149, 0.94729656, 1.0], + [5.9234858, 0.99772394, 1.0], + [3.5638256, 1.0254989, 1.0], + [0.789219, 1.0284225, 1.0], + [0.0, 1.0, 1.0], + [6.216669, 0.94323486, 1.0], + [6.1197004, 0.97384584, 1.0], + [3.111261, 1.0015063, 1.0], + [0.13389735, 1.0010753, 1.0], + [0.0, 1.0, 1.0], + [6.8003964, 0.8915141, 1.0], + [6.054007, 0.933874, 1.0], + [2.107739, 1.0027483, 1.0], + [0.0493525, 1.0011154, 1.0], + [0.0, 1.0, 1.0], + [7.9880567, 0.84407914, 1.0], + [5.6231203, 0.93609124, 1.0], + [2.2157097, 1.0120378, 1.0], + [0.032817453, 1.0015986, 1.0], + [0.0, 1.0, 1.0], + [8.595032, 0.8414821, 1.0], + [5.738505, 0.9392406, 1.0], + [1.8269083, 1.0084609, 1.0], + [0.04897936, 1.0008367, 1.0], + [0.0, 1.0, 1.0], + [8.220856, 0.87541443, 1.0], + [5.0150123, 0.95354825, 1.0], + [1.1460975, 0.9991748, 1.0], + [0.023244264, 1.0001849, 1.0], + [0.0, 1.0, 1.0], + [6.352977, 0.90786576, 1.0], + [4.568452, 0.9657601, 1.0], + [1.6529517, 1.000971, 1.0], + [0.03857504, 1.0002371, 1.0], + [0.0, 1.0, 1.0], + [4.984532, 0.9082033, 1.0], + [4.602232, 0.9495377, 1.0], + [1.6734874, 0.9877272, 1.0], + [0.03744144, 0.9998395, 1.0], + [0.0, 1.0, 1.0], + [5.9091544, 0.93606, 1.0], + [5.298362, 0.9841254, 1.0], + [1.5838195, 1.0008063, 1.0], + [0.046534117, 1.0002071, 1.0], + [0.0, 1.0, 1.0], + [5.066354, 0.97590166, 1.0], + [4.5295296, 1.0173208, 1.0], + [1.7539304, 1.013229, 1.0], + [0.053133972, 1.0004649, 1.0], + [0.0, 1.0, 1.0], + [3.9509256, 0.96985763, 1.0], + [2.8036354, 1.0145437, 1.0], + [1.5774267, 1.01402, 1.0], + [0.066340216, 1.0006825, 1.0], + [0.0, 1.0, 1.0], + [5.067796, 0.9293189, 1.0], + [4.159626, 1.0254444, 1.0], + [2.7054405, 1.0338923, 1.0], + [0.16508698, 1.0020102, 1.0], + [0.0, 1.0, 1.0], + [6.1854787, 0.88944936, 1.0], + [4.127846, 0.9825014, 1.0], + [2.468719, 1.0214276, 1.0], + [0.17890404, 1.0017699, 1.0], + [0.0, 1.0, 1.0], + [5.5697684, 0.868272, 1.0], + [3.8859425, 0.95566565, 1.0], + [1.9449633, 1.0140244, 1.0], + [0.15235585, 1.0016147, 1.0], + [0.0, 1.0, 1.0], + [4.9004993, 0.8488444, 1.0], + [3.3493254, 0.9401458, 1.0], + [1.7450691, 1.0157793, 1.0], + [0.2219146, 1.0026209, 1.0], + [0.0, 1.0, 1.0], + [5.1511345, 0.8408119, 1.0], + [2.344393, 0.9062895, 1.0], + [-0.006539325, 0.989216, 1.0], + [-0.08388412, 0.9982721, 1.0], + [0.0, 1.0, 1.0], + [5.9436674, 0.83059716, 1.0], + [2.7960584, 0.8703427, 1.0], + [-0.36575812, 0.97218806, 1.0], + [-0.23437832, 0.9956032, 1.0], + [0.0, 1.0, 1.0], + [6.1871066, 0.7945113, 1.0], + [3.3608866, 0.8636077, 1.0], + [0.7976455, 0.97853297, 1.0], + [0.016725179, 0.99557835, 1.0], + [0.0, 1.0, 1.0], + [6.6724114, 0.8086163, 1.0], + [4.0700526, 0.9031084, 1.0], + [2.1568875, 0.99063873, 1.0], + [0.28391585, 0.99492174, 1.0], + [0.0, 1.0, 1.0], + [7.5037627, 0.8231193, 1.0], + [5.7297187, 0.9803322, 1.0], + [4.538334, 1.0247983, 1.0], + [0.78753865, 0.9985357, 1.0], + [0.0, 1.0, 1.0], + [10.182478, 0.7484477, 1.0], + [4.6068373, 0.93632764, 1.0], + [5.901852, 0.9994183, 1.0], + [1.9283233, 1.0053344, 1.0], + [0.0, 1.0, 1.0], + [11.154881, 0.70661646, 1.0], + [2.7159872, 0.93115985, 1.0], + [4.0488343, 1.0193834, 1.0], + [1.9382095, 1.0209278, 1.0], + [0.0, 1.0, 1.0], + [21.429089, 0.534035, 1.0], + [10.9904585, 0.6681652, 1.0], + [0.0045763263, 0.9355186, 1.0], + [0.13676341, 0.9799069, 1.0], + [0.0, 1.0, 1.0], + [19.529167, 0.5656457, 1.0], + [9.061795, 0.7158103, 1.0], + [1.5038702, 0.90302753, 1.0], + [0.78457785, 0.9086724, 1.0], + [0.0, 1.0, 1.0], + [15.508226, 0.5985953, 1.0], + [7.4871893, 0.7188201, 1.0], + [2.3094695, 0.89489657, 1.0], + [0.73899657, 0.9671641, 1.0], + [0.0, 1.0, 1.0], + [10.39154, 0.65087306, 1.0], + [6.622824, 0.6944927, 1.0], + [5.501718, 0.7407023, 1.0], + [1.7589878, 0.92959625, 1.0], + [0.0, 1.0, 1.0], + [8.076021, 0.62203556, 1.0], + [7.467209, 0.6181576, 1.0], + [7.245981, 0.6409904, 1.0], + [5.4263334, 0.7738036, 1.0], + [0.0, 1.0, 1.0], + [8.469799, 0.5848823, 1.0], + [8.423419, 0.5817134, 1.0], + [8.497577, 0.60529923, 1.0], + [9.111917, 0.69250333, 1.0], + [0.0, 1.0, 1.0], + [7.762021, 0.6242976, 1.0], + [7.8041244, 0.61129224, 1.0], + [8.319156, 0.64303446, 1.0], + [9.280437, 0.71576697, 1.0], + [0.0, 1.0, 1.0], + [5.9923735, 0.63731086, 1.0], + [4.8158092, 0.65566516, 1.0], + [6.311018, 0.6723989, 1.0], + [8.32342, 0.7326516, 1.0], + [0.0, 1.0, 1.0], + [6.2170925, 0.60791266, 1.0], + [5.0867386, 0.6423507, 1.0], + [6.415117, 0.66422945, 1.0], + [8.737183, 0.70679927, 1.0], + [0.0, 1.0, 1.0], + [5.2006907, 0.60027266, 1.0], + [4.8628798, 0.61094993, 1.0], + [6.024442, 0.6481432, 1.0], + [9.87192, 0.66732514, 1.0], + [0.0, 1.0, 1.0], + [4.6975617, 0.6112319, 1.0], + [4.514887, 0.6216314, 1.0], + [6.3849893, 0.63863033, 1.0], + [10.629338, 0.64065015, 1.0], + [0.0, 1.0, 1.0], + [4.5348377, 0.62149215, 1.0], + [3.3658662, 0.67045367, 1.0], + [5.1989746, 0.71183586, 1.0], + [8.826136, 0.6767556, 1.0], + [0.0, 1.0, 1.0], + [3.2873642, 0.6042733, 1.0], + [2.2218688, 0.65636855, 1.0], + [0.86957604, 0.82655025, 1.0], + [0.053957075, 0.88620996, 1.0], + [0.0, 1.0, 1.0], + [2.4001925, 0.6340274, 1.0], + [1.1141202, 0.70201296, 1.0], + [0.50959253, 0.8092494, 1.0], + [0.2311227, 0.8478709, 1.0], + [0.0, 1.0, 1.0], + [1.2452888, 0.66887766, 1.0], + [-0.07834225, 0.74380815, 1.0], + [-0.13215514, 0.7847734, 1.0], + [-0.38037032, 0.7592258, 1.0], + [0.0, 1.0, 1.0], + [-1.1262196, 0.6688091, 1.0], + [-1.8940061, 0.6305882, 1.0], + [-1.4030197, 0.7027344, 1.0], + [-1.2132488, 0.7527478, 1.0], + [0.0, 1.0, 1.0], + [-1.7841728, 0.6271489, 1.0], + [-2.0797975, 0.61307365, 1.0], + [-1.7369123, 0.6998982, 1.0], + [-1.4816345, 0.78196275, 1.0], + [0.0, 1.0, 1.0], + [-1.5889271, 0.6240376, 1.0], + [-1.8163804, 0.6369125, 1.0], + [-0.99799794, 0.7531041, 1.0], + [-0.5732026, 0.82648337, 1.0], + [0.0, 1.0, 1.0], + [-2.0947924, 0.62250054, 1.0], + [-2.1894937, 0.6211645, 1.0], + [-1.2518405, 0.701402, 1.0], + [-0.14743061, 0.8963739, 1.0], + [0.0, 1.0, 1.0], + [-1.3260301, 0.622353, 1.0], + [-1.4357653, 0.6204895, 1.0], + [-0.9074722, 0.7065318, 1.0], + [-0.00092270726, 0.94407517, 1.0], + [0.0, 1.0, 1.0], + [0.53253675, 0.609965, 1.0], + [0.7909907, 0.63592654, 1.0], + [0.8281013, 0.861355, 1.0], + [0.09484549, 0.99166083, 1.0], + [0.0, 1.0, 1.0], + [2.1939044, 0.6141987, 1.0], + [2.304398, 0.68116254, 1.0], + [1.0291927, 0.94084936, 1.0], + [0.07020744, 0.99777913, 1.0], + [0.0, 1.0, 1.0], + [2.544789, 0.6641931, 1.0], + [1.7962058, 0.7804687, 1.0], + [0.024910655, 0.9779748, 1.0], + [-0.006917878, 0.9988935, 1.0], + [0.0, 1.0, 1.0], + [2.512072, 0.6963922, 1.0], + [1.0768867, 0.8578466, 1.0], + [-0.15686627, 0.95935565, 1.0], + [-0.018332472, 0.9965962, 1.0], + [0.0, 1.0, 1.0], + [2.2941554, 0.7241867, 1.0], + [0.49197897, 0.8924251, 1.0], + [-0.07057831, 0.97556025, 1.0], + [0.0007015772, 0.9984997, 1.0], + [0.0, 1.0, 1.0], + [-0.14841639, 0.75324446, 1.0], + [0.3125728, 0.9184302, 1.0], + [0.12940219, 0.99002546, 1.0], + [0.004882609, 0.9996811, 1.0], + [0.0, 1.0, 1.0], + [1.0759145, 0.8147672, 1.0], + [0.36428088, 0.95350146, 1.0], + [0.044762604, 0.9945495, 1.0], + [0.0020008544, 0.99985397, 1.0], + [0.0, 1.0, 1.0], + [1.1844163, 0.82202315, 1.0], + [0.079763375, 0.9658192, 1.0], + [-0.040133536, 0.99962455, 1.0], + [-0.0008112378, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [1.3717666, 0.79799265, 1.0], + [0.041872248, 0.9601765, 1.0], + [-0.031498604, 0.9993519, 1.0], + [-0.00014435564, 0.99999434, 1.0], + [0.0, 1.0, 1.0], + [1.4442534, 0.8161425, 1.0], + [-0.063920654, 0.9636011, 1.0], + [-0.05635313, 0.9993309, 1.0], + [-0.00048342353, 0.99999565, 1.0], + [0.0, 1.0, 1.0], + [0.52015144, 0.8598735, 1.0], + [0.19060682, 0.97779083, 1.0], + [0.0042832624, 0.9997414, 1.0], + [4.660216e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.7497536, 0.84093064, 1.0], + [-0.81031346, 0.97599655, 1.0], + [-0.09859322, 0.99943244, 1.0], + [-0.0006943415, 0.9999969, 1.0], + [0.0, 1.0, 1.0], + [-1.3677365, 0.8722283, 1.0], + [-1.8130565, 0.97921914, 1.0], + [-0.21724299, 0.9991554, 1.0], + [-0.0015388737, 0.99999464, 1.0], + [0.0, 1.0, 1.0], + [-0.91486025, 0.8624227, 1.0], + [-1.8185471, 0.98354757, 1.0], + [-0.18334843, 1.00006, 1.0], + [-0.0010572135, 1.0000012, 1.0], + [0.0, 1.0, 1.0], + [-0.7346696, 0.8424903, 1.0], + [-0.63329214, 0.9792088, 1.0], + [-0.059044026, 0.9998297, 1.0], + [-0.00033109298, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-1.6148448, 0.8629954, 1.0], + [-0.1529285, 0.9853164, 1.0], + [-0.0012836644, 0.99992263, 1.0], + [2.836192e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-2.1700978, 0.8661239, 1.0], + [-0.63642955, 0.98434937, 1.0], + [-0.031005092, 0.99981445, 1.0], + [-0.000120288605, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [-2.4762814, 0.86192167, 1.0], + [-0.56582534, 0.98256135, 1.0], + [-0.020217514, 0.9998631, 1.0], + [-7.2207775e-05, 0.9999997, 1.0], + [0.0, 1.0, 1.0], + [-3.8507402, 0.82345366, 1.0], + [-0.7367012, 0.96947515, 1.0], + [-0.014731503, 0.9995469, 1.0], + [-6.203018e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-4.3435655, 0.7789662, 1.0], + [-1.2070022, 0.9441542, 1.0], + [-0.06403829, 0.99689096, 1.0], + [-0.0005338597, 0.999972, 1.0], + [0.0, 1.0, 1.0], + [-7.598504, 0.7135656, 1.0], + [-2.8674366, 0.8796157, 1.0], + [-0.2061764, 0.98925316, 1.0], + [-0.0019789634, 0.9999003, 1.0], + [0.0, 1.0, 1.0], + [-8.5659275, 0.68000054, 1.0], + [-6.2886615, 0.7959308, 1.0], + [-1.6636302, 0.9520329, 1.0], + [-0.02707145, 0.99932, 1.0], + [0.0, 1.0, 1.0], + [-9.858409, 0.65844935, 1.0], + [-10.290696, 0.7347493, 1.0], + [-7.691491, 0.82455605, 1.0], + [-0.43717942, 0.9903527, 1.0], + [0.0, 1.0, 1.0], + [-9.475192, 0.660726, 1.0], + [-8.357337, 0.8211995, 1.0], + [-6.577057, 0.9004506, 1.0], + [-2.6555653, 0.960382, 1.0], + [0.0, 1.0, 1.0], + [-9.107314, 0.6639968, 1.0], + [-6.700647, 0.8769558, 1.0], + [-5.7258368, 0.9253376, 1.0], + [-4.3847528, 0.93834984, 1.0], + [0.0, 1.0, 1.0], + [-8.857171, 0.6214388, 1.0], + [-7.408514, 0.77258706, 1.0], + [-5.1308303, 0.92324936, 1.0], + [-3.1422675, 0.95364594, 1.0], + [0.0, 1.0, 1.0], + [-7.9207935, 0.5845634, 1.0], + [-7.1896048, 0.6665354, 1.0], + [-4.057381, 0.85848784, 1.0], + [-0.73692507, 0.97766274, 1.0], + [0.0, 1.0, 1.0], + [-6.263444, 0.5832066, 1.0], + [-5.9315505, 0.6382356, 1.0], + [-6.1455026, 0.72098804, 1.0], + [-1.0433422, 0.96975046, 1.0], + [0.0, 1.0, 1.0], + [-2.4575808, 0.57827437, 1.0], + [-2.964139, 0.62386805, 1.0], + [-4.5475383, 0.7487994, 1.0], + [-1.4768721, 0.9732885, 1.0], + [0.0, 1.0, 1.0], + [1.157196, 0.57085544, 1.0], + [-0.23392056, 0.64966595, 1.0], + [-1.4028531, 0.88008684, 1.0], + [-1.9659572, 0.9766486, 1.0], + [0.0, 1.0, 1.0], + [4.5584664, 0.6198235, 1.0], + [4.1219063, 0.74054, 1.0], + [0.9924724, 0.93495196, 1.0], + [-0.7218012, 0.988608, 1.0], + [0.0, 1.0, 1.0], + [3.719361, 0.6867312, 1.0], + [3.9891121, 0.7887134, 1.0], + [1.6480545, 0.94551146, 1.0], + [0.059591822, 0.99834126, 1.0], + [0.0, 1.0, 1.0], + [4.201495, 0.7431256, 1.0], + [4.526226, 0.8292865, 1.0], + [1.1760861, 0.9666664, 1.0], + [0.023769544, 0.9994029, 1.0], + [0.0, 1.0, 1.0], + [4.170543, 0.76272196, 1.0], + [2.6068974, 0.8673706, 1.0], + [0.39172325, 0.9858963, 1.0], + [0.004990869, 0.9999019, 1.0], + [0.0, 1.0, 1.0], + [4.301262, 0.77241415, 1.0], + [2.272072, 0.8702671, 1.0], + [0.20433316, 0.98867095, 1.0], + [0.0015561674, 0.9999392, 1.0], + [0.0, 1.0, 1.0], + [1.9231374, 0.8245692, 1.0], + [1.1306319, 0.9499702, 1.0], + [0.12337404, 0.9968917, 1.0], + [0.0012145389, 0.9999798, 1.0], + [0.0, 1.0, 1.0], + [0.7749719, 0.84335756, 1.0], + [0.5761641, 0.95042235, 1.0], + [0.15160465, 0.9963909, 1.0], + [0.0022156653, 0.9999764, 1.0], + [0.0, 1.0, 1.0], + [0.24714066, 0.83536404, 1.0], + [0.69087815, 0.93358576, 1.0], + [0.13375169, 0.9901696, 1.0], + [0.00097174913, 0.9998683, 1.0], + [0.0, 1.0, 1.0], + [0.9913264, 0.84575355, 1.0], + [0.6101601, 0.9683786, 1.0], + [0.03825838, 0.9969422, 1.0], + [-0.00015123004, 0.9999521, 1.0], + [0.0, 1.0, 1.0], + [-0.36478883, 0.8678444, 1.0], + [0.0008414984, 0.9753911, 1.0], + [0.040139902, 0.9994379, 1.0], + [0.0008033622, 0.999998, 1.0], + [0.0, 1.0, 1.0], + [1.5517159, 0.860102, 1.0], + [-0.14287986, 0.9731134, 1.0], + [-0.0241953, 0.9983607, 1.0], + [0.00026539815, 0.9999833, 1.0], + [0.0, 1.0, 1.0], + [1.771025, 0.8242807, 1.0], + [0.39131552, 0.9689884, 1.0], + [0.10286286, 1.0004354, 1.0], + [0.0041896314, 1.0000376, 1.0], + [0.0, 1.0, 1.0], + [3.0717576, 0.7639471, 1.0], + [1.06725, 0.9274218, 1.0], + [0.16506667, 0.99773335, 1.0], + [0.0063294014, 0.99998826, 1.0], + [0.0, 1.0, 1.0], + [2.2291703, 0.7308612, 1.0], + [0.49462095, 0.8725961, 1.0], + [-0.10180267, 0.97933596, 1.0], + [-0.0027677065, 0.99942666, 1.0], + [0.0, 1.0, 1.0], + [1.6348059, 0.6752963, 1.0], + [-0.1813347, 0.81271625, 1.0], + [-0.452213, 0.9789637, 1.0], + [-0.02926826, 0.99930775, 1.0], + [0.0, 1.0, 1.0], + [0.977978, 0.63618356, 1.0], + [-0.62426865, 0.7659978, 1.0], + [-1.2976239, 0.96756303, 1.0], + [-0.16725117, 0.9974616, 1.0], + [0.0, 1.0, 1.0], + [2.5557356, 0.63749415, 1.0], + [-0.46793, 0.74725634, 1.0], + [-1.4595929, 0.9653957, 1.0], + [-0.23743904, 0.99673766, 1.0], + [0.0, 1.0, 1.0], + [6.1136274, 0.6158691, 1.0], + [1.4451386, 0.7140542, 1.0], + [-0.7944747, 0.9716655, 1.0], + [-0.17677365, 0.9974942, 1.0], + [0.0, 1.0, 1.0], + [12.495926, 0.6081697, 1.0], + [2.6334763, 0.75307316, 1.0], + [-0.12727743, 0.9822432, 1.0], + [-0.019888313, 0.9994333, 1.0], + [0.0, 1.0, 1.0], + [20.359663, 0.5505554, 1.0], + [5.360571, 0.7478232, 1.0], + [-0.15579183, 0.9834688, 1.0], + [0.0031478892, 0.9996698, 1.0], + [0.0, 1.0, 1.0], + [22.983652, 0.51872075, 1.0], + [11.201727, 0.65178657, 1.0], + [0.02786072, 0.96850365, 1.0], + [0.050246127, 0.9990089, 1.0], + [0.0, 1.0, 1.0], + [22.102345, 0.5159302, 1.0], + [13.516024, 0.6239164, 1.0], + [-0.2264105, 0.9579077, 1.0], + [0.011741261, 0.9989512, 1.0], + [0.0, 1.0, 1.0], + [16.458723, 0.6381099, 1.0], + [2.070874, 0.8461226, 1.0], + [-0.38940287, 0.94793105, 1.0], + [-0.004449404, 0.9940882, 1.0], + [0.0, 1.0, 1.0], + [14.319419, 0.6865272, 1.0], + [2.107183, 0.90325254, 1.0], + [0.08382885, 0.95650256, 1.0], + [0.08427957, 0.95735323, 1.0], + [0.0, 1.0, 1.0], + [11.907165, 0.7272812, 1.0], + [2.3358235, 0.9138314, 1.0], + [0.061927434, 0.9853146, 1.0], + [0.02880717, 0.98940504, 1.0], + [0.0, 1.0, 1.0], + [9.328565, 0.7501317, 1.0], + [3.736033, 0.8165723, 1.0], + [0.886498, 0.9493906, 1.0], + [0.53428864, 0.9778165, 1.0], + [0.0, 1.0, 1.0], + [7.8924174, 0.6558145, 1.0], + [8.357237, 0.58703214, 1.0], + [5.640144, 0.71630454, 1.0], + [2.3299222, 0.9120247, 1.0], + [0.0, 1.0, 1.0], + [8.797466, 0.5970874, 1.0], + [10.189405, 0.5419211, 1.0], + [9.585496, 0.602155, 1.0], + [8.76234, 0.7506968, 1.0], + [0.0, 1.0, 1.0], + [5.77069, 0.73146415, 1.0], + [8.458371, 0.6105788, 1.0], + [9.997233, 0.66665345, 1.0], + [14.185366, 0.72296506, 1.0], + [0.0, 1.0, 1.0], + [4.832553, 0.7476512, 1.0], + [6.631264, 0.64727205, 1.0], + [9.165473, 0.66433716, 1.0], + [12.874936, 0.71633804, 1.0], + [0.0, 1.0, 1.0], + [4.3384695, 0.7561362, 1.0], + [5.5324025, 0.6862774, 1.0], + [6.1772375, 0.7093537, 1.0], + [5.11874, 0.82211673, 1.0], + [0.0, 1.0, 1.0], + [4.3927083, 0.68014836, 1.0], + [5.3236537, 0.617722, 1.0], + [5.153285, 0.7291801, 1.0], + [6.883996, 0.7625799, 1.0], + [0.0, 1.0, 1.0], + [3.9320729, 0.6744753, 1.0], + [4.8258367, 0.61940265, 1.0], + [5.4672303, 0.7232466, 1.0], + [8.657131, 0.7020352, 1.0], + [0.0, 1.0, 1.0], + [1.7061602, 0.8554576, 1.0], + [1.1815847, 0.8894174, 1.0], + [1.0903149, 0.93655825, 1.0], + [2.8275063, 0.8868961, 1.0], + [0.0, 1.0, 1.0], + [0.90848404, 0.85196733, 1.0], + [0.4342817, 0.9318938, 1.0], + [0.04572374, 0.9942397, 1.0], + [0.0133670345, 0.9983886, 1.0], + [0.0, 1.0, 1.0], + [0.4749884, 0.8968156, 1.0], + [0.13983242, 0.95840764, 1.0], + [0.012114237, 0.9945305, 1.0], + [0.004142619, 0.9975343, 1.0], + [0.0, 1.0, 1.0], + [0.2421307, 0.94426733, 1.0], + [0.025308255, 0.97187287, 1.0], + [-0.013660883, 0.9893051, 1.0], + [-0.014684059, 0.9894599, 1.0], + [0.0, 1.0, 1.0], + [-0.15639894, 0.9373672, 1.0], + [-0.3664362, 0.8935984, 1.0], + [-0.15253535, 0.9533504, 1.0], + [-0.039207056, 0.98815924, 1.0], + [0.0, 1.0, 1.0], + [-0.43275413, 0.8943086, 1.0], + [-0.70259815, 0.840497, 1.0], + [-0.252648, 0.937427, 1.0], + [-0.03330618, 0.9936832, 1.0], + [0.0, 1.0, 1.0], + [0.06659569, 0.87807643, 1.0], + [-0.1700009, 0.8497151, 1.0], + [-0.12636127, 0.963959, 1.0], + [-0.013172978, 0.9975136, 1.0], + [0.0, 1.0, 1.0], + [-0.64482135, 0.87148684, 1.0], + [-0.74943525, 0.83117217, 1.0], + [-0.27330378, 0.93200976, 1.0], + [-0.021869717, 0.9946897, 1.0], + [0.0, 1.0, 1.0], + [-1.0401838, 0.8728109, 1.0], + [-0.73823136, 0.8744596, 1.0], + [-0.18287456, 0.95961267, 1.0], + [-0.009951859, 0.9977242, 1.0], + [0.0, 1.0, 1.0], + [-0.22627981, 0.88137406, 1.0], + [0.007839352, 0.9245987, 1.0], + [0.0014544588, 0.99349904, 1.0], + [-2.9291497e-05, 0.99989325, 1.0], + [0.0, 1.0, 1.0], + [-0.18447767, 0.8809199, 1.0], + [0.008496483, 0.9478462, 1.0], + [0.0067973994, 0.9979893, 1.0], + [5.0736588e-05, 0.9999927, 1.0], + [0.0, 1.0, 1.0], + [0.29172453, 0.92011845, 1.0], + [0.15676522, 0.9734236, 1.0], + [0.004252948, 0.99931306, 1.0], + [-1.0232604e-05, 0.9999954, 1.0], + [0.0, 1.0, 1.0], + [0.17869076, 0.9557188, 1.0], + [0.08755076, 0.99127334, 1.0], + [0.0010010353, 0.99973154, 1.0], + [-5.539131e-05, 0.9999892, 1.0], + [0.0, 1.0, 1.0], + [0.22946571, 0.9652117, 1.0], + [0.05355996, 0.9938835, 1.0], + [0.0004149077, 0.9998538, 1.0], + [8.350738e-06, 0.9999956, 1.0], + [0.0, 1.0, 1.0], + [-0.037329286, 0.9731236, 1.0], + [0.006478223, 0.9956182, 1.0], + [0.00065915973, 0.9999197, 1.0], + [2.1461079e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [0.038507767, 0.98602444, 1.0], + [0.009278379, 0.9980656, 1.0], + [0.00043359466, 0.9999601, 1.0], + [1.2074154e-05, 0.99999934, 1.0], + [0.0, 1.0, 1.0], + [0.024321932, 0.99058217, 1.0], + [0.0005276345, 0.99881136, 1.0], + [-0.000108261214, 0.99998933, 1.0], + [-2.0593393e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.08471028, 0.98001426, 1.0], + [0.007567339, 0.9977277, 1.0], + [-0.000107173975, 0.9999823, 1.0], + [-1.4792153e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.21450363, 0.97752666, 1.0], + [0.027476575, 0.9972575, 1.0], + [1.0573019e-05, 0.99997705, 1.0], + [-2.127626e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.13710256, 0.98749036, 1.0], + [0.021214448, 0.9985921, 1.0], + [0.00022974156, 0.99998766, 1.0], + [6.995456e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.034353305, 0.9869362, 1.0], + [-0.0033373374, 0.99891263, 1.0], + [-0.0008769339, 0.99998987, 1.0], + [-6.4116425e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.029380038, 0.9895625, 1.0], + [-0.010520089, 0.99891555, 1.0], + [-0.0019082854, 0.9999861, 1.0], + [-1.3866463e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.085345775, 0.9883013, 1.0], + [-0.0024072328, 0.99874765, 1.0], + [-0.001102076, 0.99999017, 1.0], + [-6.650834e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.09333435, 0.9841484, 1.0], + [0.007872991, 0.99843925, 1.0], + [-0.00031172566, 0.9999886, 1.0], + [-2.2500953e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.1495411, 0.9844692, 1.0], + [-0.009347158, 0.9987978, 1.0], + [-4.104763e-05, 0.9999937, 1.0], + [-4.1019636e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13588922, 0.99047935, 1.0], + [-0.011877171, 0.9992225, 1.0], + [-0.00016244329, 0.99999523, 1.0], + [-4.7618443e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18492526, 0.98800945, 1.0], + [-0.01919961, 0.9989189, 1.0], + [-0.00018170738, 0.9999931, 1.0], + [-2.8776438e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.5042778, 0.9758248, 1.0], + [-0.05973959, 0.99744993, 1.0], + [-0.00048369938, 0.99998105, 1.0], + [-3.4633175e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-1.1252605, 0.9543784, 1.0], + [-0.18540663, 0.99284697, 1.0], + [-0.0028871074, 0.99987376, 1.0], + [-9.758851e-06, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [-1.6847491, 0.9297688, 1.0], + [-0.30172828, 0.9871863, 1.0], + [-0.006863096, 0.9996188, 1.0], + [-3.727347e-05, 0.99999744, 1.0], + [0.0, 1.0, 1.0], + [-2.370997, 0.89369446, 1.0], + [-1.2190995, 0.9472215, 1.0], + [-0.12854093, 0.99495983, 1.0], + [-0.0013108054, 0.999956, 1.0], + [0.0, 1.0, 1.0], + [-4.9047966, 0.7847389, 1.0], + [-5.3030334, 0.8162339, 1.0], + [-1.4172684, 0.9577376, 1.0], + [-0.026107062, 0.999312, 1.0], + [0.0, 1.0, 1.0], + [-5.9655056, 0.73402, 1.0], + [-5.2766113, 0.82234603, 1.0], + [-1.8166275, 0.95730686, 1.0], + [-0.073980905, 0.99863815, 1.0], + [0.0, 1.0, 1.0], + [-7.649344, 0.66660523, 1.0], + [-6.673369, 0.77574635, 1.0], + [-3.8847363, 0.9271979, 1.0], + [-0.35958254, 0.9939365, 1.0], + [0.0, 1.0, 1.0], + [-8.770011, 0.6152831, 1.0], + [-8.663781, 0.6730373, 1.0], + [-4.5796795, 0.8964258, 1.0], + [-0.38371933, 0.9933861, 1.0], + [0.0, 1.0, 1.0], + [-9.064724, 0.563459, 1.0], + [-8.883273, 0.5998541, 1.0], + [-3.3382602, 0.8610413, 1.0], + [-0.06820223, 0.9978487, 1.0], + [0.0, 1.0, 1.0], + [-8.522412, 0.5443333, 1.0], + [-10.088787, 0.5584405, 1.0], + [-8.74115, 0.69652474, 1.0], + [-0.46212718, 0.9844496, 1.0], + [0.0, 1.0, 1.0], + [-5.3416877, 0.5573365, 1.0], + [-10.180397, 0.54992926, 1.0], + [-10.34654, 0.65771925, 1.0], + [-2.239636, 0.95445883, 1.0], + [0.0, 1.0, 1.0], + [-0.17010239, 0.6200763, 1.0], + [-3.7829561, 0.64616305, 1.0], + [-7.5884185, 0.8539007, 1.0], + [-7.036888, 0.9037384, 1.0], + [0.0, 1.0, 1.0], + [2.296787, 0.8043461, 1.0], + [1.2797425, 0.88472384, 1.0], + [-2.9147022, 0.94889057, 1.0], + [-2.639398, 0.96286654, 1.0], + [0.0, 1.0, 1.0], + [1.1746049, 0.87596625, 1.0], + [2.4819775, 0.8980377, 1.0], + [0.43790334, 0.9830677, 1.0], + [-0.006361744, 0.9996459, 1.0], + [0.0, 1.0, 1.0], + [1.4755538, 0.92148954, 1.0], + [3.057467, 0.9028049, 1.0], + [0.5373922, 0.9829736, 1.0], + [0.005417032, 0.99982744, 1.0], + [0.0, 1.0, 1.0], + [0.19876552, 0.97760195, 1.0], + [0.26070496, 0.99011695, 1.0], + [0.033617545, 0.9989797, 1.0], + [0.0002978694, 0.99999154, 1.0], + [0.0, 1.0, 1.0], + [0.07401737, 0.9809595, 1.0], + [0.0669684, 0.9947014, 1.0], + [0.0036516946, 0.99973106, 1.0], + [1.5824207e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.24908549, 0.9819961, 1.0], + [-0.027645566, 0.9976866, 1.0], + [-0.00042068172, 0.9999474, 1.0], + [1.7829321e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.45939854, 0.97829735, 1.0], + [-0.06557831, 0.99736834, 1.0], + [-0.0014225548, 0.99993753, 1.0], + [7.041205e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.97001255, 0.94978815, 1.0], + [-0.06016365, 0.9956392, 1.0], + [0.0005834204, 0.9998938, 1.0], + [1.0331767e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.8007346, 0.9450692, 1.0], + [0.061064422, 0.9958661, 1.0], + [0.0004179476, 0.99996316, 1.0], + [-5.38278e-07, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.7554829, 0.97007614, 1.0], + [0.038412966, 0.99764246, 1.0], + [-1.2250363e-05, 0.99998575, 1.0], + [-4.3817803e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.4214841, 0.98421025, 1.0], + [0.018357918, 0.9986999, 1.0], + [-0.0001539443, 0.99998885, 1.0], + [-2.1144313e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [0.26302785, 0.9877291, 1.0], + [0.016418263, 0.99897736, 1.0], + [0.00017517614, 0.999994, 1.0], + [3.7461887e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.2549705, 0.9871261, 1.0], + [0.019887371, 0.99840975, 1.0], + [-3.7766345e-05, 0.99995524, 1.0], + [-7.270728e-06, 0.9999991, 1.0], + [0.0, 1.0, 1.0], + [0.57600856, 0.9830809, 1.0], + [0.028567612, 0.99657667, 1.0], + [-0.00476558, 0.99963075, 1.0], + [-0.00016184518, 0.9999903, 1.0], + [0.0, 1.0, 1.0], + [0.023581166, 0.9658987, 1.0], + [-0.12978074, 0.9894591, 1.0], + [-0.010897471, 0.999496, 1.0], + [-0.0003179809, 0.9999912, 1.0], + [0.0, 1.0, 1.0], + [-0.48003206, 0.947442, 1.0], + [-0.28566536, 0.98114216, 1.0], + [-0.02372709, 0.99919075, 1.0], + [-0.0012802386, 0.9999792, 1.0], + [0.0, 1.0, 1.0], + [-0.30012533, 0.9344908, 1.0], + [-0.415363, 0.9781973, 1.0], + [-0.04023375, 0.99892974, 1.0], + [-0.002275092, 0.9999678, 1.0], + [0.0, 1.0, 1.0], + [1.2335646, 0.91651773, 1.0], + [-0.35089287, 0.9740984, 1.0], + [-0.03576802, 0.9988748, 1.0], + [-0.0019647575, 0.99997276, 1.0], + [0.0, 1.0, 1.0], + [4.4362745, 0.8536556, 1.0], + [-0.5057072, 0.96044815, 1.0], + [-0.05471109, 0.99809283, 1.0], + [-0.00035249075, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [12.081045, 0.7132523, 1.0], + [0.5422661, 0.9341002, 1.0], + [-0.06538023, 0.99676913, 1.0], + [-0.00047848828, 0.9999808, 1.0], + [0.0, 1.0, 1.0], + [17.529392, 0.60886335, 1.0], + [1.5537803, 0.8626087, 1.0], + [-0.3528088, 0.98406494, 1.0], + [-0.0052189324, 0.99973387, 1.0], + [0.0, 1.0, 1.0], + [18.210768, 0.5879146, 1.0], + [1.4481329, 0.77672154, 1.0], + [-0.9064065, 0.9183754, 1.0], + [-0.024934258, 0.99743956, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_D5300.json b/assets/camera-profiles/NIKON_D5300.json new file mode 100644 index 000000000..5ab1f062e --- /dev/null +++ b/assets/camera-profiles/NIKON_D5300.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON D5300", + "files": 264, + "samples": 964687, + "matrix": [ + [1.1916187747366802, -0.24231291824981582, 0.06983947565237258], + [-0.07690142035770187, 1.1730235959883188, -0.10538788116290476], + [0.03036949208278017, -0.9047958593485248, 1.8955506437912675] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [-0.27515984, 1.1516873, 1.0], + [1.6639282, 1.1351492, 1.0], + [3.788241, 1.0706222, 1.0], + [2.7755148, 1.0334021, 1.0], + [0.0, 1.0, 1.0], + [0.14424162, 1.1334472, 1.0], + [1.3097183, 1.1570547, 1.0], + [2.2948222, 1.0653765, 1.0], + [2.48696, 1.0126696, 1.0], + [0.0, 1.0, 1.0], + [0.39059362, 1.108461, 1.0], + [0.91269153, 1.1615747, 1.0], + [1.2193394, 1.1302495, 1.0], + [1.564095, 1.0433257, 1.0], + [0.0, 1.0, 1.0], + [0.37582052, 1.0874242, 1.0], + [0.37180975, 1.12975, 1.0], + [0.08854759, 1.119602, 1.0], + [0.12284424, 1.0544983, 1.0], + [0.0, 1.0, 1.0], + [0.41708267, 1.0888891, 1.0], + [0.06426376, 1.1006688, 1.0], + [-0.8100427, 1.0653179, 1.0], + [-1.3206435, 1.0191233, 1.0], + [0.0, 1.0, 1.0], + [0.13243157, 1.080272, 1.0], + [-0.96863246, 1.0961237, 1.0], + [-2.4892373, 1.0579139, 1.0], + [-3.6368277, 1.0045699, 1.0], + [0.0, 1.0, 1.0], + [-0.5086707, 1.0768719, 1.0], + [-2.4455884, 1.090032, 1.0], + [-4.392341, 1.0430846, 1.0], + [-5.70884, 0.984795, 1.0], + [0.0, 1.0, 1.0], + [-0.9826923, 1.0976027, 1.0], + [-3.1522026, 1.0984454, 1.0], + [-5.0928793, 1.0361301, 1.0], + [-6.190662, 0.9671035, 1.0], + [0.0, 1.0, 1.0], + [-0.83517295, 1.1328652, 1.0], + [-2.3040426, 1.1309464, 1.0], + [-3.782865, 1.0633757, 1.0], + [-5.345632, 0.9701844, 1.0], + [0.0, 1.0, 1.0], + [0.5699928, 1.1778497, 1.0], + [-0.5092877, 1.1737088, 1.0], + [-1.6260748, 1.0975113, 1.0], + [-3.6590147, 0.97431755, 1.0], + [0.0, 1.0, 1.0], + [1.965812, 1.2052466, 1.0], + [1.2205318, 1.1937522, 1.0], + [0.32865468, 1.1070911, 1.0], + [-0.5527478, 0.9777932, 1.0], + [0.0, 1.0, 1.0], + [2.1100607, 1.198459, 1.0], + [2.1056213, 1.1429148, 1.0], + [1.3356481, 1.0316085, 1.0], + [0.5096412, 0.93477476, 1.0], + [0.0, 1.0, 1.0], + [1.4636977, 1.2118869, 1.0], + [1.7222096, 1.1241792, 1.0], + [0.98221314, 1.0024475, 1.0], + [0.21372011, 0.9131384, 1.0], + [0.0, 1.0, 1.0], + [0.5342778, 1.1797847, 1.0], + [0.957812, 1.0956764, 1.0], + [0.90817916, 1.0003859, 1.0], + [0.27506226, 0.91949815, 1.0], + [0.0, 1.0, 1.0], + [0.18069139, 1.1459695, 1.0], + [1.0440427, 1.0779425, 1.0], + [1.4630631, 0.9941303, 1.0], + [1.2396606, 0.87395155, 1.0], + [0.0, 1.0, 1.0], + [0.18410955, 1.0979248, 1.0], + [1.3498847, 1.0526143, 1.0], + [2.2518015, 0.97271824, 1.0], + [2.5790663, 0.85005623, 1.0], + [0.0, 1.0, 1.0], + [0.86806947, 1.0538092, 1.0], + [2.255343, 1.0105029, 1.0], + [3.711173, 0.92738235, 1.0], + [4.5281615, 0.85241896, 1.0], + [0.0, 1.0, 1.0], + [1.1754395, 1.0296917, 1.0], + [2.5134487, 0.98583174, 1.0], + [3.7772336, 0.90998256, 1.0], + [4.45152, 0.85479385, 1.0], + [0.0, 1.0, 1.0], + [1.1445321, 1.0435532, 1.0], + [2.0396032, 0.9995336, 1.0], + [3.0725362, 0.9361685, 1.0], + [3.631579, 0.8885794, 1.0], + [0.0, 1.0, 1.0], + [0.7827189, 1.0589092, 1.0], + [1.0505862, 1.0227683, 1.0], + [1.7957299, 0.98822904, 1.0], + [1.967721, 0.9724715, 1.0], + [0.0, 1.0, 1.0], + [-0.29322907, 1.06687, 1.0], + [-0.08487194, 1.0363688, 1.0], + [0.55795294, 1.0033514, 1.0], + [0.38443246, 0.9958227, 1.0], + [0.0, 1.0, 1.0], + [-0.20798732, 1.0558138, 1.0], + [-0.22798188, 1.0087736, 1.0], + [-0.27102852, 0.97902, 1.0], + [-0.030924844, 0.9963477, 1.0], + [0.0, 1.0, 1.0], + [-0.6630197, 1.0661411, 1.0], + [-1.1126446, 1.0020812, 1.0], + [-0.8216712, 0.96949637, 1.0], + [-0.05875481, 0.99697804, 1.0], + [0.0, 1.0, 1.0], + [-1.306994, 1.0804613, 1.0], + [-1.9421868, 1.0075339, 1.0], + [-0.4435876, 0.9711658, 1.0], + [-0.009687971, 0.9980309, 1.0], + [0.0, 1.0, 1.0], + [-1.1269542, 1.0775944, 1.0], + [-1.3976283, 1.0217266, 1.0], + [-0.24913609, 0.98865783, 1.0], + [-0.0031247279, 0.9994072, 1.0], + [0.0, 1.0, 1.0], + [-1.375694, 1.0512048, 1.0], + [-0.75242716, 1.0082058, 1.0], + [-0.06595289, 0.99760807, 1.0], + [-0.0031345324, 0.99984926, 1.0], + [0.0, 1.0, 1.0], + [-1.7031127, 1.0458598, 1.0], + [-1.1684496, 1.0118368, 1.0], + [-0.1479137, 0.9961412, 1.0], + [-0.0055244397, 0.9997797, 1.0], + [0.0, 1.0, 1.0], + [-1.7284406, 1.0431077, 1.0], + [-0.73593587, 1.002617, 1.0], + [0.02080173, 0.9911072, 1.0], + [0.00041606076, 0.9996808, 1.0], + [0.0, 1.0, 1.0], + [-1.2268276, 1.0388371, 1.0], + [-1.0188804, 1.0028651, 1.0], + [-0.10369495, 0.9951023, 1.0], + [-0.00074021873, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [-0.4187577, 1.0261325, 1.0], + [-1.5957482, 0.9980721, 1.0], + [-0.1547406, 0.99747145, 1.0], + [0.006172743, 0.99982816, 1.0], + [0.0, 1.0, 1.0], + [0.17795917, 1.0130396, 1.0], + [-0.6837107, 0.98732287, 1.0], + [-0.06094904, 0.9973522, 1.0], + [0.004337359, 0.99988484, 1.0], + [0.0, 1.0, 1.0], + [2.0028615, 1.0350925, 1.0], + [0.68680644, 1.000726, 1.0], + [0.019622738, 0.99780047, 1.0], + [0.0016065001, 0.999882, 1.0], + [0.0, 1.0, 1.0], + [2.0603294, 1.019478, 1.0], + [0.38180757, 0.983154, 1.0], + [-0.23340763, 0.98130405, 1.0], + [-0.0110910535, 0.9989576, 1.0], + [0.0, 1.0, 1.0], + [2.5435147, 1.0271243, 1.0], + [0.99150276, 0.989704, 1.0], + [-0.28383088, 0.98094606, 1.0], + [-0.020072438, 0.9989133, 1.0], + [0.0, 1.0, 1.0], + [3.08294, 1.0426484, 1.0], + [1.818317, 1.0104465, 1.0], + [0.061887734, 0.99672973, 1.0], + [-0.003478195, 0.99980235, 1.0], + [0.0, 1.0, 1.0], + [3.420556, 1.0468477, 1.0], + [1.3910332, 0.9856526, 1.0], + [0.014045477, 0.992967, 1.0], + [-0.0012650526, 0.99988115, 1.0], + [0.0, 1.0, 1.0], + [1.0608708, 1.0270871, 1.0], + [-0.91338277, 0.92517686, 1.0], + [-0.4462344, 0.9653749, 1.0], + [-0.00868847, 0.99921674, 1.0], + [0.0, 1.0, 1.0], + [-0.41799447, 1.0205152, 1.0], + [-1.6170602, 0.9293957, 1.0], + [-0.6444141, 0.96566117, 1.0], + [-0.013291724, 0.99908036, 1.0], + [0.0, 1.0, 1.0], + [0.30684704, 1.0309951, 1.0], + [-0.32407844, 0.9751206, 1.0], + [-0.2714902, 0.9828883, 1.0], + [-0.00732241, 0.9995556, 1.0], + [0.0, 1.0, 1.0], + [0.23834689, 1.0267385, 1.0], + [0.17927068, 0.98710644, 1.0], + [0.18505995, 0.988422, 1.0], + [0.007564359, 0.99952435, 1.0], + [0.0, 1.0, 1.0], + [0.9417192, 1.0186211, 1.0], + [0.4331768, 0.98655975, 1.0], + [0.5120104, 0.973346, 1.0], + [0.046577834, 0.9975678, 1.0], + [0.0, 1.0, 1.0], + [0.37069434, 1.015258, 1.0], + [-0.33970982, 1.0002911, 1.0], + [0.21809609, 0.9738671, 1.0], + [0.05885403, 0.996412, 1.0], + [0.0, 1.0, 1.0], + [0.3410674, 1.0226996, 1.0], + [-0.46905938, 1.0094384, 1.0], + [1.1913799, 0.9839222, 1.0], + [0.27729467, 0.9954679, 1.0], + [0.0, 1.0, 1.0], + [1.8399196, 1.0310917, 1.0], + [1.3589114, 1.0043445, 1.0], + [3.2229822, 0.9866625, 1.0], + [0.79395044, 0.9953453, 1.0], + [0.0, 1.0, 1.0], + [3.6512873, 1.0852343, 1.0], + [3.6366863, 1.0575762, 1.0], + [4.409545, 1.0124124, 1.0], + [1.2255954, 0.9980612, 1.0], + [0.0, 1.0, 1.0], + [3.5640118, 1.103434, 1.0], + [3.857803, 1.0989252, 1.0], + [5.013495, 1.0628691, 1.0], + [2.1981053, 1.0128543, 1.0], + [0.0, 1.0, 1.0], + [3.750485, 1.1216393, 1.0], + [4.4771504, 1.1242076, 1.0], + [5.554026, 1.098979, 1.0], + [4.0766697, 1.0487705, 1.0], + [0.0, 1.0, 1.0], + [3.716854, 1.1420878, 1.0], + [3.9434807, 1.149656, 1.0], + [4.698269, 1.1275396, 1.0], + [3.355159, 1.0637993, 1.0], + [0.0, 1.0, 1.0], + [3.1019642, 1.152162, 1.0], + [3.018406, 1.1701936, 1.0], + [3.5190942, 1.1440179, 1.0], + [1.6282337, 1.0440116, 1.0], + [0.0, 1.0, 1.0], + [3.0533535, 1.1430386, 1.0], + [2.6682806, 1.1609282, 1.0], + [1.6297065, 1.1209588, 1.0], + [0.3274113, 1.0186641, 1.0], + [0.0, 1.0, 1.0], + [2.7143104, 1.1457591, 1.0], + [2.010889, 1.1753439, 1.0], + [1.1698594, 1.1809243, 1.0], + [0.26954132, 1.0447038, 1.0], + [0.0, 1.0, 1.0], + [1.9450912, 1.1625692, 1.0], + [1.4925617, 1.1576607, 1.0], + [0.73646855, 1.1099164, 1.0], + [0.041240156, 1.0114015, 1.0], + [0.0, 1.0, 1.0], + [1.4923553, 1.1628035, 1.0], + [0.9318059, 1.1155237, 1.0], + [-0.08695841, 1.0332942, 1.0], + [-0.048176043, 1.0041637, 1.0], + [0.0, 1.0, 1.0], + [1.084317, 1.1418072, 1.0], + [0.077919334, 1.1205045, 1.0], + [-0.37862262, 1.0519394, 1.0], + [-0.020748373, 1.0024971, 1.0], + [0.0, 1.0, 1.0], + [0.70962113, 1.1346613, 1.0], + [0.040982615, 1.1304044, 1.0], + [-0.59360516, 1.0895824, 1.0], + [-0.044442892, 1.0063348, 1.0], + [0.0, 1.0, 1.0], + [0.044502545, 1.1331831, 1.0], + [-0.31845695, 1.1328877, 1.0], + [-0.70814407, 1.0888221, 1.0], + [-0.053025763, 1.0063571, 1.0], + [0.0, 1.0, 1.0], + [0.102819525, 1.0983768, 1.0], + [-0.41320392, 1.0724556, 1.0], + [-0.34162834, 1.0306761, 1.0], + [-0.015480887, 1.0013198, 1.0], + [0.0, 1.0, 1.0], + [-0.45333293, 1.1152055, 1.0], + [-0.36607388, 1.0825105, 1.0], + [-0.061023094, 1.0260044, 1.0], + [-0.00076291023, 1.0008935, 1.0], + [0.0, 1.0, 1.0], + [-1.5181267, 1.1252426, 1.0], + [-0.7772274, 1.0954831, 1.0], + [0.07648249, 1.0305958, 1.0], + [0.007827844, 1.0011276, 1.0], + [0.0, 1.0, 1.0], + [-2.7173598, 1.1436788, 1.0], + [-1.1496428, 1.0798888, 1.0], + [0.13708575, 1.0121282, 1.0], + [0.0056166523, 1.0002311, 1.0], + [0.0, 1.0, 1.0], + [-2.8031726, 1.1485914, 1.0], + [-1.1996536, 1.065685, 1.0], + [-0.12146866, 1.0105418, 1.0], + [-0.001331464, 1.000203, 1.0], + [0.0, 1.0, 1.0], + [-3.7381613, 1.1670295, 1.0], + [-2.4761944, 1.0762824, 1.0], + [-0.30495355, 1.010074, 1.0], + [-0.003095148, 1.0001698, 1.0], + [0.0, 1.0, 1.0], + [-2.7574484, 1.1367188, 1.0], + [-1.0683595, 1.0591327, 1.0], + [0.3032825, 1.0109986, 1.0], + [0.0240107, 1.0003924, 1.0], + [0.0, 1.0, 1.0], + [-4.4444375, 1.1478848, 1.0], + [-2.10396, 1.077343, 1.0], + [0.39878115, 1.0108056, 1.0], + [0.04858238, 1.0006601, 1.0], + [0.0, 1.0, 1.0], + [-3.6460211, 1.1262141, 1.0], + [-0.94227576, 1.0764561, 1.0], + [0.46847248, 1.0121009, 1.0], + [0.056671463, 1.0005428, 1.0], + [0.0, 1.0, 1.0], + [-3.941849, 1.1401461, 1.0], + [-1.4121957, 1.0967879, 1.0], + [0.3250404, 1.0172433, 1.0], + [0.05624255, 1.0007424, 1.0], + [0.0, 1.0, 1.0], + [-3.3003352, 1.1373972, 1.0], + [-1.2239021, 1.0964172, 1.0], + [0.4203376, 1.0171689, 1.0], + [0.097768836, 1.0010349, 1.0], + [0.0, 1.0, 1.0], + [-1.3678734, 1.1021602, 1.0], + [0.18997674, 1.0791068, 1.0], + [1.9053582, 1.0272077, 1.0], + [0.36325264, 1.0027457, 1.0], + [0.0, 1.0, 1.0], + [-1.888127, 1.1030134, 1.0], + [0.9222494, 1.0793839, 1.0], + [4.407062, 1.0317812, 1.0], + [1.2517463, 1.0053726, 1.0], + [0.0, 1.0, 1.0], + [-0.87627596, 1.1100941, 1.0], + [2.1658633, 1.0810852, 1.0], + [6.2091784, 1.0344337, 1.0], + [2.7591643, 1.0083429, 1.0], + [0.0, 1.0, 1.0], + [0.58831537, 1.1441597, 1.0], + [3.26025, 1.1017427, 1.0], + [5.9591794, 1.0396717, 1.0], + [2.5558283, 1.009373, 1.0], + [0.0, 1.0, 1.0], + [0.6157835, 1.1522069, 1.0], + [2.7376304, 1.1033771, 1.0], + [5.0446105, 1.0609956, 1.0], + [2.545954, 1.0267637, 1.0], + [0.0, 1.0, 1.0], + [2.567323, 1.050905, 1.0], + [3.219447, 1.086401, 1.0], + [5.309476, 1.0623287, 1.0], + [5.3261275, 1.0507418, 1.0], + [0.0, 1.0, 1.0], + [2.4884005, 1.039593, 1.0], + [2.2780461, 1.0956311, 1.0], + [3.2725804, 1.0421693, 1.0], + [3.57421, 1.0101761, 1.0], + [0.0, 1.0, 1.0], + [2.709007, 1.016824, 1.0], + [1.9036032, 1.1006229, 1.0], + [1.8250041, 1.09863, 1.0], + [2.2753541, 1.0252498, 1.0], + [0.0, 1.0, 1.0], + [2.8476596, 1.0087867, 1.0], + [1.692206, 1.0733916, 1.0], + [0.98378277, 1.0820075, 1.0], + [0.71567744, 1.0277224, 1.0], + [0.0, 1.0, 1.0], + [2.7584476, 1.0095731, 1.0], + [1.4141955, 1.0379248, 1.0], + [0.30707088, 1.0278721, 1.0], + [-0.48991957, 0.99570155, 1.0], + [0.0, 1.0, 1.0], + [2.3251288, 1.0099978, 1.0], + [0.40751106, 1.034089, 1.0], + [-1.266682, 1.0152951, 1.0], + [-2.8792267, 0.97016555, 1.0], + [0.0, 1.0, 1.0], + [1.676751, 1.0196159, 1.0], + [-1.0043784, 1.0388186, 1.0], + [-3.3858006, 1.0105125, 1.0], + [-5.2836957, 0.95619494, 1.0], + [0.0, 1.0, 1.0], + [1.4230912, 1.0371325, 1.0], + [-1.6468483, 1.0507761, 1.0], + [-4.205406, 1.0050639, 1.0], + [-5.696899, 0.9382988, 1.0], + [0.0, 1.0, 1.0], + [2.215277, 1.0757174, 1.0], + [-0.13039589, 1.0800881, 1.0], + [-2.5378978, 1.0185709, 1.0], + [-4.5910883, 0.941271, 1.0], + [0.0, 1.0, 1.0], + [3.7552316, 1.1195593, 1.0], + [1.9935799, 1.1178633, 1.0], + [-0.1950342, 1.0336235, 1.0], + [-2.9394403, 0.9406914, 1.0], + [0.0, 1.0, 1.0], + [4.6019835, 1.1451467, 1.0], + [3.4129214, 1.1365347, 1.0], + [2.2300456, 1.0304257, 1.0], + [0.6699211, 0.8971019, 1.0], + [0.0, 1.0, 1.0], + [4.556115, 1.1341692, 1.0], + [3.8200648, 1.0857233, 1.0], + [3.1856856, 0.96298456, 1.0], + [2.2841954, 0.8578174, 1.0], + [0.0, 1.0, 1.0], + [3.4975002, 1.1417533, 1.0], + [2.8865607, 1.0605808, 1.0], + [2.4713562, 0.93972397, 1.0], + [1.982328, 0.83509517, 1.0], + [0.0, 1.0, 1.0], + [2.0893435, 1.1080577, 1.0], + [1.7293227, 1.0274619, 1.0], + [1.6507962, 0.9354451, 1.0], + [1.3905607, 0.8366557, 1.0], + [0.0, 1.0, 1.0], + [1.6415445, 1.0806029, 1.0], + [1.506959, 1.0187534, 1.0], + [1.6388204, 0.9282385, 1.0], + [1.8973097, 0.82975906, 1.0], + [0.0, 1.0, 1.0], + [1.7991877, 1.0542749, 1.0], + [2.0936208, 1.0012176, 1.0], + [2.6603212, 0.9100169, 1.0], + [3.5249443, 0.80724543, 1.0], + [0.0, 1.0, 1.0], + [2.1576753, 1.0242636, 1.0], + [2.7907417, 0.9638929, 1.0], + [3.652071, 0.87338793, 1.0], + [4.5204115, 0.78994405, 1.0], + [0.0, 1.0, 1.0], + [2.1243021, 1.008548, 1.0], + [2.7552125, 0.9425203, 1.0], + [3.522211, 0.8644462, 1.0], + [4.1264706, 0.8124369, 1.0], + [0.0, 1.0, 1.0], + [1.8834304, 1.0225105, 1.0], + [2.3353438, 0.9708129, 1.0], + [3.0901859, 0.9099647, 1.0], + [3.7211735, 0.86348975, 1.0], + [0.0, 1.0, 1.0], + [1.4581822, 1.0330464, 1.0], + [1.5594033, 0.99513304, 1.0], + [2.0912547, 0.96644175, 1.0], + [2.5819116, 0.94535977, 1.0], + [0.0, 1.0, 1.0], + [0.60412645, 1.0405735, 1.0], + [0.5539686, 1.0038283, 1.0], + [0.8588483, 0.9796771, 1.0], + [0.7651864, 0.98082924, 1.0], + [0.0, 1.0, 1.0], + [0.19108707, 1.0352545, 1.0], + [-0.16049986, 0.99251735, 1.0], + [-0.5080386, 0.96194357, 1.0], + [-0.33787724, 0.98708415, 1.0], + [0.0, 1.0, 1.0], + [-0.24877733, 1.0316597, 1.0], + [-0.70576507, 0.97597045, 1.0], + [-0.8016234, 0.9442628, 1.0], + [-0.11087948, 0.9908073, 1.0], + [0.0, 1.0, 1.0], + [-0.8790754, 1.0514538, 1.0], + [-1.4108989, 0.9916971, 1.0], + [-1.0544907, 0.9481936, 1.0], + [-0.10507189, 0.99393654, 1.0], + [0.0, 1.0, 1.0], + [-1.3867714, 1.0623717, 1.0], + [-2.1656637, 1.0158321, 1.0], + [-1.1665387, 0.9772894, 1.0], + [-0.049957793, 0.99827534, 1.0], + [0.0, 1.0, 1.0], + [-2.4962778, 1.047387, 1.0], + [-3.1633992, 1.0073104, 1.0], + [-1.4553053, 0.95946413, 1.0], + [-0.13852488, 0.99207044, 1.0], + [0.0, 1.0, 1.0], + [-3.1582537, 1.039661, 1.0], + [-3.3594494, 0.99911463, 1.0], + [-1.4694877, 0.9412946, 1.0], + [-0.15061137, 0.9906891, 1.0], + [0.0, 1.0, 1.0], + [-3.7993233, 1.040663, 1.0], + [-3.003692, 0.9947964, 1.0], + [-0.8132878, 0.94909513, 1.0], + [-0.09164102, 0.99424165, 1.0], + [0.0, 1.0, 1.0], + [-3.1300888, 1.0289713, 1.0], + [-2.916435, 0.98529565, 1.0], + [-0.3949024, 0.9686243, 1.0], + [0.057513624, 0.99605733, 1.0], + [0.0, 1.0, 1.0], + [-2.870443, 1.0128809, 1.0], + [-3.22342, 0.97333175, 1.0], + [1.0390259, 0.9529574, 1.0], + [0.3107253, 0.99131966, 1.0], + [0.0, 1.0, 1.0], + [-1.9573697, 1.0006245, 1.0], + [-1.8365934, 0.9674953, 1.0], + [0.7572013, 0.9682651, 1.0], + [0.17691553, 0.9942757, 1.0], + [0.0, 1.0, 1.0], + [-0.28490174, 1.0081447, 1.0], + [-0.29309836, 0.97951233, 1.0], + [0.18393767, 0.98060596, 1.0], + [0.007778787, 0.997274, 1.0], + [0.0, 1.0, 1.0], + [0.2726132, 0.99976957, 1.0], + [-0.4088234, 0.95960116, 1.0], + [0.0077163163, 0.9512176, 1.0], + [0.019507347, 0.9959901, 1.0], + [0.0, 1.0, 1.0], + [0.7798813, 0.9985067, 1.0], + [0.76253676, 0.9651048, 1.0], + [-0.1965283, 0.95622796, 1.0], + [-0.024494128, 0.99734217, 1.0], + [0.0, 1.0, 1.0], + [1.2395806, 1.0156081, 1.0], + [1.9043974, 0.98719865, 1.0], + [0.2547382, 0.9865172, 1.0], + [-0.0017040195, 0.99939626, 1.0], + [0.0, 1.0, 1.0], + [2.3556528, 1.0269231, 1.0], + [1.6541926, 0.94798446, 1.0], + [0.19142385, 0.96310043, 1.0], + [0.00077345694, 0.99907255, 1.0], + [0.0, 1.0, 1.0], + [0.8628551, 1.0025055, 1.0], + [-0.8925193, 0.86057365, 1.0], + [-0.89507776, 0.8785938, 1.0], + [-0.02676659, 0.9942501, 1.0], + [0.0, 1.0, 1.0], + [-0.69604295, 0.9841022, 1.0], + [-1.5614027, 0.86573595, 1.0], + [-1.0682615, 0.8759204, 1.0], + [-0.053552028, 0.992165, 1.0], + [0.0, 1.0, 1.0], + [-1.3843973, 0.9732926, 1.0], + [-1.6747307, 0.9024153, 1.0], + [-1.1190381, 0.90966296, 1.0], + [-0.0430858, 0.995535, 1.0], + [0.0, 1.0, 1.0], + [-1.4726397, 0.9728779, 1.0], + [-0.48264512, 0.9410372, 1.0], + [0.31483912, 0.93758917, 1.0], + [0.029700607, 0.99565494, 1.0], + [0.0, 1.0, 1.0], + [-1.5699832, 0.9680764, 1.0], + [-0.5216335, 0.9524717, 1.0], + [1.1201954, 0.9122498, 1.0], + [0.2576845, 0.9783837, 1.0], + [0.0, 1.0, 1.0], + [-1.9922228, 0.9569479, 1.0], + [-1.1839337, 0.9620515, 1.0], + [0.49700478, 0.90824383, 1.0], + [0.2757639, 0.9656649, 1.0], + [0.0, 1.0, 1.0], + [-1.8267179, 0.97236925, 1.0], + [-1.3503194, 0.98332787, 1.0], + [-0.21534178, 0.9398797, 1.0], + [0.3592183, 0.9531863, 1.0], + [0.0, 1.0, 1.0], + [-1.3845943, 0.9576693, 1.0], + [-0.88324434, 0.95311546, 1.0], + [1.0021961, 0.92629516, 1.0], + [1.4048795, 0.9467104, 1.0], + [0.0, 1.0, 1.0], + [0.21868584, 0.9868099, 1.0], + [1.2409965, 0.98529285, 1.0], + [3.478748, 0.96618646, 1.0], + [3.547536, 0.9636579, 1.0], + [0.0, 1.0, 1.0], + [0.72944003, 1.011865, 1.0], + [2.2030432, 1.0459692, 1.0], + [4.3416624, 1.0307989, 1.0], + [4.5565796, 1.0013582, 1.0], + [0.0, 1.0, 1.0], + [0.7054098, 1.0304452, 1.0], + [2.7193584, 1.0797284, 1.0], + [4.6252937, 1.0782566, 1.0], + [5.346714, 1.0554028, 1.0], + [0.0, 1.0, 1.0], + [0.8206882, 1.0563322, 1.0], + [2.66612, 1.105401, 1.0], + [4.257176, 1.1014127, 1.0], + [5.333192, 1.0896508, 1.0], + [0.0, 1.0, 1.0], + [0.6858709, 1.0573044, 1.0], + [2.1161485, 1.1302112, 1.0], + [3.9404676, 1.1377732, 1.0], + [4.928983, 1.1188648, 1.0], + [0.0, 1.0, 1.0], + [0.4658943, 1.0244572, 1.0], + [1.4814435, 1.1276039, 1.0], + [2.0604527, 1.1611818, 1.0], + [1.7115017, 1.0927407, 1.0], + [0.0, 1.0, 1.0], + [0.66739404, 1.0136315, 1.0], + [1.41378, 1.1620725, 1.0], + [1.2858282, 1.2084693, 1.0], + [0.8847436, 1.1387153, 1.0], + [0.0, 1.0, 1.0], + [0.22088455, 1.0020697, 1.0], + [0.8503165, 1.1110431, 1.0], + [0.8370134, 1.1636014, 1.0], + [0.16764726, 1.0484313, 1.0], + [0.0, 1.0, 1.0], + [-0.032334913, 0.99697274, 1.0], + [-0.12844399, 1.0419574, 1.0], + [-0.9318103, 1.0669361, 1.0], + [-0.2286045, 1.0165362, 1.0], + [0.0, 1.0, 1.0], + [-0.1669066, 0.99338514, 1.0], + [-0.8307293, 1.0627798, 1.0], + [-1.2913208, 1.0905485, 1.0], + [-0.08585524, 1.0079323, 1.0], + [0.0, 1.0, 1.0], + [0.017506301, 0.9974502, 1.0], + [-0.4714752, 1.0923178, 1.0], + [-1.2507538, 1.1427293, 1.0], + [-0.15650667, 1.0198009, 1.0], + [0.0, 1.0, 1.0], + [0.5337322, 0.98601866, 1.0], + [0.4784788, 1.0887345, 1.0], + [-0.95893174, 1.1386591, 1.0], + [-0.15236506, 1.0194373, 1.0], + [0.0, 1.0, 1.0], + [0.78718185, 0.9737713, 1.0], + [0.8975688, 1.024816, 1.0], + [-0.2945547, 1.0546055, 1.0], + [-0.03156037, 1.0033555, 1.0], + [0.0, 1.0, 1.0], + [0.24685815, 0.98656416, 1.0], + [0.55160105, 1.0425034, 1.0], + [-0.11143197, 1.0422429, 1.0], + [-0.003670774, 1.0018142, 1.0], + [0.0, 1.0, 1.0], + [-0.77873033, 1.0003996, 1.0], + [0.19153872, 1.0427284, 1.0], + [0.37716967, 1.041646, 1.0], + [0.016879126, 1.0023743, 1.0], + [0.0, 1.0, 1.0], + [-1.7304914, 1.0388753, 1.0], + [-0.7251529, 1.0577725, 1.0], + [0.34579277, 1.0164489, 1.0], + [0.009596101, 1.0004158, 1.0], + [0.0, 1.0, 1.0], + [-1.6364738, 1.067764, 1.0], + [-0.9167525, 1.0778942, 1.0], + [0.103218675, 1.0217229, 1.0], + [0.0034776113, 1.0004051, 1.0], + [0.0, 1.0, 1.0], + [-1.7374398, 1.0553571, 1.0], + [-0.8997327, 1.072175, 1.0], + [0.018124623, 1.0228853, 1.0], + [0.0031013442, 1.0004023, 1.0], + [0.0, 1.0, 1.0], + [-1.9461782, 1.0329045, 1.0], + [-0.9284798, 1.0640512, 1.0], + [0.5232761, 1.0302181, 1.0], + [0.0713816, 1.0012505, 1.0], + [0.0, 1.0, 1.0], + [-1.7830942, 1.0167092, 1.0], + [-1.0461223, 1.0536364, 1.0], + [0.71468955, 1.031544, 1.0], + [0.1528099, 1.0023446, 1.0], + [0.0, 1.0, 1.0], + [-0.901455, 1.0192658, 1.0], + [0.3789207, 1.0608308, 1.0], + [1.4712181, 1.0287849, 1.0], + [0.17150511, 1.0013422, 1.0], + [0.0, 1.0, 1.0], + [-1.4228317, 1.0136043, 1.0], + [-0.4306419, 1.0521969, 1.0], + [0.79203665, 1.0235791, 1.0], + [0.17930758, 1.0008605, 1.0], + [0.0, 1.0, 1.0], + [-0.89692557, 1.0078237, 1.0], + [0.22381781, 1.0376579, 1.0], + [1.2225298, 1.0229918, 1.0], + [0.30199468, 1.0024537, 1.0], + [0.0, 1.0, 1.0], + [0.96971756, 0.998565, 1.0], + [2.4854388, 1.0386328, 1.0], + [5.093825, 1.0409368, 1.0], + [1.5859087, 1.0088303, 1.0], + [0.0, 1.0, 1.0], + [2.0574582, 0.99701333, 1.0], + [3.9885697, 1.0335468, 1.0], + [7.3134866, 1.02023, 1.0], + [4.876025, 1.0077791, 1.0], + [0.0, 1.0, 1.0], + [2.9773273, 1.0016919, 1.0], + [4.8199377, 1.0256822, 1.0], + [7.568321, 1.0072774, 1.0], + [6.5177317, 0.99610305, 1.0], + [0.0, 1.0, 1.0], + [3.3437927, 1.0140332, 1.0], + [4.7947435, 1.036412, 1.0], + [7.550965, 1.0280272, 1.0], + [6.326854, 1.0115026, 1.0], + [0.0, 1.0, 1.0], + [2.9055889, 1.0224042, 1.0], + [3.8671165, 1.0454612, 1.0], + [6.3752146, 1.0593462, 1.0], + [5.871097, 1.0545919, 1.0], + [0.0, 1.0, 1.0], + [4.4711437, 0.89240575, 1.0], + [3.8707209, 0.975318, 1.0], + [5.54684, 1.0295455, 1.0], + [5.4792643, 1.0330564, 1.0], + [0.0, 1.0, 1.0], + [4.5822687, 0.86561245, 1.0], + [3.369101, 0.9498307, 1.0], + [4.0309362, 0.99107826, 1.0], + [4.1218996, 0.97818476, 1.0], + [0.0, 1.0, 1.0], + [4.7380633, 0.856774, 1.0], + [3.2887201, 0.9503168, 1.0], + [2.9449494, 1.0068572, 1.0], + [3.3715508, 0.97625136, 1.0], + [0.0, 1.0, 1.0], + [4.9660296, 0.8707871, 1.0], + [3.211986, 0.9548858, 1.0], + [1.9611124, 1.0027668, 1.0], + [1.4650759, 0.9794454, 1.0], + [0.0, 1.0, 1.0], + [4.919991, 0.8776082, 1.0], + [2.96986, 0.92894495, 1.0], + [1.43205, 0.9510039, 1.0], + [0.48539585, 0.9417541, 1.0], + [0.0, 1.0, 1.0], + [4.378387, 0.883634, 1.0], + [2.1083007, 0.919261, 1.0], + [0.5318521, 0.9173686, 1.0], + [-0.7582033, 0.896065, 1.0], + [0.0, 1.0, 1.0], + [3.8246558, 0.8962875, 1.0], + [0.9034894, 0.928397, 1.0], + [-1.1259035, 0.92528087, 1.0], + [-3.3147051, 0.8988185, 1.0], + [0.0, 1.0, 1.0], + [4.153484, 0.91500145, 1.0], + [0.48695508, 0.940206, 1.0], + [-2.1672661, 0.9259778, 1.0], + [-3.7963305, 0.88363916, 1.0], + [0.0, 1.0, 1.0], + [5.7889557, 0.9511915, 1.0], + [2.4093935, 0.9637889, 1.0], + [-0.28017357, 0.9176229, 1.0], + [-1.7246062, 0.86704445, 1.0], + [0.0, 1.0, 1.0], + [6.9804792, 1.0035709, 1.0], + [4.4882298, 0.9964915, 1.0], + [2.0359013, 0.9096808, 1.0], + [0.44025534, 0.8560936, 1.0], + [0.0, 1.0, 1.0], + [7.1492724, 1.0114399, 1.0], + [5.4943357, 1.0019382, 1.0], + [4.05172, 0.8888965, 1.0], + [2.2645717, 0.7902095, 1.0], + [0.0, 1.0, 1.0], + [6.758939, 0.99153984, 1.0], + [5.431977, 0.95972574, 1.0], + [4.6545696, 0.84344786, 1.0], + [3.4948368, 0.77209806, 1.0], + [0.0, 1.0, 1.0], + [5.388168, 0.986505, 1.0], + [4.070344, 0.92283, 1.0], + [3.5232341, 0.83252263, 1.0], + [3.0174634, 0.7662801, 1.0], + [0.0, 1.0, 1.0], + [3.7689843, 0.96089333, 1.0], + [2.5116751, 0.8785204, 1.0], + [2.3376532, 0.8223458, 1.0], + [2.414712, 0.7608956, 1.0], + [0.0, 1.0, 1.0], + [3.2908578, 0.9508312, 1.0], + [2.2224586, 0.88289684, 1.0], + [2.228365, 0.8166788, 1.0], + [2.6414301, 0.7484262, 1.0], + [0.0, 1.0, 1.0], + [3.3916478, 0.93619686, 1.0], + [2.823667, 0.87227386, 1.0], + [3.0609424, 0.79408795, 1.0], + [3.626718, 0.72579795, 1.0], + [0.0, 1.0, 1.0], + [3.1597862, 0.9192608, 1.0], + [3.008894, 0.85837466, 1.0], + [3.3871465, 0.79258764, 1.0], + [3.9049165, 0.7342933, 1.0], + [0.0, 1.0, 1.0], + [2.9172287, 0.9121524, 1.0], + [2.6648562, 0.85023427, 1.0], + [2.947294, 0.8032925, 1.0], + [3.3702457, 0.76370627, 1.0], + [0.0, 1.0, 1.0], + [2.5077455, 0.9239658, 1.0], + [2.4199598, 0.87722534, 1.0], + [2.618067, 0.84640473, 1.0], + [2.995166, 0.8136889, 1.0], + [0.0, 1.0, 1.0], + [1.9437534, 0.9129008, 1.0], + [1.9712167, 0.87672514, 1.0], + [2.1036656, 0.8706977, 1.0], + [2.0733418, 0.87078476, 1.0], + [0.0, 1.0, 1.0], + [1.4246463, 0.91300803, 1.0], + [1.1752675, 0.8862555, 1.0], + [0.9968104, 0.90140474, 1.0], + [0.23840506, 0.97830564, 1.0], + [0.0, 1.0, 1.0], + [0.67706054, 0.9196341, 1.0], + [0.109584466, 0.9024811, 1.0], + [-0.27386528, 0.9419742, 1.0], + [-0.059076753, 0.9978306, 1.0], + [0.0, 1.0, 1.0], + [0.07224172, 0.93229127, 1.0], + [-0.34362468, 0.914439, 1.0], + [-0.17629111, 0.96382296, 1.0], + [-0.011366743, 0.9988268, 1.0], + [0.0, 1.0, 1.0], + [-0.81656486, 0.955494, 1.0], + [-1.0913409, 0.94884336, 1.0], + [-0.44080573, 0.982739, 1.0], + [-0.030075505, 0.9989954, 1.0], + [0.0, 1.0, 1.0], + [-1.3378361, 0.9686959, 1.0], + [-1.5182222, 0.96549654, 1.0], + [-0.4606443, 0.9918533, 1.0], + [-0.019811057, 0.9994661, 1.0], + [0.0, 1.0, 1.0], + [-2.243623, 0.95517683, 1.0], + [-2.0646274, 0.93799746, 1.0], + [-0.90104544, 0.95197743, 1.0], + [-0.10999995, 0.99141395, 1.0], + [0.0, 1.0, 1.0], + [-2.8076265, 0.9409817, 1.0], + [-2.058747, 0.9154531, 1.0], + [-1.5804225, 0.88738924, 1.0], + [-0.23936853, 0.9801497, 1.0], + [0.0, 1.0, 1.0], + [-3.9064386, 0.95459986, 1.0], + [-2.3988228, 0.896239, 1.0], + [-1.7098186, 0.8685506, 1.0], + [-0.25361952, 0.9790636, 1.0], + [0.0, 1.0, 1.0], + [-4.2093053, 0.92354167, 1.0], + [-2.2733746, 0.9118433, 1.0], + [0.16433056, 0.9224389, 1.0], + [0.32686478, 0.98159236, 1.0], + [0.0, 1.0, 1.0], + [-4.749688, 0.9219642, 1.0], + [-1.7706362, 0.91556644, 1.0], + [1.4995979, 0.9179761, 1.0], + [0.58829814, 0.9714674, 1.0], + [0.0, 1.0, 1.0], + [-3.9969661, 0.9133036, 1.0], + [-1.1233542, 0.9314691, 1.0], + [0.91808987, 0.9250387, 1.0], + [0.36256704, 0.96524554, 1.0], + [0.0, 1.0, 1.0], + [-2.4866922, 0.91120887, 1.0], + [-0.59065604, 0.9415378, 1.0], + [-0.78037834, 0.9368204, 1.0], + [-0.3913863, 0.9704481, 1.0], + [0.0, 1.0, 1.0], + [-2.3026705, 0.90438557, 1.0], + [-0.8718288, 0.94404954, 1.0], + [-0.15336794, 0.977961, 1.0], + [-0.09629151, 0.9925991, 1.0], + [0.0, 1.0, 1.0], + [-1.9070312, 0.90280366, 1.0], + [-0.55553955, 0.935856, 1.0], + [0.25413927, 0.988254, 1.0], + [0.009861473, 0.9995575, 1.0], + [0.0, 1.0, 1.0], + [-1.9049702, 0.9080516, 1.0], + [-0.266718, 0.9349882, 1.0], + [0.13215524, 0.9933759, 1.0], + [0.0041504498, 0.99984777, 1.0], + [0.0, 1.0, 1.0], + [-0.8701549, 0.90498465, 1.0], + [1.0092131, 0.8918811, 1.0], + [0.3660149, 0.9696158, 1.0], + [0.0074352697, 0.9991519, 1.0], + [0.0, 1.0, 1.0], + [-1.0172404, 0.8942851, 1.0], + [0.6829563, 0.8349779, 1.0], + [0.48213816, 0.91718537, 1.0], + [0.016518895, 0.99731606, 1.0], + [0.0, 1.0, 1.0], + [-1.7463626, 0.8773616, 1.0], + [-0.36728278, 0.80656576, 1.0], + [-0.05267751, 0.8861593, 1.0], + [-0.0062499205, 0.99655586, 1.0], + [0.0, 1.0, 1.0], + [-2.645922, 0.85724914, 1.0], + [-1.6585516, 0.8101157, 1.0], + [-0.77995527, 0.8782376, 1.0], + [-0.014658492, 0.99659693, 1.0], + [0.0, 1.0, 1.0], + [-3.6578364, 0.8668074, 1.0], + [-2.0196826, 0.8535868, 1.0], + [-0.37624604, 0.921359, 1.0], + [0.00017735251, 0.99736357, 1.0], + [0.0, 1.0, 1.0], + [-4.0841975, 0.8596372, 1.0], + [-2.4710217, 0.8740145, 1.0], + [-0.1209378, 0.920483, 1.0], + [0.045910586, 0.99135536, 1.0], + [0.0, 1.0, 1.0], + [-4.430148, 0.85118824, 1.0], + [-2.7032092, 0.8731887, 1.0], + [-0.7946189, 0.8594394, 1.0], + [-0.09909849, 0.9567725, 1.0], + [0.0, 1.0, 1.0], + [-4.8029838, 0.8495463, 1.0], + [-2.6373093, 0.8836435, 1.0], + [-1.0274059, 0.83784634, 1.0], + [-0.34142062, 0.8863734, 1.0], + [0.0, 1.0, 1.0], + [-5.0252833, 0.8179336, 1.0], + [-3.0253406, 0.8344459, 1.0], + [-1.3676816, 0.83761764, 1.0], + [0.08377362, 0.8903176, 1.0], + [0.0, 1.0, 1.0], + [-3.5889053, 0.8333059, 1.0], + [-1.7982966, 0.8581034, 1.0], + [-0.067006715, 0.87093455, 1.0], + [1.3765932, 0.9137696, 1.0], + [0.0, 1.0, 1.0], + [-2.6394725, 0.8462031, 1.0], + [-0.5374585, 0.88883096, 1.0], + [1.3434483, 0.91526973, 1.0], + [2.196534, 0.948764, 1.0], + [0.0, 1.0, 1.0], + [-1.8830673, 0.91449887, 1.0], + [0.3436676, 0.948412, 1.0], + [2.4071665, 0.9718761, 1.0], + [3.2197835, 0.9905023, 1.0], + [0.0, 1.0, 1.0], + [-2.1540444, 0.9479222, 1.0], + [0.77035385, 0.9906655, 1.0], + [3.5416546, 1.0201747, 1.0], + [4.4329867, 1.036617, 1.0], + [0.0, 1.0, 1.0], + [-2.6539636, 0.8664964, 1.0], + [0.31695533, 0.9838804, 1.0], + [3.5114965, 1.0515474, 1.0], + [4.3682404, 1.0739245, 1.0], + [0.0, 1.0, 1.0], + [-2.889564, 0.7832875, 1.0], + [-0.47233972, 0.94827306, 1.0], + [1.9158661, 1.0925105, 1.0], + [1.7773509, 1.0825669, 1.0], + [0.0, 1.0, 1.0], + [-2.359935, 0.7418968, 1.0], + [0.13793384, 0.9809765, 1.0], + [1.3180828, 1.1471351, 1.0], + [0.7645996, 1.0599663, 1.0], + [0.0, 1.0, 1.0], + [-2.2418065, 0.7301813, 1.0], + [0.35360616, 0.91816384, 1.0], + [0.9692918, 1.0641508, 1.0], + [0.07664488, 1.006114, 1.0], + [0.0, 1.0, 1.0], + [-2.0775743, 0.7516581, 1.0], + [-0.124465264, 0.8922322, 1.0], + [0.04122935, 0.9999263, 1.0], + [-0.016318215, 1.0011346, 1.0], + [0.0, 1.0, 1.0], + [-1.3793565, 0.7586109, 1.0], + [0.04257845, 0.8988754, 1.0], + [-0.082526416, 0.98730445, 1.0], + [-0.0054138596, 0.9999747, 1.0], + [0.0, 1.0, 1.0], + [-0.505196, 0.7803437, 1.0], + [0.40779415, 0.90821487, 1.0], + [-0.1287433, 1.0026283, 1.0], + [-0.011499701, 1.0009819, 1.0], + [0.0, 1.0, 1.0], + [-0.058046483, 0.78796697, 1.0], + [0.93765765, 0.894442, 1.0], + [0.043037504, 1.004575, 1.0], + [-0.008396565, 1.0010221, 1.0], + [0.0, 1.0, 1.0], + [-0.043535788, 0.8018015, 1.0], + [0.80214524, 0.9042569, 1.0], + [0.15616187, 0.9971092, 1.0], + [-0.00031897958, 1.0001391, 1.0], + [0.0, 1.0, 1.0], + [-0.13094118, 0.81996274, 1.0], + [-0.057957847, 0.9010969, 1.0], + [-0.15578985, 0.9838379, 1.0], + [-0.0028997594, 0.9998916, 1.0], + [0.0, 1.0, 1.0], + [-0.24140343, 0.8421498, 1.0], + [-0.33087662, 0.8944223, 1.0], + [-0.11538279, 0.96010375, 1.0], + [-0.0023777992, 0.99934727, 1.0], + [0.0, 1.0, 1.0], + [-0.6291469, 0.8663977, 1.0], + [-0.93443865, 0.8860551, 1.0], + [-0.52833366, 0.9360109, 1.0], + [-0.013429848, 0.99847513, 1.0], + [0.0, 1.0, 1.0], + [-0.90612704, 0.8846548, 1.0], + [-1.0758501, 0.9156333, 1.0], + [-0.31288663, 0.95635, 1.0], + [-0.0065308507, 0.9988607, 1.0], + [0.0, 1.0, 1.0], + [-0.8505977, 0.8680036, 1.0], + [-0.6228383, 0.9419045, 1.0], + [0.008568136, 0.99068564, 1.0], + [0.00062457524, 0.9997916, 1.0], + [0.0, 1.0, 1.0], + [-1.3649141, 0.8661397, 1.0], + [-1.1351817, 0.9570879, 1.0], + [-0.4343645, 0.99677765, 1.0], + [-0.007229383, 0.9998938, 1.0], + [0.0, 1.0, 1.0], + [-0.3357523, 0.86801755, 1.0], + [-0.53919494, 0.9580416, 1.0], + [-0.3332547, 1.0017875, 1.0], + [0.0020524773, 1.0001712, 1.0], + [0.0, 1.0, 1.0], + [1.0389299, 0.87367016, 1.0], + [1.5910575, 0.95255834, 1.0], + [1.0409293, 0.9986148, 1.0], + [0.06169785, 0.99989563, 1.0], + [0.0, 1.0, 1.0], + [0.69167626, 0.864867, 1.0], + [0.517416, 0.9371735, 1.0], + [0.7297484, 0.9740146, 1.0], + [0.14100057, 0.9978946, 1.0], + [0.0, 1.0, 1.0], + [1.1966501, 0.8564259, 1.0], + [0.7971627, 0.9295377, 1.0], + [0.98976326, 0.9878759, 1.0], + [0.10663256, 0.9997394, 1.0], + [0.0, 1.0, 1.0], + [2.9381518, 0.84076357, 1.0], + [2.7677243, 0.9219802, 1.0], + [4.2827673, 0.9931431, 1.0], + [0.7215513, 1.0000048, 1.0], + [0.0, 1.0, 1.0], + [4.0477877, 0.82670283, 1.0], + [3.4655888, 0.91149926, 1.0], + [5.5720844, 0.97649837, 1.0], + [2.4715145, 0.9948263, 1.0], + [0.0, 1.0, 1.0], + [4.8419666, 0.8251362, 1.0], + [4.0095496, 0.90738267, 1.0], + [5.8746896, 0.9661354, 1.0], + [4.1081214, 0.98024684, 1.0], + [0.0, 1.0, 1.0], + [4.5143023, 0.835298, 1.0], + [3.7496395, 0.9078515, 1.0], + [5.115886, 0.9541289, 1.0], + [3.3494966, 0.97551304, 1.0], + [0.0, 1.0, 1.0], + [4.479901, 0.8648893, 1.0], + [3.820371, 0.9325536, 1.0], + [5.326134, 0.97972375, 1.0], + [4.717001, 1.0019616, 1.0], + [0.0, 1.0, 1.0], + [6.5529094, 0.68653643, 1.0], + [4.249971, 0.8358243, 1.0], + [3.886182, 0.9036481, 1.0], + [3.1052408, 0.93986005, 1.0], + [0.0, 1.0, 1.0], + [6.0453534, 0.69864804, 1.0], + [4.088292, 0.8079499, 1.0], + [4.4767594, 0.8848239, 1.0], + [4.920347, 0.8965313, 1.0], + [0.0, 1.0, 1.0], + [5.8496194, 0.71566397, 1.0], + [4.111261, 0.7927633, 1.0], + [4.182716, 0.8936798, 1.0], + [4.9267282, 0.9027427, 1.0], + [0.0, 1.0, 1.0], + [6.4675403, 0.7260139, 1.0], + [4.864559, 0.7873533, 1.0], + [3.662903, 0.8703417, 1.0], + [3.33834, 0.89759904, 1.0], + [0.0, 1.0, 1.0], + [6.627538, 0.73187256, 1.0], + [4.7362914, 0.7925029, 1.0], + [2.762565, 0.85528374, 1.0], + [1.6968315, 0.8718531, 1.0], + [0.0, 1.0, 1.0], + [6.1378064, 0.74603015, 1.0], + [3.7333937, 0.8095638, 1.0], + [1.7157373, 0.84111804, 1.0], + [0.7313545, 0.84846157, 1.0], + [0.0, 1.0, 1.0], + [5.8278027, 0.73261493, 1.0], + [3.218722, 0.7893107, 1.0], + [1.7261162, 0.8081886, 1.0], + [1.3808451, 0.80620646, 1.0], + [0.0, 1.0, 1.0], + [6.5486107, 0.735842, 1.0], + [3.34487, 0.7877779, 1.0], + [1.9852514, 0.7963513, 1.0], + [3.2250917, 0.7770536, 1.0], + [0.0, 1.0, 1.0], + [8.3918, 0.7492594, 1.0], + [5.8002057, 0.76848954, 1.0], + [3.2627735, 0.7929065, 1.0], + [4.1515646, 0.7706826, 1.0], + [0.0, 1.0, 1.0], + [9.339295, 0.7832515, 1.0], + [7.126709, 0.778491, 1.0], + [3.6896281, 0.8016613, 1.0], + [3.1497123, 0.78488094, 1.0], + [0.0, 1.0, 1.0], + [9.593153, 0.7601979, 1.0], + [7.8867235, 0.7450819, 1.0], + [5.9123063, 0.7422206, 1.0], + [3.8759134, 0.74508727, 1.0], + [0.0, 1.0, 1.0], + [8.585278, 0.7479115, 1.0], + [7.529606, 0.7175019, 1.0], + [6.162009, 0.7064114, 1.0], + [4.4188056, 0.7255969, 1.0], + [0.0, 1.0, 1.0], + [6.858168, 0.7368539, 1.0], + [5.492259, 0.7144793, 1.0], + [4.347751, 0.70099527, 1.0], + [3.5681868, 0.7093534, 1.0], + [0.0, 1.0, 1.0], + [5.2282605, 0.73686594, 1.0], + [3.2710242, 0.70864916, 1.0], + [2.9356287, 0.6760259, 1.0], + [3.0854452, 0.64669275, 1.0], + [0.0, 1.0, 1.0], + [4.4789567, 0.74254066, 1.0], + [2.5880811, 0.7186022, 1.0], + [2.387628, 0.66940564, 1.0], + [2.4265919, 0.6265305, 1.0], + [0.0, 1.0, 1.0], + [4.528232, 0.7247535, 1.0], + [2.7541575, 0.7074213, 1.0], + [2.5300722, 0.6434275, 1.0], + [2.7180758, 0.5980046, 1.0], + [0.0, 1.0, 1.0], + [4.102856, 0.728164, 1.0], + [2.6525943, 0.71301305, 1.0], + [2.7899814, 0.67262053, 1.0], + [3.1614673, 0.6270317, 1.0], + [0.0, 1.0, 1.0], + [3.6348686, 0.73153895, 1.0], + [2.726432, 0.6877879, 1.0], + [2.54031, 0.7097242, 1.0], + [2.7431397, 0.7150978, 1.0], + [0.0, 1.0, 1.0], + [2.9502785, 0.69625, 1.0], + [2.6242392, 0.6294526, 1.0], + [2.2081497, 0.66211057, 1.0], + [1.3193871, 0.82756114, 1.0], + [0.0, 1.0, 1.0], + [2.0414326, 0.6955794, 1.0], + [2.0578754, 0.6632495, 1.0], + [1.7014283, 0.7117629, 1.0], + [0.3407154, 0.9488139, 1.0], + [0.0, 1.0, 1.0], + [1.1289076, 0.7163192, 1.0], + [1.151557, 0.7172398, 1.0], + [0.52380514, 0.86909467, 1.0], + [0.019359209, 0.9961916, 1.0], + [0.0, 1.0, 1.0], + [0.67299646, 0.7355766, 1.0], + [0.51002353, 0.7648463, 1.0], + [0.06944082, 0.96125567, 1.0], + [0.000119109, 0.9998196, 1.0], + [0.0, 1.0, 1.0], + [-0.031164411, 0.77996194, 1.0], + [0.014347508, 0.86290824, 1.0], + [-0.007677018, 0.99158084, 1.0], + [-6.689423e-05, 0.9999727, 1.0], + [0.0, 1.0, 1.0], + [-0.6832901, 0.8222584, 1.0], + [-0.3419607, 0.92960227, 1.0], + [-0.022380346, 0.9983181, 1.0], + [-0.00018521023, 0.9999936, 1.0], + [0.0, 1.0, 1.0], + [-1.0638311, 0.89357316, 1.0], + [-0.2623169, 0.97307545, 1.0], + [-0.008422446, 0.99948406, 1.0], + [-0.00014290158, 0.99999386, 1.0], + [0.0, 1.0, 1.0], + [-1.1319717, 0.89954185, 1.0], + [-0.28591886, 0.9751361, 1.0], + [-0.01649866, 0.9978075, 1.0], + [-0.0016126642, 0.99978495, 1.0], + [0.0, 1.0, 1.0], + [-2.0644026, 0.86974543, 1.0], + [-0.26208007, 0.9613298, 1.0], + [-0.054121625, 0.9739053, 1.0], + [-0.018682964, 0.996442, 1.0], + [0.0, 1.0, 1.0], + [-1.9141648, 0.88052386, 1.0], + [-0.27626938, 0.93415976, 1.0], + [-0.24882263, 0.9263471, 1.0], + [-0.06974276, 0.9880197, 1.0], + [0.0, 1.0, 1.0], + [-2.0521605, 0.8621233, 1.0], + [-0.4830994, 0.962234, 1.0], + [-0.02020153, 0.9847871, 1.0], + [0.019950174, 0.99770623, 1.0], + [0.0, 1.0, 1.0], + [-3.3087368, 0.84192723, 1.0], + [-0.65065837, 0.9700698, 1.0], + [0.11342574, 0.99164987, 1.0], + [0.050568797, 0.99678665, 1.0], + [0.0, 1.0, 1.0], + [-3.7450078, 0.8366232, 1.0], + [-0.49405074, 0.9702762, 1.0], + [0.107314065, 0.9858985, 1.0], + [0.053864308, 0.99334574, 1.0], + [0.0, 1.0, 1.0], + [-2.5509698, 0.8247367, 1.0], + [-0.20142983, 0.96617097, 1.0], + [-0.2560394, 0.981325, 1.0], + [-0.1159319, 0.9916455, 1.0], + [0.0, 1.0, 1.0], + [-1.5854938, 0.81745756, 1.0], + [-0.14268312, 0.97136134, 1.0], + [-0.06509467, 0.9953569, 1.0], + [-0.029058143, 0.9980467, 1.0], + [0.0, 1.0, 1.0], + [-0.8367958, 0.8402703, 1.0], + [-0.10091935, 0.97176695, 1.0], + [-0.0010153912, 0.9995565, 1.0], + [-3.0446154e-05, 0.9999878, 1.0], + [0.0, 1.0, 1.0], + [-2.1198392, 0.84143376, 1.0], + [-0.03249274, 0.952964, 1.0], + [0.012029766, 0.99843806, 1.0], + [8.835511e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-1.9262636, 0.86367226, 1.0], + [0.5532922, 0.93086356, 1.0], + [0.09160686, 0.99354273, 1.0], + [0.00081093685, 0.9999354, 1.0], + [0.0, 1.0, 1.0], + [-1.3739588, 0.8016463, 1.0], + [1.2970295, 0.7846802, 1.0], + [0.45966804, 0.9381906, 1.0], + [0.0061520343, 0.99921715, 1.0], + [0.0, 1.0, 1.0], + [-1.4077954, 0.7208454, 1.0], + [0.4733461, 0.6787829, 1.0], + [0.47692603, 0.80988336, 1.0], + [0.012340703, 0.9957405, 1.0], + [0.0, 1.0, 1.0], + [-2.542054, 0.69510156, 1.0], + [-1.0206543, 0.66685224, 1.0], + [-0.3861955, 0.77343684, 1.0], + [-0.0016055064, 0.9925781, 1.0], + [0.0, 1.0, 1.0], + [-4.949766, 0.7236445, 1.0], + [-2.2197113, 0.72735757, 1.0], + [-0.622677, 0.88092417, 1.0], + [-0.015985137, 0.996581, 1.0], + [0.0, 1.0, 1.0], + [-6.6489305, 0.68735474, 1.0], + [-3.5802984, 0.78218496, 1.0], + [-0.5320879, 0.92443025, 1.0], + [-0.022485942, 0.9953605, 1.0], + [0.0, 1.0, 1.0], + [-7.177222, 0.67137307, 1.0], + [-3.1258667, 0.780959, 1.0], + [-1.202351, 0.83195996, 1.0], + [-0.23152742, 0.9466141, 1.0], + [0.0, 1.0, 1.0], + [-7.1036143, 0.67803013, 1.0], + [-3.811027, 0.75741714, 1.0], + [-1.5931911, 0.8057145, 1.0], + [-0.4153525, 0.8942517, 1.0], + [0.0, 1.0, 1.0], + [-6.7684326, 0.6596717, 1.0], + [-5.32512, 0.69404334, 1.0], + [-3.1082761, 0.7684906, 1.0], + [-1.2460512, 0.894522, 1.0], + [0.0, 1.0, 1.0], + [-5.8683157, 0.6405401, 1.0], + [-4.9210896, 0.68566376, 1.0], + [-6.001439, 0.7500565, 1.0], + [-7.3015804, 0.812211, 1.0], + [0.0, 1.0, 1.0], + [-5.0999904, 0.6463381, 1.0], + [-3.7537494, 0.71320873, 1.0], + [-3.3960533, 0.7802192, 1.0], + [-3.351788, 0.85274297, 1.0], + [0.0, 1.0, 1.0], + [-4.22911, 0.68255895, 1.0], + [-2.943262, 0.75258833, 1.0], + [-2.539806, 0.79581076, 1.0], + [-1.4436098, 0.85693127, 1.0], + [0.0, 1.0, 1.0], + [-4.421207, 0.6897405, 1.0], + [-2.546169, 0.7558628, 1.0], + [-0.93006057, 0.8137628, 1.0], + [0.48019657, 0.90172833, 1.0], + [0.0, 1.0, 1.0], + [-4.955394, 0.63199955, 1.0], + [-3.2294798, 0.7054303, 1.0], + [-1.6279404, 0.7905365, 1.0], + [0.42406368, 0.95621544, 1.0], + [0.0, 1.0, 1.0], + [-4.406013, 0.61143196, 1.0], + [-2.4224486, 0.6885289, 1.0], + [0.15226217, 0.94315904, 1.0], + [0.3682204, 1.0079356, 1.0], + [0.0, 1.0, 1.0], + [-2.6070106, 0.59719855, 1.0], + [-0.5420019, 0.6935155, 1.0], + [0.49049014, 0.98263836, 1.0], + [0.093591675, 1.0030893, 1.0], + [0.0, 1.0, 1.0], + [-1.4122118, 0.62467295, 1.0], + [0.61009103, 0.7372408, 1.0], + [0.48575926, 0.9820844, 1.0], + [0.025345104, 1.0000371, 1.0], + [0.0, 1.0, 1.0], + [-0.9076017, 0.68767226, 1.0], + [0.656146, 0.8025899, 1.0], + [0.37165537, 0.9719272, 1.0], + [0.009354364, 0.9996975, 1.0], + [0.0, 1.0, 1.0], + [-0.519386, 0.7165023, 1.0], + [0.67302865, 0.8780051, 1.0], + [0.5275675, 0.94928, 1.0], + [0.022246271, 0.9979151, 1.0], + [0.0, 1.0, 1.0], + [0.09680573, 0.738186, 1.0], + [0.793014, 0.8750658, 1.0], + [0.381764, 0.9613324, 1.0], + [0.009429865, 0.99904037, 1.0], + [0.0, 1.0, 1.0], + [1.8834604, 0.77035594, 1.0], + [1.7601224, 0.8644663, 1.0], + [0.14496735, 0.98939514, 1.0], + [0.00014458288, 0.999937, 1.0], + [0.0, 1.0, 1.0], + [1.8881173, 0.79435354, 1.0], + [2.1193182, 0.87771165, 1.0], + [0.18743913, 0.9930873, 1.0], + [0.00059748656, 0.999982, 1.0], + [0.0, 1.0, 1.0], + [0.8984065, 0.7908348, 1.0], + [0.8870811, 0.86748314, 1.0], + [0.060655933, 0.9830568, 1.0], + [-0.00023914597, 0.9998897, 1.0], + [0.0, 1.0, 1.0], + [-0.052016236, 0.79481345, 1.0], + [-0.2686036, 0.8499417, 1.0], + [-0.24796923, 0.94703746, 1.0], + [-0.00565591, 0.99915326, 1.0], + [0.0, 1.0, 1.0], + [-1.2217782, 0.81121093, 1.0], + [-1.5196391, 0.8464336, 1.0], + [-0.6995659, 0.93161744, 1.0], + [-0.015096771, 0.99854493, 1.0], + [0.0, 1.0, 1.0], + [-1.9182163, 0.7860046, 1.0], + [-1.7838149, 0.8694839, 1.0], + [-0.3889935, 0.966303, 1.0], + [-0.0060358397, 0.99937606, 1.0], + [0.0, 1.0, 1.0], + [-1.4767361, 0.76479936, 1.0], + [-0.9574383, 0.89968514, 1.0], + [-0.093257755, 0.9917394, 1.0], + [-0.0010219052, 0.99989015, 1.0], + [0.0, 1.0, 1.0], + [-1.1596841, 0.762179, 1.0], + [-1.0841764, 0.8807078, 1.0], + [-0.13100374, 0.99240035, 1.0], + [-0.0010621684, 0.9999584, 1.0], + [0.0, 1.0, 1.0], + [-0.6849198, 0.7800176, 1.0], + [-1.0038462, 0.8779829, 1.0], + [-0.09007008, 0.9921065, 1.0], + [-0.0005381258, 0.9999664, 1.0], + [0.0, 1.0, 1.0], + [0.28321254, 0.7977584, 1.0], + [-0.16388126, 0.890086, 1.0], + [0.03192479, 0.9929511, 1.0], + [0.001241174, 0.9999684, 1.0], + [0.0, 1.0, 1.0], + [0.86829543, 0.7936024, 1.0], + [-0.37591735, 0.89833724, 1.0], + [-0.14845619, 0.9868604, 1.0], + [0.00082641013, 0.99980897, 1.0], + [0.0, 1.0, 1.0], + [2.0176198, 0.7752524, 1.0], + [1.6908506, 0.8934624, 1.0], + [0.44986808, 0.9873634, 1.0], + [0.008859725, 0.99985033, 1.0], + [0.0, 1.0, 1.0], + [3.6487887, 0.748154, 1.0], + [2.6644409, 0.8853934, 1.0], + [0.99475205, 0.98551035, 1.0], + [0.050193034, 0.9997457, 1.0], + [0.0, 1.0, 1.0], + [4.480023, 0.7250523, 1.0], + [1.926729, 0.8424514, 1.0], + [0.040736295, 0.92375875, 1.0], + [0.051179513, 0.99580204, 1.0], + [0.0, 1.0, 1.0], + [5.1756477, 0.71742564, 1.0], + [1.3003006, 0.80706257, 1.0], + [-1.707145, 0.8457335, 1.0], + [-0.49415466, 0.96840185, 1.0], + [0.0, 1.0, 1.0], + [4.975246, 0.7181208, 1.0], + [2.2769587, 0.81511086, 1.0], + [-0.6684549, 0.8240774, 1.0], + [-1.1093187, 0.9021424, 1.0], + [0.0, 1.0, 1.0], + [5.6670837, 0.7181022, 1.0], + [3.5531816, 0.83718723, 1.0], + [2.3386168, 0.85608834, 1.0], + [1.0339524, 0.91459143, 1.0], + [0.0, 1.0, 1.0], + [4.161144, 0.74439704, 1.0], + [1.2685431, 0.90642077, 1.0], + [1.2386372, 0.91409504, 1.0], + [0.9295507, 0.9615406, 1.0], + [0.0, 1.0, 1.0], + [4.1777663, 0.7336535, 1.0], + [1.6435673, 0.89623827, 1.0], + [4.6341896, 0.8448458, 1.0], + [5.328556, 0.8743724, 1.0], + [0.0, 1.0, 1.0], + [3.8456101, 0.74945605, 1.0], + [1.7118593, 0.8730719, 1.0], + [4.7893443, 0.8741666, 1.0], + [5.7356167, 0.8831293, 1.0], + [0.0, 1.0, 1.0], + [4.541483, 0.68340665, 1.0], + [3.1775188, 0.750212, 1.0], + [2.7961404, 0.89943826, 1.0], + [2.240247, 0.94229096, 1.0], + [0.0, 1.0, 1.0], + [5.315433, 0.6658273, 1.0], + [4.4101477, 0.6992003, 1.0], + [2.1726768, 0.87961835, 1.0], + [0.8891901, 0.96283895, 1.0], + [0.0, 1.0, 1.0], + [5.594221, 0.6638776, 1.0], + [5.8431273, 0.65800124, 1.0], + [5.264457, 0.7205041, 1.0], + [2.3550959, 0.89265925, 1.0], + [0.0, 1.0, 1.0], + [6.089242, 0.6326394, 1.0], + [7.3321614, 0.59986347, 1.0], + [8.751942, 0.6218272, 1.0], + [7.835094, 0.73740655, 1.0], + [0.0, 1.0, 1.0], + [6.8648624, 0.6189883, 1.0], + [7.854025, 0.5970248, 1.0], + [9.556352, 0.6305344, 1.0], + [9.962623, 0.70967585, 1.0], + [0.0, 1.0, 1.0], + [8.106201, 0.6309747, 1.0], + [8.538228, 0.6175027, 1.0], + [9.568188, 0.6525294, 1.0], + [9.985585, 0.7052805, 1.0], + [0.0, 1.0, 1.0], + [8.281834, 0.64497524, 1.0], + [7.932338, 0.6421112, 1.0], + [6.0639887, 0.72733206, 1.0], + [5.3430767, 0.77500343, 1.0], + [0.0, 1.0, 1.0], + [8.019873, 0.6824282, 1.0], + [7.110651, 0.6669911, 1.0], + [5.331789, 0.7284526, 1.0], + [3.3326175, 0.8244816, 1.0], + [0.0, 1.0, 1.0], + [7.284438, 0.6928456, 1.0], + [7.2835546, 0.6664346, 1.0], + [4.883378, 0.72301525, 1.0], + [1.6401588, 0.8878237, 1.0], + [0.0, 1.0, 1.0], + [5.3979216, 0.7423805, 1.0], + [5.322938, 0.73012125, 1.0], + [2.8791666, 0.7680131, 1.0], + [1.2293904, 0.8762204, 1.0], + [0.0, 1.0, 1.0], + [3.9055657, 0.8100785, 1.0], + [3.3159246, 0.7462504, 1.0], + [2.3902402, 0.6859098, 1.0], + [1.833428, 0.74496704, 1.0], + [0.0, 1.0, 1.0], + [2.3105671, 0.8353257, 1.0], + [1.5842186, 0.76920885, 1.0], + [1.6503354, 0.6779311, 1.0], + [1.5094707, 0.6925992, 1.0], + [0.0, 1.0, 1.0], + [1.6453714, 0.83882755, 1.0], + [1.2651672, 0.7836908, 1.0], + [1.7122288, 0.64048153, 1.0], + [1.8864015, 0.6182712, 1.0], + [0.0, 1.0, 1.0], + [1.4681439, 0.84673464, 1.0], + [1.016748, 0.7926556, 1.0], + [1.5301808, 0.6895232, 1.0], + [1.7087327, 0.6774361, 1.0], + [0.0, 1.0, 1.0], + [1.2655282, 0.8380442, 1.0], + [1.400092, 0.7222438, 1.0], + [0.9317295, 0.751648, 1.0], + [0.38670647, 0.9075669, 1.0], + [0.0, 1.0, 1.0], + [0.99011, 0.8104178, 1.0], + [1.5600725, 0.6673882, 1.0], + [0.8698622, 0.75667554, 1.0], + [0.05312782, 0.9771482, 1.0], + [0.0, 1.0, 1.0], + [0.57059133, 0.84510267, 1.0], + [0.82562035, 0.7819899, 1.0], + [0.26795024, 0.91805077, 1.0], + [0.007893546, 0.9974623, 1.0], + [0.0, 1.0, 1.0], + [-0.025850505, 0.885807, 1.0], + [0.16424465, 0.907354, 1.0], + [0.02480482, 0.9894822, 1.0], + [0.0002904646, 0.9998891, 1.0], + [0.0, 1.0, 1.0], + [0.0051441705, 0.905744, 1.0], + [0.052886356, 0.94857466, 1.0], + [0.003892918, 0.99737555, 1.0], + [1.5296924e-05, 0.9999911, 1.0], + [0.0, 1.0, 1.0], + [-0.13219802, 0.9432007, 1.0], + [0.0067049917, 0.9855628, 1.0], + [0.00048594674, 0.99958736, 1.0], + [1.3410622e-06, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-0.10593759, 0.96154904, 1.0], + [-0.007101243, 0.9942198, 1.0], + [-5.9125785e-05, 0.99992687, 1.0], + [-1.7132953e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.1380907, 0.98662883, 1.0], + [-0.0150175765, 0.99846256, 1.0], + [-0.00010678602, 0.9999852, 1.0], + [-7.602713e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.13843897, 0.99187297, 1.0], + [-0.014456171, 0.9993438, 1.0], + [-0.00010089983, 0.9999929, 1.0], + [-1.5361991e-06, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-0.25314486, 0.9803601, 1.0], + [-0.015702732, 0.9983346, 1.0], + [-0.0008912772, 0.99966294, 1.0], + [-0.00028706418, 0.9999485, 1.0], + [0.0, 1.0, 1.0], + [-0.13319662, 0.9884858, 1.0], + [-0.008045408, 0.9985032, 1.0], + [-0.00546511, 0.9987218, 1.0], + [-0.0013116907, 0.9997901, 1.0], + [0.0, 1.0, 1.0], + [-0.14743957, 0.98442274, 1.0], + [-0.010547765, 0.9988189, 1.0], + [-0.00075808156, 0.99986076, 1.0], + [-8.594952e-05, 0.99998194, 1.0], + [0.0, 1.0, 1.0], + [-0.37524244, 0.9815919, 1.0], + [-0.030857285, 0.9987118, 1.0], + [-7.657946e-06, 0.9999798, 1.0], + [7.531104e-05, 0.9999943, 1.0], + [0.0, 1.0, 1.0], + [-0.30199113, 0.97909504, 1.0], + [-0.019221604, 0.99835676, 1.0], + [0.000142119, 0.99995494, 1.0], + [0.000114501025, 0.99998343, 1.0], + [0.0, 1.0, 1.0], + [-0.07426342, 0.9718809, 1.0], + [0.006918636, 0.99745023, 1.0], + [-0.00086405553, 0.99991477, 1.0], + [-0.0004274567, 0.99996984, 1.0], + [0.0, 1.0, 1.0], + [0.1214249, 0.97155046, 1.0], + [0.020241432, 0.9978329, 1.0], + [-6.842438e-05, 0.9999722, 1.0], + [-0.000104643506, 0.99999297, 1.0], + [0.0, 1.0, 1.0], + [-0.00912883, 0.98002875, 1.0], + [0.009914126, 0.998089, 1.0], + [0.00014658475, 0.99998343, 1.0], + [-3.147069e-07, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.34022465, 0.97413856, 1.0], + [-0.001044419, 0.99583006, 1.0], + [0.0007858895, 0.9998906, 1.0], + [2.2824117e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [-0.234471, 0.98301566, 1.0], + [0.012073258, 0.9957116, 1.0], + [0.0027358236, 0.9997176, 1.0], + [1.8471012e-05, 0.9999981, 1.0], + [0.0, 1.0, 1.0], + [-0.37233362, 0.96034855, 1.0], + [0.20355557, 0.9555334, 1.0], + [0.040644884, 0.99291646, 1.0], + [0.00042774918, 0.99992293, 1.0], + [0.0, 1.0, 1.0], + [-0.26744676, 0.88313514, 1.0], + [0.25718036, 0.8152884, 1.0], + [0.09190484, 0.9529967, 1.0], + [0.0019344173, 0.999235, 1.0], + [0.0, 1.0, 1.0], + [-0.92890036, 0.8132875, 1.0], + [-0.62145543, 0.75051594, 1.0], + [-0.07648181, 0.9260143, 1.0], + [0.0015313957, 0.9985089, 1.0], + [0.0, 1.0, 1.0], + [-1.4036704, 0.87832355, 1.0], + [-0.62560636, 0.879744, 1.0], + [-0.07835409, 0.97920424, 1.0], + [-0.001020268, 0.9996515, 1.0], + [0.0, 1.0, 1.0], + [-2.5662296, 0.83067703, 1.0], + [-0.6488212, 0.94305354, 1.0], + [-0.052740045, 0.9922627, 1.0], + [-0.0016239518, 0.9996923, 1.0], + [0.0, 1.0, 1.0], + [-4.7540584, 0.71597546, 1.0], + [-2.4036474, 0.8318091, 1.0], + [-0.7855407, 0.9302143, 1.0], + [-0.030755855, 0.9963339, 1.0], + [0.0, 1.0, 1.0], + [-6.401625, 0.6375166, 1.0], + [-4.2524567, 0.73438454, 1.0], + [-1.7984731, 0.8661054, 1.0], + [-0.076227136, 0.9925712, 1.0], + [0.0, 1.0, 1.0], + [-7.093013, 0.57054, 1.0], + [-7.138467, 0.61704755, 1.0], + [-10.662155, 0.7250832, 1.0], + [-4.8741217, 0.8936352, 1.0], + [0.0, 1.0, 1.0], + [-6.7872076, 0.543695, 1.0], + [-9.125988, 0.5788258, 1.0], + [-17.282015, 0.6463619, 1.0], + [-15.361004, 0.7047228, 1.0], + [0.0, 1.0, 1.0], + [-6.5472207, 0.5460788, 1.0], + [-7.3952637, 0.5873735, 1.0], + [-9.985295, 0.6460461, 1.0], + [-9.715605, 0.75660294, 1.0], + [0.0, 1.0, 1.0], + [-6.143126, 0.5428474, 1.0], + [-6.979643, 0.59978616, 1.0], + [-7.3462987, 0.654454, 1.0], + [-4.6714454, 0.7781302, 1.0], + [0.0, 1.0, 1.0], + [-5.6845136, 0.5430165, 1.0], + [-7.2012515, 0.5718516, 1.0], + [-8.748415, 0.59380674, 1.0], + [-4.6859508, 0.76675576, 1.0], + [0.0, 1.0, 1.0], + [-5.1071506, 0.54315025, 1.0], + [-5.8883495, 0.56850153, 1.0], + [-7.7368093, 0.6292215, 1.0], + [-1.4560128, 0.9221482, 1.0], + [0.0, 1.0, 1.0], + [-2.574775, 0.5935165, 1.0], + [-1.5976609, 0.6629436, 1.0], + [-0.42182118, 0.9376009, 1.0], + [0.0032954898, 0.99905676, 1.0], + [0.0, 1.0, 1.0], + [-0.5040222, 0.66888565, 1.0], + [0.22192046, 0.765007, 1.0], + [0.039829105, 0.97707367, 1.0], + [0.0013656623, 0.99990016, 1.0], + [0.0, 1.0, 1.0], + [0.13229428, 0.750091, 1.0], + [0.26277184, 0.88919055, 1.0], + [0.020233026, 0.99522144, 1.0], + [0.00035138454, 0.99998504, 1.0], + [0.0, 1.0, 1.0], + [0.24901535, 0.8024303, 1.0], + [0.3177278, 0.91495854, 1.0], + [0.051968362, 0.9940321, 1.0], + [0.0007878256, 0.9999375, 1.0], + [0.0, 1.0, 1.0], + [0.40787914, 0.8393718, 1.0], + [0.6778435, 0.91640764, 1.0], + [0.2564198, 0.97801465, 1.0], + [0.0053125066, 0.99956983, 1.0], + [0.0, 1.0, 1.0], + [0.9363007, 0.86411923, 1.0], + [0.7308819, 0.92368954, 1.0], + [0.13012709, 0.9888115, 1.0], + [0.0020543067, 0.99983764, 1.0], + [0.0, 1.0, 1.0], + [1.792059, 0.9070631, 1.0], + [0.8328082, 0.961077, 1.0], + [0.03201244, 0.9983668, 1.0], + [0.00010289253, 0.99999255, 1.0], + [0.0, 1.0, 1.0], + [0.61971796, 0.93544394, 1.0], + [0.28986993, 0.98064864, 1.0], + [0.011807639, 0.99949574, 1.0], + [3.4211585e-05, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [0.36694813, 0.9191711, 1.0], + [0.15008779, 0.9748141, 1.0], + [0.004682179, 0.9991603, 1.0], + [5.24191e-06, 0.9999966, 1.0], + [0.0, 1.0, 1.0], + [-0.04590827, 0.94109243, 1.0], + [-0.035157125, 0.9765446, 1.0], + [-0.007650317, 0.99820185, 1.0], + [-0.00011332178, 0.99998254, 1.0], + [0.0, 1.0, 1.0], + [-0.15313512, 0.97539425, 1.0], + [-0.13065065, 0.9864903, 1.0], + [-0.017957566, 0.9982736, 1.0], + [-0.00023828977, 0.99997765, 1.0], + [0.0, 1.0, 1.0], + [-0.32719183, 0.960239, 1.0], + [-0.13606764, 0.9877723, 1.0], + [-0.007543968, 0.99940944, 1.0], + [-6.793774e-05, 0.9999944, 1.0], + [0.0, 1.0, 1.0], + [-0.33494267, 0.960886, 1.0], + [-0.12677892, 0.9896274, 1.0], + [-0.00804112, 0.9995762, 1.0], + [-5.0966377e-05, 0.99999756, 1.0], + [0.0, 1.0, 1.0], + [-0.56956315, 0.94241995, 1.0], + [-0.83334297, 0.9581462, 1.0], + [-0.09486739, 0.9961979, 1.0], + [-0.00065249624, 0.99997574, 1.0], + [0.0, 1.0, 1.0], + [-0.39292914, 0.943953, 1.0], + [-0.43260995, 0.9667326, 1.0], + [-0.036218695, 0.99800736, 1.0], + [-0.00021563526, 0.99999046, 1.0], + [0.0, 1.0, 1.0], + [-0.32144308, 0.95278865, 1.0], + [-0.25995246, 0.9713513, 1.0], + [-0.016442856, 0.9985246, 1.0], + [-9.176571e-05, 0.9999942, 1.0], + [0.0, 1.0, 1.0], + [-0.2966282, 0.9744063, 1.0], + [-0.86246514, 0.9668585, 1.0], + [-0.17140412, 0.9942151, 1.0], + [-0.0021118673, 0.9999324, 1.0], + [0.0, 1.0, 1.0], + [0.08769655, 0.97262937, 1.0], + [-0.25923535, 0.9713818, 1.0], + [-0.06498976, 0.99482614, 1.0], + [-0.00078246964, 0.99993557, 1.0], + [0.0, 1.0, 1.0], + [0.5253878, 0.9505631, 1.0], + [-0.16767718, 0.9614315, 1.0], + [-0.24291219, 0.98733723, 1.0], + [-0.009154632, 0.99959785, 1.0], + [0.0, 1.0, 1.0], + [0.57957804, 0.9173889, 1.0], + [-3.944535, 0.831082, 1.0], + [-2.9686522, 0.8881784, 1.0], + [-0.14177988, 0.9944771, 1.0], + [0.0, 1.0, 1.0], + [0.42895818, 0.90997833, 1.0], + [-5.41419, 0.7882363, 1.0], + [-5.786541, 0.80714077, 1.0], + [-0.8622991, 0.97064185, 1.0], + [0.0, 1.0, 1.0], + [1.3849322, 0.89100575, 1.0], + [-3.418151, 0.8283688, 1.0], + [-7.0915565, 0.766481, 1.0], + [-2.9420247, 0.89867216, 1.0], + [0.0, 1.0, 1.0], + [2.6696517, 0.8438243, 1.0], + [-0.2037993, 0.8984032, 1.0], + [-3.0600338, 0.83977926, 1.0], + [-1.4190359, 0.93247384, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_Z_6_2.json b/assets/camera-profiles/NIKON_Z_6_2.json new file mode 100644 index 000000000..b00e045c1 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_6_2.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 6_2", + "files": 296, + "samples": 1099207, + "matrix": [ + [1.182910144251291, -0.29205257488744785, 0.10076600427689364], + [-0.06715969465291372, 1.185204734495203, -0.11541627485450512], + [-0.04243207284624334, -0.8237200432609093, 1.8732041320081247] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [6.2229915, 1.153762, 1.0], + [3.9428349, 1.1697679, 1.0], + [2.9512422, 1.1150318, 1.0], + [1.4521159, 1.0443524, 1.0], + [0.0, 1.0, 1.0], + [6.067863, 1.1530151, 1.0], + [2.674163, 1.1492246, 1.0], + [1.6906267, 1.0755203, 1.0], + [1.0559876, 1.0193381, 1.0], + [0.0, 1.0, 1.0], + [6.406609, 1.1438, 1.0], + [2.0468237, 1.126483, 1.0], + [0.6318166, 1.0328915, 1.0], + [0.38942862, 0.9951044, 1.0], + [0.0, 1.0, 1.0], + [5.6572795, 1.1365504, 1.0], + [1.088494, 1.1316147, 1.0], + [-0.7465848, 1.038763, 1.0], + [-1.7078602, 0.9750448, 1.0], + [0.0, 1.0, 1.0], + [5.6649947, 1.1077542, 1.0], + [0.8204385, 1.1192347, 1.0], + [-1.5888842, 1.0491564, 1.0], + [-3.0090246, 0.96298206, 1.0], + [0.0, 1.0, 1.0], + [5.256699, 1.0831563, 1.0], + [0.6007586, 1.0977919, 1.0], + [-2.570202, 1.0551977, 1.0], + [-5.4068832, 0.9654456, 1.0], + [0.0, 1.0, 1.0], + [4.539097, 1.0591519, 1.0], + [-0.10774251, 1.0788635, 1.0], + [-4.641281, 1.0305448, 1.0], + [-8.283271, 0.93709576, 1.0], + [0.0, 1.0, 1.0], + [4.142809, 1.0373175, 1.0], + [-0.8308222, 1.0613977, 1.0], + [-5.655344, 1.0214658, 1.0], + [-9.607277, 0.93629843, 1.0], + [0.0, 1.0, 1.0], + [3.8500762, 1.0362741, 1.0], + [-1.0345856, 1.0639124, 1.0], + [-5.4233723, 1.0284288, 1.0], + [-9.0873, 0.9531294, 1.0], + [0.0, 1.0, 1.0], + [4.0558853, 1.0385702, 1.0], + [-0.52777964, 1.0754012, 1.0], + [-4.6981053, 1.0426258, 1.0], + [-7.390084, 0.97722876, 1.0], + [0.0, 1.0, 1.0], + [4.439531, 1.0652789, 1.0], + [0.86604506, 1.0861824, 1.0], + [-2.8576968, 1.0505832, 1.0], + [-5.8478637, 0.9746698, 1.0], + [0.0, 1.0, 1.0], + [4.3485656, 1.0967733, 1.0], + [1.9174945, 1.1038177, 1.0], + [0.058721844, 1.0518842, 1.0], + [-1.5929213, 0.9253342, 1.0], + [0.0, 1.0, 1.0], + [3.6591005, 1.1211772, 1.0], + [2.2590137, 1.1025198, 1.0], + [1.1305307, 1.0139761, 1.0], + [-0.1372095, 0.87818533, 1.0], + [0.0, 1.0, 1.0], + [2.6356182, 1.1266937, 1.0], + [1.7848063, 1.0862155, 1.0], + [1.2136673, 0.99129325, 1.0], + [0.49023673, 0.8714401, 1.0], + [0.0, 1.0, 1.0], + [1.4648303, 1.118881, 1.0], + [0.9687011, 1.0753107, 1.0], + [0.7960804, 0.99398947, 1.0], + [0.7395835, 0.8858062, 1.0], + [0.0, 1.0, 1.0], + [0.44883335, 1.1299157, 1.0], + [0.61828953, 1.0732124, 1.0], + [0.9684744, 0.99130034, 1.0], + [1.4130936, 0.9186135, 1.0], + [0.0, 1.0, 1.0], + [0.40853867, 1.140463, 1.0], + [0.99736977, 1.0603918, 1.0], + [1.5362628, 0.9824557, 1.0], + [2.16631, 0.9292637, 1.0], + [0.0, 1.0, 1.0], + [0.20395991, 1.1486486, 1.0], + [0.8272985, 1.0560744, 1.0], + [1.4990557, 0.9908201, 1.0], + [2.295185, 0.93907094, 1.0], + [0.0, 1.0, 1.0], + [-0.57153416, 1.1716017, 1.0], + [0.24325375, 1.0848265, 1.0], + [1.1403633, 1.0200775, 1.0], + [2.1166058, 0.95587236, 1.0], + [0.0, 1.0, 1.0], + [-1.7980459, 1.2015567, 1.0], + [-0.9087, 1.1328977, 1.0], + [0.12065623, 1.0612612, 1.0], + [1.1421617, 0.9739673, 1.0], + [0.0, 1.0, 1.0], + [-3.3530312, 1.227411, 1.0], + [-2.557493, 1.1682405, 1.0], + [-1.6862918, 1.0950687, 1.0], + [-0.6257788, 0.9961956, 1.0], + [0.0, 1.0, 1.0], + [-5.1431966, 1.2525787, 1.0], + [-4.3500066, 1.1942875, 1.0], + [-3.3942263, 1.1264504, 1.0], + [-0.860235, 1.0129987, 1.0], + [0.0, 1.0, 1.0], + [-7.172523, 1.2782786, 1.0], + [-6.2652287, 1.2184532, 1.0], + [-5.2175336, 1.1654094, 1.0], + [-0.90600926, 1.02157, 1.0], + [0.0, 1.0, 1.0], + [-9.301277, 1.3098416, 1.0], + [-8.319625, 1.254189, 1.0], + [-6.29361, 1.1807519, 1.0], + [-0.2820965, 1.0070943, 1.0], + [0.0, 1.0, 1.0], + [-11.833535, 1.3462554, 1.0], + [-10.534334, 1.2848289, 1.0], + [-5.5140915, 1.1255784, 1.0], + [-0.11413354, 1.0005571, 1.0], + [0.0, 1.0, 1.0], + [-13.837044, 1.3168585, 1.0], + [-11.592036, 1.241745, 1.0], + [-3.8057983, 1.0537387, 1.0], + [-0.12250334, 0.9994377, 1.0], + [0.0, 1.0, 1.0], + [-15.302334, 1.2547938, 1.0], + [-12.304744, 1.171094, 1.0], + [-2.6331367, 1.0254496, 1.0], + [-0.02462376, 1.0000972, 1.0], + [0.0, 1.0, 1.0], + [-16.3649, 1.2033144, 1.0], + [-13.840853, 1.121017, 1.0], + [-4.7440042, 1.016522, 1.0], + [-0.09164992, 1.0001389, 1.0], + [0.0, 1.0, 1.0], + [-17.23536, 1.1622376, 1.0], + [-14.924817, 1.0792886, 1.0], + [-6.874241, 1.0002234, 1.0], + [-0.19945428, 0.9995216, 1.0], + [0.0, 1.0, 1.0], + [-17.24167, 1.1120819, 1.0], + [-13.978684, 1.0264746, 1.0], + [-5.8763328, 0.98856574, 1.0], + [-0.13876384, 0.999489, 1.0], + [0.0, 1.0, 1.0], + [-18.172258, 1.0878747, 1.0], + [-15.457481, 1.0080547, 1.0], + [-6.565444, 0.9856226, 1.0], + [-0.13294418, 0.9996134, 1.0], + [0.0, 1.0, 1.0], + [-16.83038, 1.0693645, 1.0], + [-15.625549, 0.9914287, 1.0], + [-7.72421, 0.9804178, 1.0], + [-0.15811352, 0.9995089, 1.0], + [0.0, 1.0, 1.0], + [-16.11028, 1.0584325, 1.0], + [-14.346652, 0.99943095, 1.0], + [-6.310089, 0.9858199, 1.0], + [-0.109850876, 0.9996651, 1.0], + [0.0, 1.0, 1.0], + [-14.929288, 1.0361999, 1.0], + [-12.50551, 0.9834405, 1.0], + [-5.2874904, 0.98251873, 1.0], + [-0.0921156, 0.99959683, 1.0], + [0.0, 1.0, 1.0], + [-14.727734, 1.0360364, 1.0], + [-12.03307, 0.9717191, 1.0], + [-5.2304645, 0.97766334, 1.0], + [-0.07621693, 0.9996011, 1.0], + [0.0, 1.0, 1.0], + [-15.998532, 1.0374138, 1.0], + [-13.555987, 0.9710816, 1.0], + [-6.071474, 0.97758615, 1.0], + [-0.088387944, 0.9996019, 1.0], + [0.0, 1.0, 1.0], + [-16.750172, 1.027443, 1.0], + [-14.493484, 0.9749124, 1.0], + [-5.9644837, 0.9815559, 1.0], + [-0.09535428, 0.9994739, 1.0], + [0.0, 1.0, 1.0], + [-17.231142, 0.9994733, 1.0], + [-14.346948, 0.95981574, 1.0], + [-6.037715, 0.9591468, 1.0], + [-0.38386485, 0.9952976, 1.0], + [0.0, 1.0, 1.0], + [-17.424784, 0.9555782, 1.0], + [-14.198873, 0.9357824, 1.0], + [-6.6123714, 0.94408447, 1.0], + [-0.4532846, 0.9941679, 1.0], + [0.0, 1.0, 1.0], + [-17.279493, 0.90192854, 1.0], + [-13.564478, 0.90127003, 1.0], + [-6.147515, 0.93834627, 1.0], + [-0.2323653, 0.9971496, 1.0], + [0.0, 1.0, 1.0], + [-17.093803, 0.85399765, 1.0], + [-12.756323, 0.87821823, 1.0], + [-6.1378803, 0.92416245, 1.0], + [-0.26953444, 0.9962813, 1.0], + [0.0, 1.0, 1.0], + [-16.701775, 0.8148734, 1.0], + [-11.737119, 0.86213005, 1.0], + [-4.978892, 0.925974, 1.0], + [-0.2315494, 0.9961441, 1.0], + [0.0, 1.0, 1.0], + [-16.116898, 0.79390544, 1.0], + [-11.100497, 0.8544334, 1.0], + [-3.051835, 0.9431366, 1.0], + [-0.095544234, 0.99742615, 1.0], + [0.0, 1.0, 1.0], + [-15.288991, 0.78051233, 1.0], + [-9.944976, 0.8618912, 1.0], + [-2.3829138, 0.9570897, 1.0], + [-0.033786688, 0.9981832, 1.0], + [0.0, 1.0, 1.0], + [-12.088607, 0.799739, 1.0], + [-6.9067097, 0.9043021, 1.0], + [-2.2045915, 0.98223805, 1.0], + [-0.04008953, 0.9986963, 1.0], + [0.0, 1.0, 1.0], + [-8.372293, 0.8433635, 1.0], + [-3.43054, 0.96173584, 1.0], + [-0.43066922, 1.0183202, 1.0], + [0.19982383, 1.0013425, 1.0], + [0.0, 1.0, 1.0], + [-7.0979204, 0.87468654, 1.0], + [-2.605678, 0.9790638, 1.0], + [0.7438621, 1.0240322, 1.0], + [0.90940267, 1.007664, 1.0], + [0.0, 1.0, 1.0], + [-7.001, 0.881765, 1.0], + [-1.34574, 1.0225254, 1.0], + [1.5278748, 1.0623524, 1.0], + [1.7761368, 1.0346187, 1.0], + [0.0, 1.0, 1.0], + [-6.164434, 0.8987546, 1.0], + [-0.2458219, 1.0717543, 1.0], + [1.9966408, 1.1075406, 1.0], + [2.03719, 1.0744351, 1.0], + [0.0, 1.0, 1.0], + [-5.634933, 0.86535674, 1.0], + [-1.0753672, 1.0728083, 1.0], + [1.0631428, 1.1154276, 1.0], + [0.6964589, 1.0430381, 1.0], + [0.0, 1.0, 1.0], + [-2.4912696, 0.92214453, 1.0], + [-0.93249446, 1.0711535, 1.0], + [-0.41212073, 1.0973893, 1.0], + [-0.1942435, 1.0235159, 1.0], + [0.0, 1.0, 1.0], + [-0.8377413, 1.0034848, 1.0], + [-0.45683056, 1.0996051, 1.0], + [-0.7544672, 1.0989084, 1.0], + [-0.3452393, 1.0226208, 1.0], + [0.0, 1.0, 1.0], + [-0.1365077, 1.0087785, 1.0], + [0.0327875, 1.0931748, 1.0], + [0.03409552, 1.0605692, 1.0], + [-0.08136579, 1.0068369, 1.0], + [0.0, 1.0, 1.0], + [1.215217, 0.95414764, 1.0], + [1.6130612, 1.0335056, 1.0], + [0.07605914, 1.0273331, 1.0], + [-0.20885852, 1.003798, 1.0], + [0.0, 1.0, 1.0], + [2.3572938, 0.92316496, 1.0], + [2.6421683, 1.0025958, 1.0], + [0.9645612, 1.0110464, 1.0], + [0.10840102, 1.0010912, 1.0], + [0.0, 1.0, 1.0], + [3.770162, 0.91984165, 1.0], + [3.5076504, 0.99966085, 1.0], + [1.6117474, 1.0087022, 1.0], + [0.47452623, 0.99906003, 1.0], + [0.0, 1.0, 1.0], + [3.279187, 0.9259939, 1.0], + [2.1051095, 1.0003723, 1.0], + [0.7558728, 1.0085037, 1.0], + [0.4204648, 0.999157, 1.0], + [0.0, 1.0, 1.0], + [3.1316261, 0.9648295, 1.0], + [1.2332041, 1.0157939, 1.0], + [0.23060545, 1.0078584, 1.0], + [0.014259016, 1.0006881, 1.0], + [0.0, 1.0, 1.0], + [3.3989656, 0.99194896, 1.0], + [2.2900262, 1.03556, 1.0], + [1.1375499, 1.013088, 1.0], + [0.113812156, 1.0003917, 1.0], + [0.0, 1.0, 1.0], + [4.0414777, 1.0378546, 1.0], + [2.3057628, 1.1105787, 1.0], + [1.6360228, 1.0446037, 1.0], + [0.24664895, 1.0011342, 1.0], + [0.0, 1.0, 1.0], + [4.806446, 1.0598252, 1.0], + [2.9564526, 1.1106843, 1.0], + [0.9111372, 1.0506293, 1.0], + [-0.017489238, 1.0016158, 1.0], + [0.0, 1.0, 1.0], + [4.3138623, 1.0589161, 1.0], + [2.0492713, 1.1007633, 1.0], + [1.2071747, 1.0373629, 1.0], + [0.13990282, 1.0014288, 1.0], + [0.0, 1.0, 1.0], + [4.150446, 1.0142206, 1.0], + [2.3013182, 1.0552701, 1.0], + [1.7414474, 1.0204091, 1.0], + [0.2009622, 1.0014528, 1.0], + [0.0, 1.0, 1.0], + [5.0740504, 0.9837976, 1.0], + [2.0655751, 1.0382036, 1.0], + [0.7234968, 1.018726, 1.0], + [-0.1094017, 1.0020746, 1.0], + [0.0, 1.0, 1.0], + [5.1731286, 0.99559367, 1.0], + [2.5818305, 1.019053, 1.0], + [1.2261839, 1.0031092, 1.0], + [0.17011318, 1.0003996, 1.0], + [0.0, 1.0, 1.0], + [4.2959146, 1.0069213, 1.0], + [1.4468756, 1.0213524, 1.0], + [0.6366564, 1.0068634, 1.0], + [0.06898734, 1.0005128, 1.0], + [0.0, 1.0, 1.0], + [6.0099535, 1.005978, 1.0], + [2.4273057, 1.0169591, 1.0], + [-0.13818303, 1.0067599, 1.0], + [-0.0033633148, 1.0006784, 1.0], + [0.0, 1.0, 1.0], + [7.5230107, 1.0331137, 1.0], + [4.7096148, 1.0529838, 1.0], + [1.6526409, 1.0215665, 1.0], + [0.09062616, 1.0016879, 1.0], + [0.0, 1.0, 1.0], + [8.177528, 1.0500778, 1.0], + [5.78432, 1.0727233, 1.0], + [4.1867056, 1.0556285, 1.0], + [0.946918, 1.0117143, 1.0], + [0.0, 1.0, 1.0], + [7.787752, 1.0514582, 1.0], + [6.581295, 1.0929804, 1.0], + [5.1642475, 1.0660969, 1.0], + [1.3973913, 1.0133713, 1.0], + [0.0, 1.0, 1.0], + [7.352549, 1.0832101, 1.0], + [6.488305, 1.1071424, 1.0], + [4.7150044, 1.0691316, 1.0], + [0.9782577, 1.0079756, 1.0], + [0.0, 1.0, 1.0], + [7.44746, 1.1423436, 1.0], + [5.2333174, 1.1424847, 1.0], + [4.2587075, 1.0928718, 1.0], + [1.1760778, 1.0190315, 1.0], + [0.0, 1.0, 1.0], + [4.7819715, 1.0929513, 1.0], + [3.038743, 1.1225566, 1.0], + [3.0403032, 1.0947937, 1.0], + [2.7168245, 1.0584737, 1.0], + [0.0, 1.0, 1.0], + [4.2442985, 1.0740716, 1.0], + [1.8085717, 1.092445, 1.0], + [1.6521245, 1.0550505, 1.0], + [1.2793007, 1.015843, 1.0], + [0.0, 1.0, 1.0], + [3.8161194, 1.048953, 1.0], + [1.0271565, 1.0645669, 1.0], + [0.37108362, 1.0149966, 1.0], + [0.16207772, 0.98473, 1.0], + [0.0, 1.0, 1.0], + [2.9961936, 1.0367951, 1.0], + [0.3735622, 1.0575669, 1.0], + [-1.3138187, 0.9924495, 1.0], + [-2.2027087, 0.9511259, 1.0], + [0.0, 1.0, 1.0], + [3.2774894, 1.0240017, 1.0], + [0.48561475, 1.0381898, 1.0], + [-1.8494679, 0.98786247, 1.0], + [-3.2887905, 0.9338637, 1.0], + [0.0, 1.0, 1.0], + [3.4378223, 1.0108881, 1.0], + [0.52244306, 1.0189863, 1.0], + [-1.8643115, 0.99323344, 1.0], + [-5.1015615, 0.92938703, 1.0], + [0.0, 1.0, 1.0], + [3.1067812, 0.9985392, 1.0], + [-0.12131784, 1.0145019, 1.0], + [-4.1442766, 0.986398, 1.0], + [-8.339385, 0.9153509, 1.0], + [0.0, 1.0, 1.0], + [2.4298422, 0.96142745, 1.0], + [-1.1118385, 1.0061582, 1.0], + [-5.695069, 0.9836191, 1.0], + [-9.741408, 0.91440827, 1.0], + [0.0, 1.0, 1.0], + [2.01661, 0.9458497, 1.0], + [-1.662954, 0.999966, 1.0], + [-5.8223696, 0.9850643, 1.0], + [-9.232703, 0.9267809, 1.0], + [0.0, 1.0, 1.0], + [2.2962167, 0.9605364, 1.0], + [-1.1373878, 1.0126402, 1.0], + [-4.8479958, 0.99816114, 1.0], + [-7.4450336, 0.9406932, 1.0], + [0.0, 1.0, 1.0], + [3.036456, 0.99115914, 1.0], + [0.17963961, 1.0285513, 1.0], + [-3.3088403, 1.0056642, 1.0], + [-6.273956, 0.93629354, 1.0], + [0.0, 1.0, 1.0], + [3.0664618, 1.026951, 1.0], + [1.2165159, 1.0476551, 1.0], + [-0.42700893, 0.9974062, 1.0], + [-1.4580182, 0.8679089, 1.0], + [0.0, 1.0, 1.0], + [2.7013292, 1.0606134, 1.0], + [1.5602528, 1.0500852, 1.0], + [0.58767015, 0.96606606, 1.0], + [-0.12461443, 0.83817023, 1.0], + [0.0, 1.0, 1.0], + [1.75421, 1.0628642, 1.0], + [1.077012, 1.0321119, 1.0], + [0.81344783, 0.9464788, 1.0], + [0.4725721, 0.8391105, 1.0], + [0.0, 1.0, 1.0], + [0.6471407, 1.0532293, 1.0], + [0.40457213, 1.0171094, 1.0], + [0.61659175, 0.9494374, 1.0], + [0.7804061, 0.8600364, 1.0], + [0.0, 1.0, 1.0], + [-0.15095921, 1.0584109, 1.0], + [0.24909112, 0.999217, 1.0], + [0.7718789, 0.9383637, 1.0], + [1.2283664, 0.8916653, 1.0], + [0.0, 1.0, 1.0], + [-0.23281652, 1.0636709, 1.0], + [0.5478948, 0.98645896, 1.0], + [1.1050255, 0.9356548, 1.0], + [1.721518, 0.89987594, 1.0], + [0.0, 1.0, 1.0], + [-0.45712063, 1.0652657, 1.0], + [0.3815263, 0.98859835, 1.0], + [1.0404433, 0.94592875, 1.0], + [1.8172855, 0.90899503, 1.0], + [0.0, 1.0, 1.0], + [-1.1051946, 1.0864792, 1.0], + [-0.06119209, 1.0147051, 1.0], + [0.7248705, 0.9713246, 1.0], + [1.5626872, 0.92918205, 1.0], + [0.0, 1.0, 1.0], + [-2.237749, 1.127092, 1.0], + [-1.070423, 1.0662243, 1.0], + [-0.104194425, 1.0130713, 1.0], + [0.804536, 0.9474472, 1.0], + [0.0, 1.0, 1.0], + [-3.6953864, 1.1598293, 1.0], + [-2.686826, 1.106682, 1.0], + [-1.7835299, 1.0456588, 1.0], + [-0.8053292, 0.9597566, 1.0], + [0.0, 1.0, 1.0], + [-5.2643313, 1.1904461, 1.0], + [-4.4016128, 1.1447512, 1.0], + [-3.6761749, 1.0970792, 1.0], + [-2.1196303, 0.9963984, 1.0], + [0.0, 1.0, 1.0], + [-7.199227, 1.2223662, 1.0], + [-6.308516, 1.1835574, 1.0], + [-5.5572577, 1.15013, 1.0], + [-2.1184824, 1.0233642, 1.0], + [0.0, 1.0, 1.0], + [-9.357493, 1.2634833, 1.0], + [-8.469821, 1.2300229, 1.0], + [-7.221032, 1.1844004, 1.0], + [-0.7896582, 1.0104114, 1.0], + [0.0, 1.0, 1.0], + [-11.875645, 1.3014976, 1.0], + [-10.859666, 1.2672151, 1.0], + [-7.7761583, 1.1516356, 1.0], + [-0.47165853, 0.9992604, 1.0], + [0.0, 1.0, 1.0], + [-14.108668, 1.2699456, 1.0], + [-12.638117, 1.2220073, 1.0], + [-7.2701273, 1.0625613, 1.0], + [-0.6548264, 0.99727935, 1.0], + [0.0, 1.0, 1.0], + [-15.724136, 1.204452, 1.0], + [-13.639737, 1.1411229, 1.0], + [-5.4015455, 1.0294274, 1.0], + [-0.093046986, 0.99997, 1.0], + [0.0, 1.0, 1.0], + [-16.673388, 1.1604823, 1.0], + [-15.056745, 1.0991567, 1.0], + [-6.999325, 1.0093077, 1.0], + [-0.15357149, 0.99979734, 1.0], + [0.0, 1.0, 1.0], + [-17.081741, 1.1213652, 1.0], + [-15.57136, 1.0569661, 1.0], + [-8.844844, 0.9832494, 1.0], + [-0.32678962, 0.9983225, 1.0], + [0.0, 1.0, 1.0], + [-17.33926, 1.0855446, 1.0], + [-15.313985, 1.0192273, 1.0], + [-8.119184, 0.9764423, 1.0], + [-0.23862769, 0.9987188, 1.0], + [0.0, 1.0, 1.0], + [-18.433193, 1.0620961, 1.0], + [-16.90102, 0.9942711, 1.0], + [-9.617896, 0.9711072, 1.0], + [-0.2552796, 0.99904865, 1.0], + [0.0, 1.0, 1.0], + [-17.276731, 1.0457346, 1.0], + [-16.739477, 0.9782363, 1.0], + [-11.156491, 0.96119857, 1.0], + [-0.34167433, 0.9984216, 1.0], + [0.0, 1.0, 1.0], + [-17.084091, 1.0438286, 1.0], + [-15.816032, 0.984013, 1.0], + [-9.323119, 0.9641214, 1.0], + [-0.2387637, 0.9986463, 1.0], + [0.0, 1.0, 1.0], + [-15.596954, 1.0198183, 1.0], + [-13.655594, 0.9686297, 1.0], + [-7.797318, 0.955375, 1.0], + [-0.22518365, 0.9980864, 1.0], + [0.0, 1.0, 1.0], + [-15.301703, 1.0141279, 1.0], + [-12.989882, 0.93955874, 1.0], + [-7.9458184, 0.9391862, 1.0], + [-0.2038498, 0.9980101, 1.0], + [0.0, 1.0, 1.0], + [-16.792349, 1.0134387, 1.0], + [-14.75936, 0.9455252, 1.0], + [-8.760609, 0.9422921, 1.0], + [-0.30783197, 0.9958334, 1.0], + [0.0, 1.0, 1.0], + [-17.005386, 1.0050753, 1.0], + [-15.469527, 0.9674358, 1.0], + [-8.49741, 0.95576, 1.0], + [-0.41260514, 0.9953345, 1.0], + [0.0, 1.0, 1.0], + [-17.273808, 0.9787281, 1.0], + [-15.69969, 0.942322, 1.0], + [-12.234762, 0.87553686, 1.0], + [-2.3212836, 0.9701262, 1.0], + [0.0, 1.0, 1.0], + [-17.132599, 0.9134147, 1.0], + [-15.41619, 0.8991917, 1.0], + [-12.281813, 0.8682994, 1.0], + [-2.1571953, 0.970933, 1.0], + [0.0, 1.0, 1.0], + [-16.799068, 0.85166913, 1.0], + [-14.772821, 0.8631338, 1.0], + [-9.500277, 0.8921043, 1.0], + [-0.7092388, 0.98975027, 1.0], + [0.0, 1.0, 1.0], + [-16.442728, 0.8058451, 1.0], + [-13.375286, 0.8466388, 1.0], + [-8.537738, 0.8891218, 1.0], + [-0.5375653, 0.99202126, 1.0], + [0.0, 1.0, 1.0], + [-15.213923, 0.78132874, 1.0], + [-11.185756, 0.8387474, 1.0], + [-7.113949, 0.8830028, 1.0], + [-0.51550055, 0.98982215, 1.0], + [0.0, 1.0, 1.0], + [-14.168373, 0.7644356, 1.0], + [-10.289349, 0.830659, 1.0], + [-4.6552444, 0.8868689, 1.0], + [-0.362867, 0.9811054, 1.0], + [0.0, 1.0, 1.0], + [-12.229682, 0.75506926, 1.0], + [-8.255229, 0.8390729, 1.0], + [-3.1724656, 0.88942176, 1.0], + [-0.36211884, 0.96008885, 1.0], + [0.0, 1.0, 1.0], + [-8.780003, 0.78203535, 1.0], + [-4.8128314, 0.88308793, 1.0], + [-1.7214035, 0.9332362, 1.0], + [0.07604427, 0.9700093, 1.0], + [0.0, 1.0, 1.0], + [-5.1902246, 0.84770036, 1.0], + [-2.0365455, 0.94785786, 1.0], + [0.075043306, 0.98688334, 1.0], + [1.348575, 0.9891258, 1.0], + [0.0, 1.0, 1.0], + [-3.9187067, 0.8880616, 1.0], + [-0.8643225, 0.98719597, 1.0], + [1.677873, 1.0176314, 1.0], + [3.0216281, 1.0148586, 1.0], + [0.0, 1.0, 1.0], + [-3.5886059, 0.8924353, 1.0], + [0.30670318, 1.0308015, 1.0], + [2.7104156, 1.0678495, 1.0], + [3.735997, 1.0629262, 1.0], + [0.0, 1.0, 1.0], + [-3.084609, 0.91431457, 1.0], + [1.0545353, 1.082462, 1.0], + [2.8006148, 1.1123718, 1.0], + [3.2798169, 1.1044176, 1.0], + [0.0, 1.0, 1.0], + [-3.0868382, 0.88934803, 1.0], + [0.24395227, 1.0737534, 1.0], + [1.8516512, 1.1117501, 1.0], + [1.8256167, 1.0846347, 1.0], + [0.0, 1.0, 1.0], + [-1.303041, 0.91892815, 1.0], + [-0.3339426, 1.0717436, 1.0], + [-0.5027457, 1.1226003, 1.0], + [-0.55369043, 1.0714196, 1.0], + [0.0, 1.0, 1.0], + [0.009014121, 0.99058026, 1.0], + [-0.30335096, 1.1036884, 1.0], + [-1.4451755, 1.1425995, 1.0], + [-1.269934, 1.0913438, 1.0], + [0.0, 1.0, 1.0], + [0.61342883, 0.9911708, 1.0], + [-0.14296348, 1.1055524, 1.0], + [-1.336663, 1.1335871, 1.0], + [-1.1314107, 1.0589578, 1.0], + [0.0, 1.0, 1.0], + [1.7491869, 0.94608873, 1.0], + [1.4192203, 1.0620985, 1.0], + [-0.24427313, 1.079302, 1.0], + [-1.2224454, 1.0283191, 1.0], + [0.0, 1.0, 1.0], + [3.2000449, 0.9136502, 1.0], + [3.1475732, 1.0253253, 1.0], + [2.1547606, 1.0377923, 1.0], + [0.30383298, 1.0079229, 1.0], + [0.0, 1.0, 1.0], + [4.7772436, 0.9144093, 1.0], + [4.162533, 1.047513, 1.0], + [4.2758074, 1.0457861, 1.0], + [3.105151, 0.9908633, 1.0], + [0.0, 1.0, 1.0], + [3.676512, 0.9309974, 1.0], + [2.7957842, 1.0558958, 1.0], + [3.4883666, 1.0355694, 1.0], + [3.137294, 0.9896551, 1.0], + [0.0, 1.0, 1.0], + [3.122498, 0.9589433, 1.0], + [1.543551, 1.0560359, 1.0], + [1.3497539, 1.0383536, 1.0], + [0.11099989, 1.0035237, 1.0], + [0.0, 1.0, 1.0], + [3.580221, 0.9780591, 1.0], + [1.9945487, 1.0655055, 1.0], + [2.4221385, 1.0402309, 1.0], + [0.4563281, 1.0015888, 1.0], + [0.0, 1.0, 1.0], + [4.7698708, 1.0119603, 1.0], + [2.4278777, 1.1139587, 1.0], + [3.0077763, 1.075531, 1.0], + [0.89499116, 1.0035012, 1.0], + [0.0, 1.0, 1.0], + [5.788685, 1.0530056, 1.0], + [2.6037643, 1.1413025, 1.0], + [1.4215078, 1.0968238, 1.0], + [0.20484339, 1.0055182, 1.0], + [0.0, 1.0, 1.0], + [5.1022477, 1.0453085, 1.0], + [2.2648408, 1.1255327, 1.0], + [3.5084808, 1.0749761, 1.0], + [0.8860151, 1.0050836, 1.0], + [0.0, 1.0, 1.0], + [4.840788, 0.99767727, 1.0], + [2.867805, 1.0597396, 1.0], + [5.0557113, 1.041923, 1.0], + [1.229561, 1.0051966, 1.0], + [0.0, 1.0, 1.0], + [7.0307097, 0.97708684, 1.0], + [4.806399, 1.047505, 1.0], + [1.4926684, 1.0392994, 1.0], + [-0.316868, 1.0058483, 1.0], + [0.0, 1.0, 1.0], + [6.4268966, 0.97928965, 1.0], + [4.44971, 1.0286714, 1.0], + [3.9176347, 1.0229295, 1.0], + [0.89289904, 1.0034719, 1.0], + [0.0, 1.0, 1.0], + [5.0285277, 0.9728169, 1.0], + [2.574475, 1.0172137, 1.0], + [3.1116145, 1.0253292, 1.0], + [0.8804246, 1.0046561, 1.0], + [0.0, 1.0, 1.0], + [5.9326806, 0.9673203, 1.0], + [3.0191274, 0.9986364, 1.0], + [2.0088856, 1.0064441, 1.0], + [0.8739533, 1.0035771, 1.0], + [0.0, 1.0, 1.0], + [6.88084, 0.9650284, 1.0], + [3.5343342, 0.9909499, 1.0], + [2.52684, 0.99866265, 1.0], + [1.0010276, 1.0015429, 1.0], + [0.0, 1.0, 1.0], + [6.6674595, 0.9901648, 1.0], + [3.5274854, 1.0219513, 1.0], + [4.0995092, 1.0413644, 1.0], + [2.9521806, 1.0259401, 1.0], + [0.0, 1.0, 1.0], + [7.1322083, 1.0172622, 1.0], + [4.796819, 1.0498525, 1.0], + [4.7010384, 1.0518665, 1.0], + [3.3101153, 1.0278659, 1.0], + [0.0, 1.0, 1.0], + [6.23181, 1.0341, 1.0], + [4.575992, 1.0661216, 1.0], + [4.1811743, 1.0555745, 1.0], + [2.9168804, 1.0209843, 1.0], + [0.0, 1.0, 1.0], + [5.773051, 1.0802647, 1.0], + [3.930077, 1.1002141, 1.0], + [3.9242337, 1.0825543, 1.0], + [3.3296824, 1.0521122, 1.0], + [0.0, 1.0, 1.0], + [2.4754193, 0.95264477, 1.0], + [1.6237425, 0.9976076, 1.0], + [1.6940635, 1.0124187, 1.0], + [1.4032439, 1.0027901, 1.0], + [0.0, 1.0, 1.0], + [1.8328916, 0.9358476, 1.0], + [0.5054257, 0.9516976, 1.0], + [0.82207125, 0.96641874, 1.0], + [0.867466, 0.96557885, 1.0], + [0.0, 1.0, 1.0], + [1.107456, 0.9178314, 1.0], + [-0.11612688, 0.9198909, 1.0], + [-0.13824676, 0.9118656, 1.0], + [-0.0028314982, 0.9240737, 1.0], + [0.0, 1.0, 1.0], + [1.2662044, 0.9126919, 1.0], + [0.20858723, 0.91308266, 1.0], + [-0.74156207, 0.8892692, 1.0], + [-0.96805483, 0.8813321, 1.0], + [0.0, 1.0, 1.0], + [1.5846848, 0.8996693, 1.0], + [0.3940221, 0.9022535, 1.0], + [-1.0160103, 0.8844732, 1.0], + [-1.9547969, 0.869879, 1.0], + [0.0, 1.0, 1.0], + [1.8889635, 0.8861786, 1.0], + [0.46730384, 0.8902896, 1.0], + [-0.8314566, 0.8740593, 1.0], + [-3.0556507, 0.85314214, 1.0], + [0.0, 1.0, 1.0], + [1.786228, 0.87517726, 1.0], + [-0.25766176, 0.8843216, 1.0], + [-2.6286218, 0.87064177, 1.0], + [-6.709825, 0.8475933, 1.0], + [0.0, 1.0, 1.0], + [1.1630952, 0.8485678, 1.0], + [-1.315835, 0.88361984, 1.0], + [-4.564759, 0.8761655, 1.0], + [-7.6052637, 0.84420526, 1.0], + [0.0, 1.0, 1.0], + [0.65808207, 0.8359365, 1.0], + [-1.8502634, 0.871772, 1.0], + [-4.9014773, 0.88665104, 1.0], + [-6.9419703, 0.85948473, 1.0], + [0.0, 1.0, 1.0], + [0.98892176, 0.85553217, 1.0], + [-1.2973236, 0.88800645, 1.0], + [-3.9453318, 0.8810778, 1.0], + [-5.0194716, 0.83465356, 1.0], + [0.0, 1.0, 1.0], + [1.4077076, 0.8883648, 1.0], + [-0.20262001, 0.89855146, 1.0], + [-2.0743167, 0.87382066, 1.0], + [-2.846246, 0.8157335, 1.0], + [0.0, 1.0, 1.0], + [1.6395962, 0.92741174, 1.0], + [0.7942348, 0.92847836, 1.0], + [-0.10936606, 0.878342, 1.0], + [-0.28163967, 0.78697026, 1.0], + [0.0, 1.0, 1.0], + [1.9163837, 0.9670593, 1.0], + [1.1606812, 0.94956505, 1.0], + [0.48494318, 0.86900467, 1.0], + [0.48800853, 0.77839893, 1.0], + [0.0, 1.0, 1.0], + [0.9139721, 0.9643932, 1.0], + [0.70910484, 0.9468291, 1.0], + [0.70515186, 0.87377256, 1.0], + [0.7435633, 0.7895271, 1.0], + [0.0, 1.0, 1.0], + [-0.021806521, 0.9712936, 1.0], + [0.09517821, 0.94683015, 1.0], + [0.48132607, 0.8953816, 1.0], + [0.7116937, 0.82242763, 1.0], + [0.0, 1.0, 1.0], + [-0.79750377, 0.9793866, 1.0], + [-0.08199019, 0.933896, 1.0], + [0.54560524, 0.8938053, 1.0], + [0.91736573, 0.8542098, 1.0], + [0.0, 1.0, 1.0], + [-0.93426365, 0.9845968, 1.0], + [0.15814182, 0.92910755, 1.0], + [0.72163683, 0.89656097, 1.0], + [1.2182864, 0.86374855, 1.0], + [0.0, 1.0, 1.0], + [-1.0705345, 0.98736614, 1.0], + [-0.011547462, 0.9383716, 1.0], + [0.6337991, 0.90952563, 1.0], + [1.2993928, 0.87798977, 1.0], + [0.0, 1.0, 1.0], + [-1.6194162, 1.0018165, 1.0], + [-0.49097732, 0.9606046, 1.0], + [0.2521988, 0.9307922, 1.0], + [0.9645536, 0.8885482, 1.0], + [0.0, 1.0, 1.0], + [-2.80128, 1.0367984, 1.0], + [-1.5576428, 0.9923525, 1.0], + [-0.66330856, 0.9425935, 1.0], + [0.1666689, 0.83448035, 1.0], + [0.0, 1.0, 1.0], + [-4.1835513, 1.0688453, 1.0], + [-3.0496554, 1.0094827, 1.0], + [-2.0194643, 0.9350747, 1.0], + [-0.84885937, 0.81810623, 1.0], + [0.0, 1.0, 1.0], + [-5.472717, 1.096382, 1.0], + [-4.410626, 1.0342624, 1.0], + [-3.3867917, 0.9620032, 1.0], + [-1.1987106, 0.92667925, 1.0], + [0.0, 1.0, 1.0], + [-7.2029138, 1.1229113, 1.0], + [-5.953019, 1.0709114, 1.0], + [-3.932608, 1.0097591, 1.0], + [-0.7465921, 0.9776096, 1.0], + [0.0, 1.0, 1.0], + [-8.683279, 1.1355549, 1.0], + [-7.128312, 1.0855685, 1.0], + [-3.1864233, 1.005719, 1.0], + [-0.26363567, 0.99284154, 1.0], + [0.0, 1.0, 1.0], + [-10.163454, 1.1477863, 1.0], + [-7.8144236, 1.0853112, 1.0], + [-1.8801234, 1.0063028, 1.0], + [-0.08024162, 0.9992318, 1.0], + [0.0, 1.0, 1.0], + [-12.115101, 1.1146804, 1.0], + [-8.96621, 1.0459248, 1.0], + [-1.6161847, 0.9987317, 1.0], + [-0.071704574, 0.99979985, 1.0], + [0.0, 1.0, 1.0], + [-13.706088, 1.0729254, 1.0], + [-8.66254, 1.008506, 1.0], + [-1.0726118, 0.99519026, 1.0], + [-0.013879563, 0.999924, 1.0], + [0.0, 1.0, 1.0], + [-13.639081, 1.0313063, 1.0], + [-8.5867815, 0.9870387, 1.0], + [-1.1318198, 0.9935133, 1.0], + [-0.014718597, 0.99989676, 1.0], + [0.0, 1.0, 1.0], + [-13.590664, 1.0171928, 1.0], + [-7.5184603, 0.98305726, 1.0], + [-0.9713564, 0.9937742, 1.0], + [-0.017022038, 0.9998613, 1.0], + [0.0, 1.0, 1.0], + [-13.854863, 1.0014843, 1.0], + [-7.721969, 0.9806506, 1.0], + [-0.77561605, 0.99587494, 1.0], + [-0.009547515, 0.9999292, 1.0], + [0.0, 1.0, 1.0], + [-14.060616, 0.98336315, 1.0], + [-8.691673, 0.94924486, 1.0], + [-1.2617085, 0.9861989, 1.0], + [-0.017596014, 0.99978256, 1.0], + [0.0, 1.0, 1.0], + [-12.874084, 0.9567539, 1.0], + [-9.545912, 0.9260754, 1.0], + [-2.5305426, 0.97063804, 1.0], + [-0.05151436, 0.99930036, 1.0], + [0.0, 1.0, 1.0], + [-13.672559, 0.9840259, 1.0], + [-8.942783, 0.9562326, 1.0], + [-1.7533314, 0.983495, 1.0], + [-0.031896364, 0.99963516, 1.0], + [0.0, 1.0, 1.0], + [-12.389929, 0.9684237, 1.0], + [-7.423202, 0.9658616, 1.0], + [-1.1646813, 0.9892395, 1.0], + [-0.019982396, 0.9997543, 1.0], + [0.0, 1.0, 1.0], + [-11.921755, 0.9305603, 1.0], + [-8.221304, 0.9161024, 1.0], + [-2.065746, 0.9699598, 1.0], + [-0.05210479, 0.999025, 1.0], + [0.0, 1.0, 1.0], + [-12.236582, 0.92396444, 1.0], + [-8.276435, 0.9221221, 1.0], + [-3.3240492, 0.9396533, 1.0], + [-0.477805, 0.9889453, 1.0], + [0.0, 1.0, 1.0], + [-11.884224, 0.9080763, 1.0], + [-6.9323835, 0.9483917, 1.0], + [-2.8353944, 0.9600339, 1.0], + [-0.33724767, 0.9937283, 1.0], + [0.0, 1.0, 1.0], + [-13.285868, 0.8697298, 1.0], + [-7.293664, 0.9262688, 1.0], + [-2.6733654, 0.9643648, 1.0], + [-0.293086, 0.9961857, 1.0], + [0.0, 1.0, 1.0], + [-14.295206, 0.8048045, 1.0], + [-9.883155, 0.8632042, 1.0], + [-3.227856, 0.95656085, 1.0], + [-0.23363292, 0.9969017, 1.0], + [0.0, 1.0, 1.0], + [-14.586395, 0.75223583, 1.0], + [-11.311147, 0.81547827, 1.0], + [-2.238798, 0.96711767, 1.0], + [-0.07408935, 0.998945, 1.0], + [0.0, 1.0, 1.0], + [-14.474079, 0.7245708, 1.0], + [-11.533241, 0.7828741, 1.0], + [-2.736401, 0.9435411, 1.0], + [-0.044651125, 0.9990903, 1.0], + [0.0, 1.0, 1.0], + [-13.674288, 0.71236974, 1.0], + [-9.81334, 0.7972127, 1.0], + [-3.45957, 0.9197005, 1.0], + [-0.07574147, 0.9975253, 1.0], + [0.0, 1.0, 1.0], + [-12.7195, 0.69227666, 1.0], + [-8.596455, 0.7779914, 1.0], + [-2.3802116, 0.8463873, 1.0], + [-0.35825908, 0.95749724, 1.0], + [0.0, 1.0, 1.0], + [-10.18446, 0.6873084, 1.0], + [-6.0542045, 0.7841001, 1.0], + [-2.1212237, 0.8281544, 1.0], + [-0.7646297, 0.9053229, 1.0], + [0.0, 1.0, 1.0], + [-6.1229663, 0.74606425, 1.0], + [-3.4445412, 0.8351193, 1.0], + [-1.6135985, 0.87599313, 1.0], + [-0.2056776, 0.9368623, 1.0], + [0.0, 1.0, 1.0], + [-3.7523043, 0.8158876, 1.0], + [-1.8315514, 0.894766, 1.0], + [-0.27596512, 0.93012476, 1.0], + [1.0396978, 0.9575369, 1.0], + [0.0, 1.0, 1.0], + [-2.7688127, 0.8445321, 1.0], + [-0.50951934, 0.9420834, 1.0], + [1.4284167, 0.9780485, 1.0], + [2.5512972, 0.9817148, 1.0], + [0.0, 1.0, 1.0], + [-2.1246834, 0.8524671, 1.0], + [0.29334772, 0.96627426, 1.0], + [2.560665, 1.0186502, 1.0], + [3.3895814, 1.0229484, 1.0], + [0.0, 1.0, 1.0], + [-1.3665168, 0.8436846, 1.0], + [1.2660046, 1.012508, 1.0], + [2.7319925, 1.0737249, 1.0], + [3.0757403, 1.0803311, 1.0], + [0.0, 1.0, 1.0], + [-0.5887472, 0.81296784, 1.0], + [0.699834, 0.98561674, 1.0], + [1.4461241, 1.0658891, 1.0], + [1.3923068, 1.0570195, 1.0], + [0.0, 1.0, 1.0], + [1.096663, 0.8274317, 1.0], + [0.40805075, 0.98812795, 1.0], + [-0.045402966, 1.0472325, 1.0], + [-0.06040587, 1.0157863, 1.0], + [0.0, 1.0, 1.0], + [1.9790232, 0.87913215, 1.0], + [0.37345043, 1.0132964, 1.0], + [-0.79287934, 1.0823095, 1.0], + [-0.34175304, 1.0432074, 1.0], + [0.0, 1.0, 1.0], + [2.5627797, 0.87573963, 1.0], + [0.6304921, 1.0118407, 1.0], + [-0.9373828, 1.0851029, 1.0], + [-0.46959534, 1.031097, 1.0], + [0.0, 1.0, 1.0], + [3.7361374, 0.8472772, 1.0], + [1.9630458, 0.98144615, 1.0], + [-0.21733184, 1.0450604, 1.0], + [-0.49014735, 1.0128133, 1.0], + [0.0, 1.0, 1.0], + [4.3971963, 0.8324688, 1.0], + [2.660751, 0.9585303, 1.0], + [0.16532291, 1.0232745, 1.0], + [-0.14636081, 1.0036846, 1.0], + [0.0, 1.0, 1.0], + [5.0258904, 0.8367818, 1.0], + [2.8924615, 0.9749669, 1.0], + [1.7615348, 1.0238429, 1.0], + [0.81233835, 0.9984574, 1.0], + [0.0, 1.0, 1.0], + [5.2452407, 0.85815537, 1.0], + [2.6645274, 0.9761913, 1.0], + [1.4570304, 1.0139804, 1.0], + [1.0984119, 0.997529, 1.0], + [0.0, 1.0, 1.0], + [4.93689, 0.8569606, 1.0], + [3.1328692, 0.9457437, 1.0], + [0.033365987, 1.0082009, 1.0], + [-0.05564574, 1.0016743, 1.0], + [0.0, 1.0, 1.0], + [5.0655975, 0.8770879, 1.0], + [2.174103, 0.9875497, 1.0], + [0.2545323, 1.0086097, 1.0], + [0.022092488, 1.000235, 1.0], + [0.0, 1.0, 1.0], + [4.4488134, 0.9094009, 1.0], + [1.7526729, 1.0061622, 1.0], + [0.42274538, 1.0103294, 1.0], + [0.051791485, 1.0002624, 1.0], + [0.0, 1.0, 1.0], + [5.1548405, 0.95060486, 1.0], + [1.8016412, 1.0232372, 1.0], + [0.20820145, 1.0119417, 1.0], + [0.027733212, 1.0003631, 1.0], + [0.0, 1.0, 1.0], + [5.4379053, 0.94287974, 1.0], + [1.6865833, 1.0055557, 1.0], + [0.5884739, 1.0089288, 1.0], + [0.10115034, 1.0003462, 1.0], + [0.0, 1.0, 1.0], + [3.8398051, 0.90459824, 1.0], + [1.1598605, 0.9777082, 1.0], + [0.77112013, 1.0046481, 1.0], + [0.15015621, 1.0003301, 1.0], + [0.0, 1.0, 1.0], + [5.6665335, 0.91051537, 1.0], + [3.33516, 0.98394734, 1.0], + [0.32077682, 1.0035998, 1.0], + [-0.005901165, 1.00032, 1.0], + [0.0, 1.0, 1.0], + [4.3764243, 0.9138666, 1.0], + [1.7997692, 0.97097856, 1.0], + [0.87327296, 0.9996552, 1.0], + [0.14200021, 1.0003701, 1.0], + [0.0, 1.0, 1.0], + [2.8900363, 0.8881567, 1.0], + [-0.016681926, 0.9412745, 1.0], + [1.2008874, 0.9925353, 1.0], + [0.2819057, 1.0004404, 1.0], + [0.0, 1.0, 1.0], + [3.0338037, 0.8888064, 1.0], + [0.49603212, 0.92353123, 1.0], + [0.344872, 0.9546143, 1.0], + [0.23514952, 0.99573493, 1.0], + [0.0, 1.0, 1.0], + [3.2889633, 0.8941952, 1.0], + [0.12481757, 0.90704453, 1.0], + [-0.89585435, 0.91424674, 1.0], + [-0.24134572, 0.9783999, 1.0], + [0.0, 1.0, 1.0], + [2.7199328, 0.9107902, 1.0], + [0.096942216, 0.9292534, 1.0], + [-0.3109405, 0.9336825, 1.0], + [-0.33917436, 0.96199673, 1.0], + [0.0, 1.0, 1.0], + [3.5014708, 0.9283932, 1.0], + [1.2909425, 0.95882636, 1.0], + [0.26298514, 0.95131576, 1.0], + [-0.69164455, 0.9573067, 1.0], + [0.0, 1.0, 1.0], + [3.5789986, 0.93125904, 1.0], + [2.0657613, 0.9823736, 1.0], + [1.2339572, 0.9823017, 1.0], + [0.035981927, 0.9774156, 1.0], + [0.0, 1.0, 1.0], + [3.2489352, 0.947897, 1.0], + [2.3317747, 1.0011578, 1.0], + [1.9685928, 1.009779, 1.0], + [1.1930741, 1.0035145, 1.0], + [0.0, 1.0, 1.0], + [0.41370514, 0.86071473, 1.0], + [-0.63482434, 0.8487081, 1.0], + [-0.96838796, 0.8932299, 1.0], + [-0.16537139, 0.98455006, 1.0], + [0.0, 1.0, 1.0], + [0.7274317, 0.8435787, 1.0], + [-0.7413707, 0.8246945, 1.0], + [-0.6780908, 0.84962696, 1.0], + [-0.03611998, 0.9278284, 1.0], + [0.0, 1.0, 1.0], + [-0.025755988, 0.7956887, 1.0], + [-0.7080098, 0.7706736, 1.0], + [-0.00667306, 0.80417067, 1.0], + [1.2645549, 0.8267063, 1.0], + [0.0, 1.0, 1.0], + [1.844674, 0.77885765, 1.0], + [1.358191, 0.74993426, 1.0], + [1.8842225, 0.771222, 1.0], + [3.6828177, 0.75069827, 1.0], + [0.0, 1.0, 1.0], + [2.147642, 0.7875774, 1.0], + [1.6485577, 0.7667687, 1.0], + [2.232393, 0.75781333, 1.0], + [3.8045251, 0.73986065, 1.0], + [0.0, 1.0, 1.0], + [2.1638684, 0.78218484, 1.0], + [1.5419188, 0.7789207, 1.0], + [1.5175326, 0.76315284, 1.0], + [2.9210594, 0.7496939, 1.0], + [0.0, 1.0, 1.0], + [1.6404829, 0.76603454, 1.0], + [0.3848709, 0.76939934, 1.0], + [-0.5066958, 0.7645504, 1.0], + [0.97468865, 0.73737776, 1.0], + [0.0, 1.0, 1.0], + [0.65317655, 0.7593511, 1.0], + [-0.66478103, 0.7523295, 1.0], + [0.07674445, 0.7192325, 1.0], + [0.11743816, 0.7180049, 1.0], + [0.0, 1.0, 1.0], + [-0.07553896, 0.75588036, 1.0], + [-0.7599607, 0.7307774, 1.0], + [0.7937273, 0.702063, 1.0], + [1.210313, 0.71042067, 1.0], + [0.0, 1.0, 1.0], + [0.6146166, 0.7519787, 1.0], + [-0.11158234, 0.7340125, 1.0], + [1.8024358, 0.6884776, 1.0], + [2.2258494, 0.6841371, 1.0], + [0.0, 1.0, 1.0], + [0.7284711, 0.81019914, 1.0], + [0.0031727236, 0.7846696, 1.0], + [2.4180965, 0.6961533, 1.0], + [3.2767165, 0.68659955, 1.0], + [0.0, 1.0, 1.0], + [0.7535696, 0.863907, 1.0], + [0.36523274, 0.8374792, 1.0], + [1.2830526, 0.791074, 1.0], + [1.2069559, 0.8067631, 1.0], + [0.0, 1.0, 1.0], + [1.3411092, 0.88778365, 1.0], + [0.7838614, 0.86403745, 1.0], + [1.0240868, 0.81532073, 1.0], + [0.9583133, 0.7903267, 1.0], + [0.0, 1.0, 1.0], + [-0.12238092, 0.85272485, 1.0], + [0.07477853, 0.8615265, 1.0], + [0.6215121, 0.80922437, 1.0], + [0.8200295, 0.7752551, 1.0], + [0.0, 1.0, 1.0], + [-0.82122755, 0.87185943, 1.0], + [-0.2457037, 0.8873393, 1.0], + [0.26640493, 0.85581523, 1.0], + [0.45366037, 0.81068766, 1.0], + [0.0, 1.0, 1.0], + [-1.3295536, 0.9104388, 1.0], + [-0.5824124, 0.87757504, 1.0], + [0.006645972, 0.8408192, 1.0], + [0.28378436, 0.8216209, 1.0], + [0.0, 1.0, 1.0], + [-1.7040519, 0.9045666, 1.0], + [-0.47549373, 0.86758405, 1.0], + [0.053788297, 0.83981687, 1.0], + [0.40476194, 0.81218266, 1.0], + [0.0, 1.0, 1.0], + [-1.5469458, 0.9090261, 1.0], + [-0.5149614, 0.88305897, 1.0], + [0.04461902, 0.8536314, 1.0], + [0.47533098, 0.8176997, 1.0], + [0.0, 1.0, 1.0], + [-1.9928781, 0.9177581, 1.0], + [-1.1592609, 0.8999771, 1.0], + [-0.47069812, 0.8662861, 1.0], + [0.24946758, 0.8074272, 1.0], + [0.0, 1.0, 1.0], + [-2.8105757, 0.9419143, 1.0], + [-1.9706584, 0.9134377, 1.0], + [-1.0593511, 0.8243537, 1.0], + [-0.37524036, 0.77119154, 1.0], + [0.0, 1.0, 1.0], + [-3.1471803, 0.9753915, 1.0], + [-2.3176653, 0.91906554, 1.0], + [-1.3088802, 0.86219937, 1.0], + [-0.58739233, 0.86786795, 1.0], + [0.0, 1.0, 1.0], + [-2.94802, 0.97012186, 1.0], + [-2.1835663, 0.95009077, 1.0], + [-1.0128062, 0.9544606, 1.0], + [-0.15606245, 0.98380494, 1.0], + [0.0, 1.0, 1.0], + [-3.345666, 0.98280185, 1.0], + [-1.4991432, 0.95262855, 1.0], + [-0.4551724, 0.9883049, 1.0], + [-0.03270482, 0.9987078, 1.0], + [0.0, 1.0, 1.0], + [-3.8461266, 0.9927924, 1.0], + [-1.276193, 0.98380685, 1.0], + [-0.1276723, 0.99596465, 1.0], + [-0.006702845, 0.9997529, 1.0], + [0.0, 1.0, 1.0], + [-2.713865, 0.98152876, 1.0], + [-0.75546634, 0.9879916, 1.0], + [-0.049746733, 0.9991844, 1.0], + [-0.0007843447, 0.9999856, 1.0], + [0.0, 1.0, 1.0], + [-3.2784758, 0.98056763, 1.0], + [-1.0423217, 0.98997813, 1.0], + [-0.050068997, 0.9994019, 1.0], + [-0.00034191704, 0.9999971, 1.0], + [0.0, 1.0, 1.0], + [-2.9297116, 0.96755266, 1.0], + [-0.5981317, 0.9923545, 1.0], + [-0.017654736, 0.999798, 1.0], + [-9.46762e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.1329167, 0.9680987, 1.0], + [-0.42209783, 0.9959619, 1.0], + [-0.012668612, 0.9998711, 1.0], + [-8.0989346e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-3.681753, 0.96301955, 1.0], + [-0.42579386, 0.9962166, 1.0], + [-0.00814088, 0.9999096, 1.0], + [-4.4159344e-05, 0.9999995, 1.0], + [0.0, 1.0, 1.0], + [-3.2585168, 0.9649381, 1.0], + [-0.30428812, 0.9962269, 1.0], + [-0.005310869, 0.999927, 1.0], + [-2.5505675e-05, 0.99999964, 1.0], + [0.0, 1.0, 1.0], + [-2.2021565, 0.9749354, 1.0], + [-0.3944129, 0.9904369, 1.0], + [-0.025527034, 0.9992587, 1.0], + [-0.00025198492, 0.9999939, 1.0], + [0.0, 1.0, 1.0], + [-1.929304, 0.971067, 1.0], + [-0.45261085, 0.99142736, 1.0], + [-0.05011053, 0.9990594, 1.0], + [-0.0008144145, 0.99998575, 1.0], + [0.0, 1.0, 1.0], + [-2.7514565, 0.9707775, 1.0], + [-0.364937, 0.9964542, 1.0], + [-0.020018931, 0.9997449, 1.0], + [-0.00028382998, 0.9999959, 1.0], + [0.0, 1.0, 1.0], + [-2.7956183, 0.9632996, 1.0], + [-0.42287976, 0.9969505, 1.0], + [-0.013087578, 0.9999144, 1.0], + [-8.082379e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [-2.4113169, 0.9547617, 1.0], + [-0.33804947, 0.9945204, 1.0], + [-0.026301317, 0.9995303, 1.0], + [-0.0012762488, 0.99997115, 1.0], + [0.0, 1.0, 1.0], + [-2.6816075, 0.95678437, 1.0], + [-0.44680336, 0.99332184, 1.0], + [-0.13794503, 0.99693584, 1.0], + [-0.024087088, 0.9994248, 1.0], + [0.0, 1.0, 1.0], + [-3.1641667, 0.9557707, 1.0], + [-0.37171954, 0.99482054, 1.0], + [-0.076554835, 0.99849296, 1.0], + [-0.012065996, 0.9997304, 1.0], + [0.0, 1.0, 1.0], + [-5.1045394, 0.91970074, 1.0], + [-0.6387288, 0.9893497, 1.0], + [-0.016694926, 0.9997221, 1.0], + [-0.0008751128, 0.99998766, 1.0], + [0.0, 1.0, 1.0], + [-6.9304733, 0.85944414, 1.0], + [-1.2397609, 0.9733297, 1.0], + [-0.039723754, 0.99928564, 1.0], + [-0.0009279736, 0.9999874, 1.0], + [0.0, 1.0, 1.0], + [-8.17317, 0.7962485, 1.0], + [-2.5968554, 0.9279736, 1.0], + [-0.06445126, 0.9980414, 1.0], + [-0.00037242688, 0.99999243, 1.0], + [0.0, 1.0, 1.0], + [-10.704245, 0.7364805, 1.0], + [-5.6246877, 0.8459256, 1.0], + [-0.26169717, 0.99058115, 1.0], + [-0.0014548219, 0.9999466, 1.0], + [0.0, 1.0, 1.0], + [-10.675443, 0.68334174, 1.0], + [-4.872382, 0.8478102, 1.0], + [-0.4881562, 0.98677355, 1.0], + [-0.009578875, 0.99971837, 1.0], + [0.0, 1.0, 1.0], + [-10.985089, 0.61614233, 1.0], + [-6.222056, 0.75582373, 1.0], + [-1.2719929, 0.92699444, 1.0], + [-0.07923371, 0.99300826, 1.0], + [0.0, 1.0, 1.0], + [-9.835739, 0.6035176, 1.0], + [-6.378802, 0.7114187, 1.0], + [-2.099836, 0.85796636, 1.0], + [-0.28848997, 0.9751244, 1.0], + [0.0, 1.0, 1.0], + [-6.6503634, 0.67487407, 1.0], + [-5.500912, 0.7487064, 1.0], + [-4.2971406, 0.8258655, 1.0], + [-0.6571109, 0.96886605, 1.0], + [0.0, 1.0, 1.0], + [-4.7830634, 0.73914504, 1.0], + [-4.141989, 0.79455334, 1.0], + [-5.006245, 0.8116664, 1.0], + [-1.3780211, 0.94300467, 1.0], + [0.0, 1.0, 1.0], + [-3.9277482, 0.75783825, 1.0], + [-2.659795, 0.83125865, 1.0], + [-2.5174627, 0.85422933, 1.0], + [-1.3893734, 0.9027223, 1.0], + [0.0, 1.0, 1.0], + [-2.7880778, 0.79374677, 1.0], + [-1.5678242, 0.8652207, 1.0], + [0.2077058, 0.92604816, 1.0], + [0.5667539, 0.94809216, 1.0], + [0.0, 1.0, 1.0], + [-1.7917917, 0.7941517, 1.0], + [-0.65741813, 0.895106, 1.0], + [0.75534123, 0.9829742, 1.0], + [0.4768409, 0.98621637, 1.0], + [0.0, 1.0, 1.0], + [-1.1052668, 0.73317593, 1.0], + [-0.6049394, 0.8575412, 1.0], + [-0.15478949, 0.99293196, 1.0], + [-0.25519162, 0.99671155, 1.0], + [0.0, 1.0, 1.0], + [1.3859553, 0.7583256, 1.0], + [0.3529042, 0.9013461, 1.0], + [-0.09280269, 0.99014723, 1.0], + [-0.19439623, 0.9756913, 1.0], + [0.0, 1.0, 1.0], + [2.662148, 0.78581506, 1.0], + [1.3001701, 0.8985178, 1.0], + [0.46722722, 0.9815502, 1.0], + [0.23440441, 0.98131305, 1.0], + [0.0, 1.0, 1.0], + [2.7134244, 0.7862562, 1.0], + [1.8330814, 0.8773475, 1.0], + [0.75808954, 0.9856246, 1.0], + [0.15871423, 0.9977693, 1.0], + [0.0, 1.0, 1.0], + [3.707434, 0.78545547, 1.0], + [2.0999856, 0.88736033, 1.0], + [0.21350783, 0.9968301, 1.0], + [0.0005532017, 1.0001154, 1.0], + [0.0, 1.0, 1.0], + [4.2265773, 0.7976756, 1.0], + [2.41513, 0.9033128, 1.0], + [0.098965846, 0.99737674, 1.0], + [-0.0019273323, 1.0000342, 1.0], + [0.0, 1.0, 1.0], + [3.586104, 0.8242546, 1.0], + [1.1944336, 0.94521993, 1.0], + [0.018398829, 0.999946, 1.0], + [0.0061938893, 1.00001, 1.0], + [0.0, 1.0, 1.0], + [3.3312273, 0.85412824, 1.0], + [0.8018384, 0.96504784, 1.0], + [-0.038536284, 1.0002738, 1.0], + [0.0041213674, 1.0000454, 1.0], + [0.0, 1.0, 1.0], + [3.5602465, 0.86023563, 1.0], + [0.8536984, 0.97106135, 1.0], + [-0.06325424, 1.0002065, 1.0], + [-0.009787895, 1.0001173, 1.0], + [0.0, 1.0, 1.0], + [3.319309, 0.88970965, 1.0], + [0.4574435, 0.98576313, 1.0], + [0.0016855623, 0.9999831, 1.0], + [-0.000406475, 1.0000062, 1.0], + [0.0, 1.0, 1.0], + [2.0391257, 0.92719066, 1.0], + [-0.11533654, 0.9924606, 1.0], + [-0.022894483, 1.0000801, 1.0], + [-3.8161572e-05, 1.0000013, 1.0], + [0.0, 1.0, 1.0], + [2.221341, 0.9522132, 1.0], + [0.16617404, 0.99605083, 1.0], + [-0.0031928865, 0.999991, 1.0], + [1.2969534e-05, 1.0000004, 1.0], + [0.0, 1.0, 1.0], + [2.2095737, 0.94623184, 1.0], + [0.2084119, 0.9924171, 1.0], + [-0.0017147859, 0.999864, 1.0], + [0.00015271064, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [1.7305284, 0.92747825, 1.0], + [0.26084626, 0.99035066, 1.0], + [0.002197185, 0.9998796, 1.0], + [0.0002519184, 1.0, 1.0], + [0.0, 1.0, 1.0], + [1.83777, 0.9303522, 1.0], + [0.49865678, 0.99259496, 1.0], + [0.010380797, 0.99993306, 1.0], + [1.543077e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.6540057, 0.93718725, 1.0], + [-0.056169994, 0.98687667, 1.0], + [-0.009431662, 0.9991736, 1.0], + [0.00055399764, 0.9999875, 1.0], + [0.0, 1.0, 1.0], + [1.219968, 0.9170335, 1.0], + [-1.04431, 0.9552193, 1.0], + [-0.7334884, 0.9844318, 1.0], + [-0.067632034, 0.9988193, 1.0], + [0.0, 1.0, 1.0], + [1.3329756, 0.9103736, 1.0], + [-4.453365, 0.87807673, 1.0], + [-7.3110423, 0.86146367, 1.0], + [-1.0087034, 0.9803414, 1.0], + [0.0, 1.0, 1.0], + [0.96455824, 0.8981043, 1.0], + [-5.200631, 0.8460088, 1.0], + [-11.297041, 0.7854038, 1.0], + [-2.6635964, 0.9481161, 1.0], + [0.0, 1.0, 1.0], + [0.67527205, 0.9010675, 1.0], + [-6.0442433, 0.8281746, 1.0], + [-12.542688, 0.7473031, 1.0], + [-5.324665, 0.8921913, 1.0], + [0.0, 1.0, 1.0], + [1.2026789, 0.89397043, 1.0], + [-4.445598, 0.84913737, 1.0], + [-11.919539, 0.74464583, 1.0], + [-6.795066, 0.85686606, 1.0], + [0.0, 1.0, 1.0], + [0.6662235, 0.8867006, 1.0], + [-3.937991, 0.83842826, 1.0], + [-13.422844, 0.71948135, 1.0], + [-8.835538, 0.8229743, 1.0], + [0.0, 1.0, 1.0], + [0.7172698, 0.8660123, 1.0], + [-0.94286054, 0.8551379, 1.0], + [-5.286918, 0.83392274, 1.0], + [-2.4993267, 0.9417325, 1.0], + [0.0, 1.0, 1.0], + [0.0017951994, 0.9914302, 1.0], + [-0.06927332, 0.9921463, 1.0], + [-0.057490297, 0.9959994, 1.0], + [-0.008928111, 0.99963653, 1.0], + [0.0, 1.0, 1.0], + [-0.041254885, 0.98150927, 1.0], + [-0.1306019, 0.97009796, 1.0], + [-0.048590906, 0.9860053, 1.0], + [0.0030063766, 0.99852145, 1.0], + [0.0, 1.0, 1.0], + [-0.112703465, 0.95625734, 1.0], + [-0.2643623, 0.9157722, 1.0], + [0.17177379, 0.9519122, 1.0], + [0.89937246, 0.95642823, 1.0], + [0.0, 1.0, 1.0], + [0.29111728, 0.94092214, 1.0], + [0.3868302, 0.90032035, 1.0], + [3.1862643, 0.84815085, 1.0], + [6.1715746, 0.7603379, 1.0], + [0.0, 1.0, 1.0], + [0.538952, 0.9446369, 1.0], + [0.83218, 0.90219474, 1.0], + [3.4132833, 0.844821, 1.0], + [6.4263897, 0.7552901, 1.0], + [0.0, 1.0, 1.0], + [0.90486366, 0.9366256, 1.0], + [1.3550385, 0.9041044, 1.0], + [4.7555075, 0.83666027, 1.0], + [9.834919, 0.7355414, 1.0], + [0.0, 1.0, 1.0], + [0.749637, 0.92996854, 1.0], + [1.2060188, 0.9036133, 1.0], + [10.243389, 0.71855605, 1.0], + [15.236945, 0.6319401, 1.0], + [0.0, 1.0, 1.0], + [0.38263044, 0.9178865, 1.0], + [1.6851017, 0.8404756, 1.0], + [6.062546, 0.71273714, 1.0], + [7.5150943, 0.7055973, 1.0], + [0.0, 1.0, 1.0], + [0.2073275, 0.8944352, 1.0], + [1.694259, 0.7952898, 1.0], + [4.963602, 0.71330947, 1.0], + [4.4856033, 0.76623136, 1.0], + [0.0, 1.0, 1.0], + [0.42934892, 0.91428727, 1.0], + [1.7023742, 0.81033534, 1.0], + [4.653338, 0.67880553, 1.0], + [4.1607633, 0.7217924, 1.0], + [0.0, 1.0, 1.0], + [0.7662586, 0.92551315, 1.0], + [1.281609, 0.8594126, 1.0], + [4.2112913, 0.7075525, 1.0], + [3.2996652, 0.7721644, 1.0], + [0.0, 1.0, 1.0], + [0.36315376, 0.96427274, 1.0], + [0.17142291, 0.9671226, 1.0], + [0.3704626, 0.96229166, 1.0], + [0.16843563, 0.98521745, 1.0], + [0.0, 1.0, 1.0], + [0.14203277, 0.980844, 1.0], + [0.08888949, 0.98635364, 1.0], + [0.08142525, 0.99080217, 1.0], + [0.04689812, 0.99329925, 1.0], + [0.0, 1.0, 1.0], + [-0.041995242, 0.97444767, 1.0], + [-0.015486327, 0.9832361, 1.0], + [0.025345497, 0.9886648, 1.0], + [0.061056096, 0.9832061, 1.0], + [0.0, 1.0, 1.0], + [-0.06349996, 0.9847081, 1.0], + [-0.010158759, 0.993606, 1.0], + [0.0070079323, 0.9965746, 1.0], + [0.012292187, 0.9953988, 1.0], + [0.0, 1.0, 1.0], + [-0.02714656, 0.99495846, 1.0], + [-0.0047707465, 0.995372, 1.0], + [-0.011022978, 0.9921308, 1.0], + [-0.0004924384, 0.99648327, 1.0], + [0.0, 1.0, 1.0], + [-0.065880194, 0.99450415, 1.0], + [-0.041692242, 0.99075663, 1.0], + [-0.022810183, 0.9813757, 1.0], + [0.0066275946, 0.9910071, 1.0], + [0.0, 1.0, 1.0], + [-0.051800754, 0.9933403, 1.0], + [-0.031969808, 0.9909193, 1.0], + [-0.0138383275, 0.98399764, 1.0], + [0.0023257341, 0.99193186, 1.0], + [0.0, 1.0, 1.0], + [-0.064492755, 0.99565995, 1.0], + [-0.056850635, 0.993179, 1.0], + [-0.03568528, 0.9925555, 1.0], + [-0.006500367, 0.9941837, 1.0], + [0.0, 1.0, 1.0], + [-0.076604635, 0.9975212, 1.0], + [-0.04835303, 0.99697053, 1.0], + [-0.05276575, 0.9912819, 1.0], + [-0.048627503, 0.98763776, 1.0], + [0.0, 1.0, 1.0], + [-0.07185075, 0.99944353, 1.0], + [-0.049465895, 0.99844307, 1.0], + [-0.03390795, 0.9961937, 1.0], + [-0.01572451, 0.99718726, 1.0], + [0.0, 1.0, 1.0], + [-0.06696204, 0.99883705, 1.0], + [-0.059485506, 0.99909484, 1.0], + [-0.020033833, 0.99961084, 1.0], + [-0.001215999, 0.9999084, 1.0], + [0.0, 1.0, 1.0], + [-0.06329192, 0.9984661, 1.0], + [-0.030843416, 0.99811184, 1.0], + [-0.010421576, 0.99985075, 1.0], + [-0.000277232, 0.99999887, 1.0], + [0.0, 1.0, 1.0], + [-0.09967728, 0.9996282, 1.0], + [-0.024422867, 0.9996509, 1.0], + [-0.0013948235, 0.9999416, 1.0], + [-1.7647946e-05, 0.99999917, 1.0], + [0.0, 1.0, 1.0], + [-0.061887965, 0.9996889, 1.0], + [-0.0055153067, 0.9999299, 1.0], + [-0.00014356381, 0.9999968, 1.0], + [-7.3728444e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.06313205, 0.9991679, 1.0], + [-0.0105344625, 0.99976623, 1.0], + [-0.00022694842, 0.99999404, 1.0], + [-6.2425016e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.07309622, 0.998452, 1.0], + [-0.010137941, 0.99966383, 1.0], + [-0.00015650428, 0.999994, 1.0], + [-2.0530616e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.12139271, 0.99886787, 1.0], + [-0.006217619, 0.9999299, 1.0], + [-2.767876e-05, 0.9999996, 1.0], + [-2.8060315e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11297469, 0.9986093, 1.0], + [-0.005666369, 0.99993217, 1.0], + [-2.3441991e-05, 0.99999976, 1.0], + [-1.6374731e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11892667, 0.9984859, 1.0], + [-0.0054892004, 0.9999281, 1.0], + [-1.8891958e-05, 0.9999997, 1.0], + [-1.42802365e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.063609995, 0.9991952, 1.0], + [-0.003612312, 0.9999425, 1.0], + [-5.6865858e-05, 0.99999845, 1.0], + [-2.8125933e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.03687637, 0.99945694, 1.0], + [-0.0022629017, 0.99996173, 1.0], + [-4.7338475e-05, 0.9999989, 1.0], + [-3.8873702e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.066277154, 0.9989287, 1.0], + [-0.0041808677, 0.9999447, 1.0], + [-2.6149986e-05, 0.9999997, 1.0], + [-7.8076916e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.18022063, 0.99584043, 1.0], + [-0.009360226, 0.99979895, 1.0], + [-4.573966e-05, 0.99999934, 1.0], + [-4.968432e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.057497833, 0.9985912, 1.0], + [-0.0030033966, 0.99992764, 1.0], + [-2.1283115e-05, 0.9999996, 1.0], + [-7.399287e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.02698668, 0.99940234, 1.0], + [-0.0018560152, 0.9999619, 1.0], + [-0.00011015628, 0.9999977, 1.0], + [-1.6312291e-05, 0.9999996, 1.0], + [0.0, 1.0, 1.0], + [-0.020353582, 0.99962854, 1.0], + [-0.0012829889, 0.99997663, 1.0], + [-4.823795e-05, 0.999999, 1.0], + [-7.425042e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.046696484, 0.9990762, 1.0], + [-0.0027994127, 0.999945, 1.0], + [-1.47203e-05, 0.9999997, 1.0], + [-1.4775334e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.11455002, 0.9970088, 1.0], + [-0.008082145, 0.9997811, 1.0], + [-5.997455e-05, 0.9999983, 1.0], + [-2.153083e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.20616151, 0.9932033, 1.0], + [-0.026683627, 0.9989487, 1.0], + [-0.0006210741, 0.9999699, 1.0], + [-1.9223974e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.42821977, 0.98413795, 1.0], + [-0.09130513, 0.9955624, 1.0], + [-0.0039294222, 0.99976504, 1.0], + [-1.938189e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.737228, 0.9656451, 1.0], + [-0.11107813, 0.99402, 1.0], + [-0.005023841, 0.9997979, 1.0], + [-7.540838e-05, 0.9999977, 1.0], + [0.0, 1.0, 1.0], + [-2.0423653, 0.89530706, 1.0], + [-0.39499432, 0.9775829, 1.0], + [-0.021451676, 0.9990429, 1.0], + [-0.00062327104, 0.9999673, 1.0], + [0.0, 1.0, 1.0], + [-4.7882614, 0.7621558, 1.0], + [-1.4943185, 0.919121, 1.0], + [-0.26310703, 0.9902079, 1.0], + [-0.026021129, 0.9991246, 1.0], + [0.0, 1.0, 1.0], + [-5.967163, 0.70525557, 1.0], + [-6.2708335, 0.7825963, 1.0], + [-3.0123074, 0.9135249, 1.0], + [-0.16591884, 0.99503696, 1.0], + [0.0, 1.0, 1.0], + [-6.747682, 0.693125, 1.0], + [-11.015906, 0.6835952, 1.0], + [-10.588246, 0.7426725, 1.0], + [-1.4011235, 0.96407425, 1.0], + [0.0, 1.0, 1.0], + [-6.8046284, 0.6869158, 1.0], + [-12.226663, 0.67317563, 1.0], + [-14.401511, 0.68768495, 1.0], + [-5.656612, 0.8701306, 1.0], + [0.0, 1.0, 1.0], + [-5.2980156, 0.7554591, 1.0], + [-8.451796, 0.7457393, 1.0], + [-10.891963, 0.76741225, 1.0], + [-4.9737005, 0.8988167, 1.0], + [0.0, 1.0, 1.0], + [-3.7862368, 0.7663066, 1.0], + [-6.968383, 0.74598277, 1.0], + [-7.1698184, 0.82501894, 1.0], + [-3.5572019, 0.9294954, 1.0], + [0.0, 1.0, 1.0], + [-2.0710254, 0.80158305, 1.0], + [-2.644557, 0.82318443, 1.0], + [-2.829198, 0.9057661, 1.0], + [-3.1149986, 0.90927655, 1.0], + [0.0, 1.0, 1.0], + [0.5783251, 0.8887635, 1.0], + [0.14086528, 0.92088306, 1.0], + [-1.3454764, 0.91390127, 1.0], + [-2.1663988, 0.89477795, 1.0], + [0.0, 1.0, 1.0], + [0.90967906, 0.9097722, 1.0], + [0.40966874, 0.96211725, 1.0], + [0.012563121, 0.9758351, 1.0], + [-0.12836997, 0.9668173, 1.0], + [0.0, 1.0, 1.0], + [0.7635332, 0.9177408, 1.0], + [0.26205018, 0.9728322, 1.0], + [0.017903045, 0.99900806, 1.0], + [0.0027794365, 0.9998005, 1.0], + [0.0, 1.0, 1.0], + [0.70256805, 0.9528801, 1.0], + [0.12026947, 0.989671, 1.0], + [0.0014969448, 0.9997942, 1.0], + [4.7565347e-05, 0.99999905, 1.0], + [0.0, 1.0, 1.0], + [0.43385658, 0.97707945, 1.0], + [0.08405786, 0.99513173, 1.0], + [0.001577933, 0.99989617, 1.0], + [2.1989558e-06, 0.99999976, 1.0], + [0.0, 1.0, 1.0], + [0.17016695, 0.990982, 1.0], + [0.020933557, 0.99893415, 1.0], + [0.00020579397, 0.99998814, 1.0], + [1.8093911e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.118497156, 0.9939799, 1.0], + [0.009560065, 0.99949676, 1.0], + [-6.1264276e-05, 0.99999744, 1.0], + [-7.5626076e-06, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.18632784, 0.9930626, 1.0], + [0.016471203, 0.9994041, 1.0], + [1.850781e-05, 0.99999624, 1.0], + [-1.3992527e-05, 1.0000001, 1.0], + [0.0, 1.0, 1.0], + [0.12490073, 0.99491405, 1.0], + [0.009683974, 0.9996138, 1.0], + [5.5344124e-05, 0.9999977, 1.0], + [-6.122279e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.046315443, 0.9974161, 1.0], + [0.0011095641, 0.99975616, 1.0], + [-9.514568e-05, 0.9999985, 1.0], + [-4.2969023e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.06989265, 0.99835086, 1.0], + [0.0046474864, 0.9999081, 1.0], + [8.086032e-06, 0.99999964, 1.0], + [-6.4283086e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.05445273, 0.9984405, 1.0], + [0.0056540556, 0.9999072, 1.0], + [3.1231557e-05, 0.99999934, 1.0], + [-1.4891012e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.049770087, 0.9989236, 1.0], + [0.0066248314, 0.9999419, 1.0], + [5.813844e-05, 0.99999964, 1.0], + [3.4580864e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.011926558, 0.9986787, 1.0], + [0.0030024045, 0.99992526, 1.0], + [5.3523254e-05, 0.9999996, 1.0], + [7.105971e-08, 1.0, 1.0], + [0.0, 1.0, 1.0], + [0.0051469407, 0.99917525, 1.0], + [-0.0015877569, 0.99988997, 1.0], + [-0.0019601185, 0.99996835, 1.0], + [-0.00019134225, 0.9999972, 1.0], + [0.0, 1.0, 1.0], + [-0.0026346056, 0.9979655, 1.0], + [-0.6399264, 0.9890465, 1.0], + [-1.0591722, 0.9829664, 1.0], + [-0.1191338, 0.9980818, 1.0], + [0.0, 1.0, 1.0], + [-0.33427265, 0.9909866, 1.0], + [-6.1553082, 0.8892826, 1.0], + [-8.770668, 0.85096085, 1.0], + [-1.2606072, 0.9774506, 1.0], + [0.0, 1.0, 1.0], + [-0.47517955, 0.9840748, 1.0], + [-8.44668, 0.82929236, 1.0], + [-13.877107, 0.7396451, 1.0], + [-3.6484365, 0.9244635, 1.0], + [0.0, 1.0, 1.0], + [-0.47713858, 0.9821221, 1.0], + [-11.744733, 0.7621532, 1.0], + [-19.645943, 0.6432617, 1.0], + [-8.809313, 0.82319057, 1.0], + [0.0, 1.0, 1.0], + [-0.22001219, 0.98877585, 1.0], + [-9.559413, 0.79853284, 1.0], + [-19.126335, 0.6495684, 1.0], + [-10.234527, 0.7984638, 1.0], + [0.0, 1.0, 1.0], + [-0.15887243, 0.9931774, 1.0], + [-6.930633, 0.85245496, 1.0], + [-16.76443, 0.69196665, 1.0], + [-9.0214, 0.82428676, 1.0], + [0.0, 1.0, 1.0], + [-0.016224792, 0.9942058, 1.0], + [-0.8251402, 0.9754959, 1.0], + [-3.2789445, 0.9271614, 1.0], + [-1.1487821, 0.97528994, 1.0] + ], + "srgb_value": true + } +} diff --git a/assets/camera-profiles/NIKON_Z_8.json b/assets/camera-profiles/NIKON_Z_8.json new file mode 100644 index 000000000..0c63c0d84 --- /dev/null +++ b/assets/camera-profiles/NIKON_Z_8.json @@ -0,0 +1,1819 @@ +{ + "version": 1, + "model": "NIKON Z 8", + "files": 261, + "samples": 969860, + "matrix": [ + [1.0990362785046823, -0.20725005493106963, 0.10268209021537977], + [-0.029117321941279695, 1.1458550830734442, -0.11855469779814065], + [-0.10582269960146817, -0.749496743582382, 1.900598651063195] + ], + "hue_sat": { + "hue_divisions": 72, + "sat_divisions": 5, + "val_divisions": 5, + "data": [ + [0.0, 1.0, 1.0], + [7.111473, 1.181401, 1.0], + [2.9113195, 1.1633477, 1.0], + [1.8994527, 1.137069, 1.0], + [0.53534466, 1.0345078, 1.0], + [0.0, 1.0, 1.0], + [6.794022, 1.1620317, 1.0], + [1.6603756, 1.1454334, 1.0], + [0.65508085, 1.131233, 1.0], + [0.18744454, 1.0536848, 1.0], + [0.0, 1.0, 1.0], + [6.2740474, 1.1378076, 1.0], + [0.39785913, 1.126624, 1.0], + [-1.5408971, 1.0759563, 1.0], + [-1.7140917, 1.0247983, 1.0], + [0.0, 1.0, 1.0], + [5.2532353, 1.1126455, 1.0], + [-0.7449979, 1.1205007, 1.0], + [-3.0170422, 1.0559602, 1.0], + [-3.354577, 0.9959453, 1.0], + [0.0, 1.0, 1.0], + [4.8965535, 1.0955226, 1.0], + [-0.70895463, 1.1094975, 1.0], + [-3.1364403, 1.0476824, 1.0], + [-4.1989164, 0.96736634, 1.0], + [0.0, 1.0, 1.0], + [3.7045963, 1.0627788, 1.0], + [-1.3298069, 1.0956368, 1.0], + [-2.823391, 1.0708525, 1.0], + [-2.8166869, 1.0153031, 1.0], + [0.0, 1.0, 1.0], + [2.7337725, 1.037307, 1.0], + [-1.8260294, 1.0655074, 1.0], + [-3.5580583, 1.0488383, 1.0], + [-4.437229, 1.0113343, 1.0], + [0.0, 1.0, 1.0], + [2.3106112, 1.0036124, 1.0], + [-2.1256907, 1.0205824, 1.0], + [-4.961178, 1.0156583, 1.0], + [-6.710935, 0.9911362, 1.0], + [0.0, 1.0, 1.0], + [1.3536092, 0.9934402, 1.0], + [-2.5290065, 0.99898964, 1.0], + [-5.4174004, 0.9998236, 1.0], + [-6.597085, 0.9757822, 1.0], + [0.0, 1.0, 1.0], + [1.1366612, 1.002107, 1.0], + [-2.4894423, 0.9997597, 1.0], + [-5.4894304, 0.9861238, 1.0], + [-7.5021935, 0.92478454, 1.0], + [0.0, 1.0, 1.0], + [1.5145179, 1.021503, 1.0], + [-1.5998987, 1.0070803, 1.0], + [-4.508984, 0.9658881, 1.0], + [-7.158313, 0.89098585, 1.0], + [0.0, 1.0, 1.0], + [1.5639887, 1.0445297, 1.0], + [-0.915344, 1.0133737, 1.0], + [-3.1283567, 0.9622828, 1.0], + [-5.1986914, 0.8836733, 1.0], + [0.0, 1.0, 1.0], + [1.1355672, 1.0603209, 1.0], + [-0.56755894, 1.0115352, 1.0], + [-1.8851622, 0.95658684, 1.0], + [-3.4127562, 0.8781371, 1.0], + [0.0, 1.0, 1.0], + [0.5467292, 1.0580485, 1.0], + [-0.41302148, 1.0018384, 1.0], + [-1.0522548, 0.94771504, 1.0], + [-1.7539524, 0.876335, 1.0], + [0.0, 1.0, 1.0], + [-0.0725913, 1.0581536, 1.0], + [-0.4528266, 0.9990066, 1.0], + [-0.57961255, 0.9448505, 1.0], + [-0.5526036, 0.8833151, 1.0], + [0.0, 1.0, 1.0], + [-0.46772903, 1.0652338, 1.0], + [-0.02375252, 1.0044563, 1.0], + [0.43251258, 0.94911736, 1.0], + [0.87126744, 0.89478797, 1.0], + [0.0, 1.0, 1.0], + [0.16140896, 1.0854058, 1.0], + [1.07174, 1.0152693, 1.0], + [1.7586585, 0.95322454, 1.0], + [2.3524222, 0.8983894, 1.0], + [0.0, 1.0, 1.0], + [0.2469127, 1.1031706, 1.0], + [1.1097546, 1.0245277, 1.0], + [1.8333874, 0.96286947, 1.0], + [2.6193652, 0.9074495, 1.0], + [0.0, 1.0, 1.0], + [-0.6874714, 1.1182649, 1.0], + [0.16355518, 1.0515357, 1.0], + [1.1668121, 0.98603916, 1.0], + [2.3031707, 0.9206025, 1.0], + [0.0, 1.0, 1.0], + [-2.0503242, 1.1499335, 1.0], + [-1.1552397, 1.0879966, 1.0], + [-0.10152792, 1.0231872, 1.0], + [1.0718579, 0.95476115, 1.0], + [0.0, 1.0, 1.0], + [-3.8791447, 1.188533, 1.0], + [-2.9817088, 1.1327138, 1.0], + [-1.7755328, 1.0618997, 1.0], + [-0.21666291, 0.94862247, 1.0], + [0.0, 1.0, 1.0], + [-5.8841834, 1.226612, 1.0], + [-4.98382, 1.1740679, 1.0], + [-3.1025357, 1.0698453, 1.0], + [-0.9934275, 0.9242727, 1.0], + [0.0, 1.0, 1.0], + [-8.169295, 1.2657825, 1.0], + [-7.0660734, 1.2132481, 1.0], + [-3.1967385, 1.0559258, 1.0], + [-0.6672474, 0.97608167, 1.0], + [0.0, 1.0, 1.0], + [-10.515467, 1.3097212, 1.0], + [-8.952165, 1.2460706, 1.0], + [-2.6956723, 1.0394313, 1.0], + [-0.24548055, 0.9968061, 1.0], + [0.0, 1.0, 1.0], + [-13.161258, 1.348111, 1.0], + [-10.267992, 1.2503926, 1.0], + [-1.3459312, 1.0174122, 1.0], + [-0.040196028, 1.0000353, 1.0], + [0.0, 1.0, 1.0], + [-15.790122, 1.339094, 1.0], + [-11.393451, 1.2008783, 1.0], + [-2.688318, 1.0172836, 1.0], + [-0.20984244, 1.0009907, 1.0], + [0.0, 1.0, 1.0], + [-18.627531, 1.308051, 1.0], + [-12.985201, 1.160609, 1.0], + [-2.6413164, 1.014484, 1.0], + [-0.1162438, 1.0004718, 1.0], + [0.0, 1.0, 1.0], + [-19.562479, 1.2632611, 1.0], + [-12.712911, 1.108189, 1.0], + [-2.095739, 1.0048033, 1.0], + [-0.04567434, 0.99998564, 1.0], + [0.0, 1.0, 1.0], + [-20.291159, 1.2343935, 1.0], + [-12.380699, 1.0716544, 1.0], + [-1.7035112, 0.9987354, 1.0], + [-0.026139155, 0.9999211, 1.0], + [0.0, 1.0, 1.0], + [-19.989433, 1.200086, 1.0], + [-11.1118, 1.0480033, 1.0], + [-1.400926, 0.996738, 1.0], + [-0.024835525, 0.9998831, 1.0], + [0.0, 1.0, 1.0], + [-19.566994, 1.17641, 1.0], + [-10.257852, 1.0363005, 1.0], + [-1.2149851, 0.99804825, 1.0], + [-0.015627315, 0.9999487, 1.0], + [0.0, 1.0, 1.0], + [-18.913862, 1.134827, 1.0], + [-10.370755, 1.0167582, 1.0], + [-1.6505148, 0.99528724, 1.0], + [-0.026268613, 0.9998695, 1.0], + [0.0, 1.0, 1.0], + [-18.999414, 1.1098753, 1.0], + [-9.642386, 1.0115949, 1.0], + [-1.2675271, 0.9941188, 1.0], + [-0.018203475, 0.99986815, 1.0], + [0.0, 1.0, 1.0], + [-17.586956, 1.091192, 1.0], + [-8.722237, 1.0048285, 1.0], + [-1.1602849, 0.9960165, 1.0], + [-0.013571129, 0.9999434, 1.0], + [0.0, 1.0, 1.0], + [-16.729242, 1.0722094, 1.0], + [-9.583627, 0.9960505, 1.0], + [-2.039792, 0.9939141, 1.0], + [-0.04050307, 0.99985063, 1.0], + [0.0, 1.0, 1.0], + [-16.906395, 1.075484, 1.0], + [-8.840361, 1.0053103, 1.0], + [-1.4046353, 0.9954329, 1.0], + [-0.031679757, 0.99983394, 1.0], + [0.0, 1.0, 1.0], + [-17.531858, 1.0629116, 1.0], + [-8.608148, 0.9959869, 1.0], + [-1.1416123, 0.99636537, 1.0], + [-0.013378057, 0.99994534, 1.0], + [0.0, 1.0, 1.0], + [-17.99668, 1.0072999, 1.0], + [-10.570478, 0.9696376, 1.0], + [-1.9599729, 0.9910149, 1.0], + [-0.022619616, 0.99988747, 1.0], + [0.0, 1.0, 1.0], + [-18.86213, 0.96442, 1.0], + [-11.836861, 0.9444823, 1.0], + [-2.469638, 0.984263, 1.0], + [-0.030691203, 0.99978447, 1.0], + [0.0, 1.0, 1.0], + [-20.236654, 0.90864915, 1.0], + [-13.107022, 0.9121311, 1.0], + [-2.9203506, 0.97508854, 1.0], + [-0.041130785, 0.9996122, 1.0], + [0.0, 1.0, 1.0], + [-20.714214, 0.8536292, 1.0], + [-14.74853, 0.8750625, 1.0], + [-3.946734, 0.9621585, 1.0], + [-0.05774192, 0.9994195, 1.0], + [0.0, 1.0, 1.0], + [-20.098251, 0.80836505, 1.0], + [-14.476766, 0.851234, 1.0], + [-5.6719704, 0.9378101, 1.0], + [-0.12513989, 0.9985625, 1.0], + [0.0, 1.0, 1.0], + [-19.542671, 0.76297987, 1.0], + [-13.628136, 0.83560556, 1.0], + [-7.0074353, 0.91483426, 1.0], + [-0.28871387, 0.99629575, 1.0], + [0.0, 1.0, 1.0], + [-18.084225, 0.74823433, 1.0], + [-12.162158, 0.84476614, 1.0], + [-6.7999973, 0.9135009, 1.0], + [-0.40371415, 0.9944375, 1.0], + [0.0, 1.0, 1.0], + [-16.452961, 0.72636646, 1.0], + [-9.328942, 0.86091775, 1.0], + [-4.9608183, 0.9375208, 1.0], + [-0.27671492, 0.9964092, 1.0], + [0.0, 1.0, 1.0], + [-13.168701, 0.74453783, 1.0], + [-6.218363, 0.90167975, 1.0], + [-3.7400405, 0.95979875, 1.0], + [-0.46412376, 0.995171, 1.0], + [0.0, 1.0, 1.0], + [-10.4688425, 0.77134514, 1.0], + [-5.7574325, 0.8950614, 1.0], + [-3.034646, 0.9650316, 1.0], + [-0.36110362, 0.9969887, 1.0], + [0.0, 1.0, 1.0], + [-7.514723, 0.7996464, 1.0], + [-3.7689729, 0.9172442, 1.0], + [-0.9782931, 1.0084524, 1.0], + [0.31402144, 1.0211289, 1.0], + [0.0, 1.0, 1.0], + [-5.6537147, 0.8334752, 1.0], + [-2.504266, 0.96418613, 1.0], + [-0.07010336, 1.052774, 1.0], + [0.894411, 1.0569643, 1.0], + [0.0, 1.0, 1.0], + [-4.493737, 0.7882688, 1.0], + [-2.3410633, 0.936153, 1.0], + [-0.32673165, 1.0513396, 1.0], + [0.49912313, 1.0484643, 1.0], + [0.0, 1.0, 1.0], + [-1.7034019, 0.73160046, 1.0], + [-0.831635, 0.8589306, 1.0], + [-0.3882015, 1.0134665, 1.0], + [-0.18704136, 1.0146226, 1.0], + [0.0, 1.0, 1.0], + [0.61953825, 0.7258855, 1.0], + [0.81030744, 0.8431724, 1.0], + [0.00040164185, 1.0047435, 1.0], + [-0.10240378, 1.0050768, 1.0], + [0.0, 1.0, 1.0], + [2.0624804, 0.7425685, 1.0], + [1.5886667, 0.8645959, 1.0], + [0.4176404, 1.0037674, 1.0], + [-0.00398716, 1.0012411, 1.0], + [0.0, 1.0, 1.0], + [3.623457, 0.7510747, 1.0], + [1.7844758, 0.885587, 1.0], + [-0.036421247, 1.0089039, 1.0], + [-0.020441405, 1.001009, 1.0], + [0.0, 1.0, 1.0], + [4.7174945, 0.7708686, 1.0], + [3.2143273, 0.90923154, 1.0], + [0.25317824, 1.0065747, 1.0], + [-0.016773207, 1.0006565, 1.0], + [0.0, 1.0, 1.0], + [5.9824314, 0.7961609, 1.0], + [3.939417, 0.94614726, 1.0], + [0.7240807, 1.0062363, 1.0], + [0.030703958, 1.0001671, 1.0], + [0.0, 1.0, 1.0], + [7.3545732, 0.82526684, 1.0], + [4.982609, 0.9743153, 1.0], + [1.4535582, 1.0078721, 1.0], + [0.062866114, 1.0002259, 1.0], + [0.0, 1.0, 1.0], + [7.4568152, 0.8617716, 1.0], + [3.6539178, 1.0011972, 1.0], + [0.9039327, 1.0089482, 1.0], + [0.03915892, 1.000176, 1.0], + [0.0, 1.0, 1.0], + [7.3634186, 0.91159344, 1.0], + [3.4343126, 1.0100657, 1.0], + [0.695478, 1.010742, 1.0], + [0.03255169, 1.0004065, 1.0], + [0.0, 1.0, 1.0], + [7.238165, 0.9486031, 1.0], + [4.089919, 1.0266953, 1.0], + [1.1045972, 1.0186851, 1.0], + [0.057033073, 1.0010694, 1.0], + [0.0, 1.0, 1.0], + [7.0543814, 0.9770822, 1.0], + [3.9128, 1.0336262, 1.0], + [1.1944752, 1.0113511, 1.0], + [0.07083795, 1.0004443, 1.0], + [0.0, 1.0, 1.0], + [8.143442, 0.99413896, 1.0], + [4.040843, 1.0496417, 1.0], + [0.8080747, 1.0116086, 1.0], + [0.042427447, 0.99993575, 1.0], + [0.0, 1.0, 1.0], + [7.18577, 0.9783746, 1.0], + [3.2767346, 1.0421677, 1.0], + [1.2492384, 1.016086, 1.0], + [0.15854485, 1.0005085, 1.0], + [0.0, 1.0, 1.0], + [7.194208, 1.0016203, 1.0], + [3.0112083, 1.0349026, 1.0], + [0.91562295, 1.0119883, 1.0], + [0.0654759, 1.0005552, 1.0], + [0.0, 1.0, 1.0], + [7.414966, 0.998843, 1.0], + [3.1521475, 1.0262057, 1.0], + [0.9972541, 1.0102346, 1.0], + [0.060677312, 1.0006106, 1.0], + [0.0, 1.0, 1.0], + [9.276159, 0.9916557, 1.0], + [4.375442, 1.0329365, 1.0], + [1.5409418, 1.015073, 1.0], + [0.13645935, 1.0012853, 1.0], + [0.0, 1.0, 1.0], + [9.8098955, 1.0221618, 1.0], + [5.381683, 1.0610034, 1.0], + [2.1655529, 1.0283315, 1.0], + [0.15032019, 1.0015818, 1.0], + [0.0, 1.0, 1.0], + [8.995072, 1.0311706, 1.0], + [5.2788897, 1.0783035, 1.0], + [2.6601613, 1.0430328, 1.0], + [0.23225841, 1.0026511, 1.0], + [0.0, 1.0, 1.0], + [10.116063, 1.0506214, 1.0], + [6.2658486, 1.0895245, 1.0], + [3.640211, 1.0535048, 1.0], + [0.39292678, 1.0040704, 1.0], + [0.0, 1.0, 1.0], + [9.800368, 1.0582323, 1.0], + [6.5737424, 1.0822098, 1.0], + [6.3034444, 1.0650462, 1.0], + [1.6771196, 1.0111806, 1.0], + [0.0, 1.0, 1.0], + [9.205544, 1.097704, 1.0], + [5.412633, 1.1020687, 1.0], + [4.3975163, 1.0531521, 1.0], + [1.2703937, 1.0038551, 1.0], + [0.0, 1.0, 1.0], + [8.4540615, 1.1362739, 1.0], + [4.478308, 1.1312087, 1.0], + [2.8681872, 1.0796018, 1.0], + [0.52417415, 1.009483, 1.0], + [0.0, 1.0, 1.0], + [6.872955, 1.1356013, 1.0], + [2.2753267, 1.1355586, 1.0], + [1.7234284, 1.1281133, 1.0], + [1.1223476, 1.0683283, 1.0], + [0.0, 1.0, 1.0], + [6.124278, 1.1170305, 1.0], + [0.4689261, 1.1054591, 1.0], + [-0.22648379, 1.088597, 1.0], + [-0.41844296, 1.0501652, 1.0], + [0.0, 1.0, 1.0], + [5.5311556, 1.0830601, 1.0], + [-0.7712272, 1.0794301, 1.0], + [-2.3635144, 1.0375766, 1.0], + [-2.6961873, 1.0000627, 1.0], + [0.0, 1.0, 1.0], + [4.776837, 1.051424, 1.0], + [-1.30482, 1.0655525, 1.0], + [-3.4497104, 1.0115541, 1.0], + [-4.008624, 0.9658105, 1.0], + [0.0, 1.0, 1.0], + [4.325843, 1.0289863, 1.0], + [-0.89701647, 1.0507549, 1.0], + [-3.5267189, 0.9840365, 1.0], + [-4.8896995, 0.92570627, 1.0], + [0.0, 1.0, 1.0], + [3.2383246, 0.9979077, 1.0], + [-1.0782118, 1.0382204, 1.0], + [-2.7714362, 1.0106578, 1.0], + [-3.8654947, 0.9470026, 1.0], + [0.0, 1.0, 1.0], + [2.3593056, 0.97518253, 1.0], + [-1.4679712, 1.0127448, 1.0], + [-3.3288295, 1.0004958, 1.0], + [-4.8622665, 0.95781606, 1.0], + [0.0, 1.0, 1.0], + [1.5416691, 0.94487643, 1.0], + [-1.9971544, 0.97270143, 1.0], + [-4.8065176, 0.9733487, 1.0], + [-6.8176403, 0.9401679, 1.0], + [0.0, 1.0, 1.0], + [0.57058907, 0.9429605, 1.0], + [-2.4935536, 0.96301734, 1.0], + [-5.3679204, 0.9637666, 1.0], + [-6.831026, 0.91412973, 1.0], + [0.0, 1.0, 1.0], + [0.26053378, 0.9610666, 1.0], + [-2.7039409, 0.9701933, 1.0], + [-5.4002733, 0.9458906, 1.0], + [-6.9784875, 0.86947393, 1.0], + [0.0, 1.0, 1.0], + [0.53928864, 0.9811304, 1.0], + [-2.0121186, 0.97265023, 1.0], + [-4.3233175, 0.9172091, 1.0], + [-6.005866, 0.8446555, 1.0], + [0.0, 1.0, 1.0], + [0.6283378, 1.0006269, 1.0], + [-1.4216532, 0.9777993, 1.0], + [-3.27393, 0.91542476, 1.0], + [-4.512793, 0.8313424, 1.0], + [0.0, 1.0, 1.0], + [0.36547175, 1.0127599, 1.0], + [-1.0703696, 0.9762654, 1.0], + [-2.3095455, 0.9178097, 1.0], + [-3.2788665, 0.82293683, 1.0], + [0.0, 1.0, 1.0], + [-0.073588796, 1.014126, 1.0], + [-0.83646154, 0.96881944, 1.0], + [-1.3723681, 0.9147329, 1.0], + [-1.7665399, 0.82023007, 1.0], + [0.0, 1.0, 1.0], + [-0.63139224, 1.017959, 1.0], + [-0.81763905, 0.96828437, 1.0], + [-0.8057263, 0.91791266, 1.0], + [-0.65346074, 0.85073775, 1.0], + [0.0, 1.0, 1.0], + [-0.9540565, 1.0314627, 1.0], + [-0.4009139, 0.97503597, 1.0], + [0.16406323, 0.91989255, 1.0], + [0.69900894, 0.8609631, 1.0], + [0.0, 1.0, 1.0], + [-0.29338956, 1.051337, 1.0], + [0.6638694, 0.9824637, 1.0], + [1.4182094, 0.92527664, 1.0], + [2.0502326, 0.8743307, 1.0], + [0.0, 1.0, 1.0], + [-0.19034386, 1.060521, 1.0], + [0.7418265, 0.9920945, 1.0], + [1.5311259, 0.94175714, 1.0], + [2.3645382, 0.89062786, 1.0], + [0.0, 1.0, 1.0], + [-1.1072253, 1.0738078, 1.0], + [-0.17039186, 1.0250928, 1.0], + [1.0041893, 0.9713254, 1.0], + [2.2262986, 0.9069038, 1.0], + [0.0, 1.0, 1.0], + [-2.3444607, 1.0938511, 1.0], + [-1.396141, 1.0545899, 1.0], + [-0.19473651, 1.0042597, 1.0], + [1.0580148, 0.93644226, 1.0], + [0.0, 1.0, 1.0], + [-3.9988203, 1.1291953, 1.0], + [-3.121686, 1.0951587, 1.0], + [-1.7795982, 1.0358233, 1.0], + [-0.33727086, 0.92730236, 1.0], + [0.0, 1.0, 1.0], + [-5.7153583, 1.1640722, 1.0], + [-4.88902, 1.1327654, 1.0], + [-3.3953416, 1.0511376, 1.0], + [-1.5109448, 0.885162, 1.0], + [0.0, 1.0, 1.0], + [-7.723858, 1.1939173, 1.0], + [-6.834126, 1.162886, 1.0], + [-4.4360557, 1.0393157, 1.0], + [-2.0289664, 0.9277147, 1.0], + [0.0, 1.0, 1.0], + [-9.929747, 1.233579, 1.0], + [-8.989848, 1.1980727, 1.0], + [-5.8884463, 1.0467408, 1.0], + [-1.4371129, 0.9823743, 1.0], + [0.0, 1.0, 1.0], + [-12.79711, 1.2787904, 1.0], + [-11.43725, 1.2281201, 1.0], + [-5.0934134, 1.0372151, 1.0], + [-0.3870912, 0.99834675, 1.0], + [0.0, 1.0, 1.0], + [-15.6093645, 1.2775221, 1.0], + [-13.393321, 1.1939902, 1.0], + [-7.004254, 1.02318, 1.0], + [-0.8632698, 1.0002364, 1.0], + [0.0, 1.0, 1.0], + [-18.6125, 1.2465408, 1.0], + [-15.71152, 1.1482728, 1.0], + [-7.1894984, 1.0131606, 1.0], + [-0.6019608, 0.9991237, 1.0], + [0.0, 1.0, 1.0], + [-19.882221, 1.2043245, 1.0], + [-16.663143, 1.0912375, 1.0], + [-6.9870176, 0.9956438, 1.0], + [-0.2885382, 0.9992374, 1.0], + [0.0, 1.0, 1.0], + [-20.625488, 1.1759886, 1.0], + [-16.814833, 1.0537983, 1.0], + [-6.23361, 0.9808524, 1.0], + [-0.16323197, 0.99922365, 1.0], + [0.0, 1.0, 1.0], + [-21.191574, 1.1593459, 1.0], + [-16.181896, 1.0420355, 1.0], + [-4.434142, 0.979971, 1.0], + [-0.098916985, 0.9993104, 1.0], + [0.0, 1.0, 1.0], + [-21.118694, 1.1469342, 1.0], + [-15.308236, 1.0306582, 1.0], + [-3.593454, 0.9800867, 1.0], + [-0.11090971, 0.99904144, 1.0], + [0.0, 1.0, 1.0], + [-20.06453, 1.1023701, 1.0], + [-16.392384, 0.9912434, 1.0], + [-6.5470276, 0.95821047, 1.0], + [-0.29561612, 0.99735236, 1.0], + [0.0, 1.0, 1.0], + [-20.86482, 1.0911682, 1.0], + [-16.905964, 0.9835901, 1.0], + [-6.5230694, 0.95751834, 1.0], + [-0.18875386, 0.99822915, 1.0], + [0.0, 1.0, 1.0], + [-19.563591, 1.0770866, 1.0], + [-15.724344, 0.9894494, 1.0], + [-5.0314937, 0.9782653, 1.0], + [-0.08916562, 0.99951047, 1.0], + [0.0, 1.0, 1.0], + [-19.648596, 1.066223, 1.0], + [-15.772977, 0.97731113, 1.0], + [-6.1778703, 0.97096556, 1.0], + [-0.17319372, 0.999023, 1.0], + [0.0, 1.0, 1.0], + [-19.83075, 1.0626674, 1.0], + [-15.684646, 0.9758881, 1.0], + [-5.8669987, 0.96583444, 1.0], + [-0.21658812, 0.99854726, 1.0], + [0.0, 1.0, 1.0], + [-19.55086, 1.04659, 1.0], + [-14.666198, 0.9744223, 1.0], + [-4.026313, 0.98011285, 1.0], + [-0.07394151, 0.9995447, 1.0], + [0.0, 1.0, 1.0], + [-20.196869, 0.988159, 1.0], + [-17.390198, 0.93337435, 1.0], + [-7.4060826, 0.95599365, 1.0], + [-0.14739579, 0.99894, 1.0], + [0.0, 1.0, 1.0], + [-21.26852, 0.9264197, 1.0], + [-18.344461, 0.8974489, 1.0], + [-8.743559, 0.936538, 1.0], + [-0.19807786, 0.99823266, 1.0], + [0.0, 1.0, 1.0], + [-21.510828, 0.87103164, 1.0], + [-18.518517, 0.86176705, 1.0], + [-10.065859, 0.9085589, 1.0], + [-0.2821744, 0.99699414, 1.0], + [0.0, 1.0, 1.0], + [-21.584885, 0.8148765, 1.0], + [-18.512503, 0.82746816, 1.0], + [-11.73013, 0.8824068, 1.0], + [-0.3848725, 0.9958189, 1.0], + [0.0, 1.0, 1.0], + [-20.356161, 0.77016896, 1.0], + [-16.736427, 0.8110233, 1.0], + [-13.46877, 0.8463329, 1.0], + [-1.0992389, 0.9864028, 1.0], + [0.0, 1.0, 1.0], + [-19.00107, 0.7378436, 1.0], + [-14.466894, 0.8157218, 1.0], + [-12.32627, 0.84496945, 1.0], + [-2.4299142, 0.9672269, 1.0], + [0.0, 1.0, 1.0], + [-17.491095, 0.72382104, 1.0], + [-11.5228195, 0.84021336, 1.0], + [-9.1774845, 0.87830716, 1.0], + [-2.8254447, 0.9605242, 1.0], + [0.0, 1.0, 1.0], + [-15.435101, 0.7124373, 1.0], + [-8.480166, 0.8606328, 1.0], + [-5.882448, 0.91346925, 1.0], + [-1.6596885, 0.9736952, 1.0], + [0.0, 1.0, 1.0], + [-9.965368, 0.78840566, 1.0], + [-5.0230317, 0.92467666, 1.0], + [-3.841451, 0.95225155, 1.0], + [-2.0082777, 0.97166634, 1.0], + [0.0, 1.0, 1.0], + [-7.585785, 0.8164634, 1.0], + [-4.2593484, 0.9317818, 1.0], + [-2.753388, 0.96636844, 1.0], + [-0.8575842, 0.98519945, 1.0], + [0.0, 1.0, 1.0], + [-5.9080076, 0.82606196, 1.0], + [-2.4876971, 0.95708805, 1.0], + [-0.017117301, 1.0234948, 1.0], + [1.1949928, 1.0367807, 1.0], + [0.0, 1.0, 1.0], + [-4.3217926, 0.8621694, 1.0], + [-1.1579821, 1.013248, 1.0], + [0.829161, 1.0717134, 1.0], + [1.6019802, 1.078209, 1.0], + [0.0, 1.0, 1.0], + [-3.4365811, 0.801541, 1.0], + [-1.142285, 0.9963089, 1.0], + [0.6441237, 1.0807948, 1.0], + [1.3309326, 1.0855218, 1.0], + [0.0, 1.0, 1.0], + [-1.0976561, 0.7120078, 1.0], + [-0.8466615, 0.89612305, 1.0], + [-0.7710759, 1.0564098, 1.0], + [-0.6550111, 1.07382, 1.0], + [0.0, 1.0, 1.0], + [0.8495644, 0.71387726, 1.0], + [0.49764365, 0.8776602, 1.0], + [-0.9067246, 1.0398972, 1.0], + [-1.0517284, 1.0432695, 1.0], + [0.0, 1.0, 1.0], + [2.4473016, 0.72682, 1.0], + [1.7983775, 0.88795257, 1.0], + [0.20548964, 1.0286621, 1.0], + [-0.35707185, 1.0189359, 1.0], + [0.0, 1.0, 1.0], + [4.0796447, 0.7306628, 1.0], + [2.1504579, 0.89834225, 1.0], + [0.34639126, 1.0416607, 1.0], + [-0.23788506, 1.019259, 1.0], + [0.0, 1.0, 1.0], + [5.6701546, 0.74359995, 1.0], + [4.0726633, 0.91694146, 1.0], + [1.3216503, 1.0316267, 1.0], + [-0.045850165, 1.0076267, 1.0], + [0.0, 1.0, 1.0], + [7.0043573, 0.7694095, 1.0], + [5.4557076, 0.9445862, 1.0], + [4.0584545, 1.0185257, 1.0], + [0.56355107, 1.0008852, 1.0], + [0.0, 1.0, 1.0], + [8.084995, 0.78970784, 1.0], + [5.8458114, 0.970168, 1.0], + [4.5538306, 1.024938, 1.0], + [0.49345702, 1.0022416, 1.0], + [0.0, 1.0, 1.0], + [8.05806, 0.817034, 1.0], + [4.4210987, 1.0048858, 1.0], + [3.0152867, 1.0326685, 1.0], + [0.38135135, 1.0021529, 1.0], + [0.0, 1.0, 1.0], + [9.005357, 0.85885465, 1.0], + [4.763144, 1.0251466, 1.0], + [2.17603, 1.0425395, 1.0], + [0.36619714, 1.0030634, 1.0], + [0.0, 1.0, 1.0], + [8.993758, 0.90082896, 1.0], + [5.2438936, 1.0453547, 1.0], + [2.549889, 1.0553246, 1.0], + [0.32793412, 1.0068744, 1.0], + [0.0, 1.0, 1.0], + [9.829749, 0.9506837, 1.0], + [6.0920825, 1.0672028, 1.0], + [3.7592368, 1.0513718, 1.0], + [0.4075813, 1.0041977, 1.0], + [0.0, 1.0, 1.0], + [9.575895, 0.9809986, 1.0], + [5.147536, 1.0732907, 1.0], + [2.6697485, 1.0479143, 1.0], + [0.23983598, 1.0018369, 1.0], + [0.0, 1.0, 1.0], + [8.565204, 0.9654144, 1.0], + [4.159953, 1.0592482, 1.0], + [2.982862, 1.046513, 1.0], + [0.51444083, 1.0031579, 1.0], + [0.0, 1.0, 1.0], + [9.271386, 0.9671414, 1.0], + [3.9021678, 1.0480341, 1.0], + [2.3847647, 1.0395492, 1.0], + [0.338095, 1.0032356, 1.0], + [0.0, 1.0, 1.0], + [9.753154, 0.9583014, 1.0], + [4.58982, 1.0358695, 1.0], + [3.3719125, 1.0326496, 1.0], + [0.62065965, 1.0025914, 1.0], + [0.0, 1.0, 1.0], + [10.777158, 0.9585278, 1.0], + [5.382428, 1.024311, 1.0], + [3.983915, 1.028954, 1.0], + [0.95064604, 1.0067729, 1.0], + [0.0, 1.0, 1.0], + [10.107686, 0.98531634, 1.0], + [5.2336845, 1.0421758, 1.0], + [3.7427158, 1.0352445, 1.0], + [0.91338927, 1.0056044, 1.0], + [0.0, 1.0, 1.0], + [10.010471, 1.002975, 1.0], + [5.0806794, 1.0562643, 1.0], + [3.9300086, 1.0432692, 1.0], + [1.2768706, 1.0071726, 1.0], + [0.0, 1.0, 1.0], + [10.721367, 1.020363, 1.0], + [5.9179363, 1.0760281, 1.0], + [4.653525, 1.0701501, 1.0], + [1.5535151, 1.0154992, 1.0], + [0.0, 1.0, 1.0], + [10.558615, 1.0318329, 1.0], + [6.080362, 1.067487, 1.0], + [6.671132, 1.0704038, 1.0], + [4.238108, 1.0257412, 1.0], + [0.0, 1.0, 1.0], + [9.48343, 1.0585111, 1.0], + [4.4426146, 1.0725648, 1.0], + [4.5216165, 1.0536869, 1.0], + [3.2611907, 1.0090235, 1.0], + [0.0, 1.0, 1.0], + [8.163124, 1.0967343, 1.0], + [3.4163864, 1.1021494, 1.0], + [3.0036535, 1.0894843, 1.0], + [1.6298203, 1.0331198, 1.0], + [0.0, 1.0, 1.0], + [4.7200303, 0.99990386, 1.0], + [-0.18434145, 0.9829197, 1.0], + [-1.7782454, 0.9463851, 1.0], + [-0.8780288, 0.9674484, 1.0], + [0.0, 1.0, 1.0], + [3.3225217, 0.9776916, 1.0], + [-1.491963, 0.9430486, 1.0], + [-2.2781556, 0.91853374, 1.0], + [-1.9229406, 0.92325467, 1.0], + [0.0, 1.0, 1.0], + [2.4281845, 0.93801886, 1.0], + [-2.0302162, 0.92144394, 1.0], + [-2.984083, 0.90740937, 1.0], + [-2.8202295, 0.9010818, 1.0], + [0.0, 1.0, 1.0], + [2.21699, 0.9216663, 1.0], + [-1.478845, 0.9148489, 1.0], + [-3.1692345, 0.9026742, 1.0], + [-3.4822135, 0.8847937, 1.0], + [0.0, 1.0, 1.0], + [2.6095815, 0.91278374, 1.0], + [-0.50396746, 0.90550876, 1.0], + [-2.5619872, 0.8661648, 1.0], + [-3.3487625, 0.8425053, 1.0], + [0.0, 1.0, 1.0], + [2.2401028, 0.9033001, 1.0], + [-0.25377062, 0.8960398, 1.0], + [-2.032842, 0.87459487, 1.0], + [-2.7365766, 0.83498585, 1.0], + [0.0, 1.0, 1.0], + [1.695987, 0.8888191, 1.0], + [-0.67016447, 0.88870287, 1.0], + [-2.36245, 0.87585235, 1.0], + [-3.2576814, 0.8303152, 1.0], + [0.0, 1.0, 1.0], + [0.9194321, 0.8731023, 1.0], + [-1.3481888, 0.8760105, 1.0], + [-3.32636, 0.8514322, 1.0], + [-4.274623, 0.8039877, 1.0], + [0.0, 1.0, 1.0], + [0.23748975, 0.8785199, 1.0], + [-1.6813989, 0.8869438, 1.0], + [-4.1970234, 0.867815, 1.0], + [-4.993503, 0.8058187, 1.0], + [0.0, 1.0, 1.0], + [-0.0443294, 0.89978844, 1.0], + [-1.9890785, 0.90232074, 1.0], + [-3.950284, 0.85917366, 1.0], + [-4.1894603, 0.7918877, 1.0], + [0.0, 1.0, 1.0], + [0.29374087, 0.92003465, 1.0], + [-1.5607008, 0.90463936, 1.0], + [-2.7787287, 0.8298095, 1.0], + [-2.9107876, 0.7702355, 1.0], + [0.0, 1.0, 1.0], + [0.2690241, 0.9420806, 1.0], + [-1.3042494, 0.91745335, 1.0], + [-2.3432212, 0.8269896, 1.0], + [-2.5380266, 0.7619623, 1.0], + [0.0, 1.0, 1.0], + [0.00078135007, 0.9576381, 1.0], + [-1.0908285, 0.9226896, 1.0], + [-1.7685524, 0.83202, 1.0], + [-1.8833431, 0.7369599, 1.0], + [0.0, 1.0, 1.0], + [-0.3298981, 0.9611947, 1.0], + [-0.9030142, 0.9218993, 1.0], + [-1.1803176, 0.84522974, 1.0], + [-1.2364491, 0.7347839, 1.0], + [0.0, 1.0, 1.0], + [-0.90065134, 0.96639967, 1.0], + [-0.92589504, 0.9220938, 1.0], + [-0.7533085, 0.86246485, 1.0], + [-0.58836806, 0.7862185, 1.0], + [0.0, 1.0, 1.0], + [-1.2350203, 0.982918, 1.0], + [-0.63451254, 0.92791486, 1.0], + [-0.05854761, 0.8623331, 1.0], + [0.30643168, 0.8013406, 1.0], + [0.0, 1.0, 1.0], + [-0.6747929, 0.99744356, 1.0], + [0.28179812, 0.93459874, 1.0], + [0.98867965, 0.88537693, 1.0], + [1.5234042, 0.83812547, 1.0], + [0.0, 1.0, 1.0], + [-0.5743862, 1.0066322, 1.0], + [0.4175759, 0.9530498, 1.0], + [1.1614387, 0.9119914, 1.0], + [1.933506, 0.86427605, 1.0], + [0.0, 1.0, 1.0], + [-1.3801366, 1.0321106, 1.0], + [-0.38676104, 0.9921655, 1.0], + [0.70755553, 0.94378203, 1.0], + [1.802139, 0.8818721, 1.0], + [0.0, 1.0, 1.0], + [-2.5278633, 1.0482858, 1.0], + [-1.6080537, 1.0186095, 1.0], + [-0.4270131, 0.96137357, 1.0], + [0.52042204, 0.89067566, 1.0], + [0.0, 1.0, 1.0], + [-3.9787476, 1.078391, 1.0], + [-3.1643727, 1.050093, 1.0], + [-1.7115612, 0.9687348, 1.0], + [-0.4047129, 0.8798787, 1.0], + [0.0, 1.0, 1.0], + [-5.596516, 1.1130706, 1.0], + [-4.6923046, 1.0734227, 1.0], + [-2.8240192, 0.92865837, 1.0], + [-1.3897139, 0.8587299, 1.0], + [0.0, 1.0, 1.0], + [-7.4136, 1.1421592, 1.0], + [-6.2911453, 1.0910819, 1.0], + [-3.5203412, 0.91654646, 1.0], + [-1.5387671, 0.8988556, 1.0], + [0.0, 1.0, 1.0], + [-9.345446, 1.175205, 1.0], + [-7.8788543, 1.1139026, 1.0], + [-3.9887538, 0.95732474, 1.0], + [-0.7494897, 0.9770353, 1.0], + [0.0, 1.0, 1.0], + [-11.726303, 1.2018178, 1.0], + [-9.235658, 1.1204212, 1.0], + [-2.6193218, 0.99971265, 1.0], + [-0.17413116, 0.9981184, 1.0], + [0.0, 1.0, 1.0], + [-13.852155, 1.1776309, 1.0], + [-9.851412, 1.0741005, 1.0], + [-3.3592322, 0.99141484, 1.0], + [-0.30808955, 0.99881417, 1.0], + [0.0, 1.0, 1.0], + [-15.295475, 1.1342069, 1.0], + [-10.099276, 1.0167769, 1.0], + [-3.3911893, 0.9816512, 1.0], + [-0.22844037, 0.99877155, 1.0], + [0.0, 1.0, 1.0], + [-15.857363, 1.0911952, 1.0], + [-10.737116, 0.98477983, 1.0], + [-3.0231202, 0.9772009, 1.0], + [-0.08856626, 0.99933064, 1.0], + [0.0, 1.0, 1.0], + [-16.511763, 1.0658559, 1.0], + [-10.546561, 0.97217023, 1.0], + [-2.431429, 0.97843724, 1.0], + [-0.04837607, 0.9995322, 1.0], + [0.0, 1.0, 1.0], + [-17.32334, 1.046019, 1.0], + [-9.642773, 0.9659112, 1.0], + [-1.8824904, 0.9784988, 1.0], + [-0.039536994, 0.9995053, 1.0], + [0.0, 1.0, 1.0], + [-16.230003, 1.0588695, 1.0], + [-7.7154303, 0.98527706, 1.0], + [-1.1871514, 0.9866838, 1.0], + [-0.05562747, 0.99940675, 1.0], + [0.0, 1.0, 1.0], + [-14.798325, 1.0390636, 1.0], + [-9.137856, 0.95683104, 1.0], + [-3.1458273, 0.96849316, 1.0], + [-0.17147331, 0.99812067, 1.0], + [0.0, 1.0, 1.0], + [-15.813182, 1.0053109, 1.0], + [-10.048948, 0.94771963, 1.0], + [-3.4650283, 0.9650353, 1.0], + [-0.12416336, 0.9983432, 1.0], + [0.0, 1.0, 1.0], + [-16.047022, 0.98889244, 1.0], + [-8.209296, 0.96882516, 1.0], + [-2.0700836, 0.98336583, 1.0], + [-0.05653161, 0.9994855, 1.0], + [0.0, 1.0, 1.0], + [-16.9603, 0.97119784, 1.0], + [-8.66278, 0.9401887, 1.0], + [-2.2208483, 0.9735744, 1.0], + [-0.05832822, 0.9992916, 1.0], + [0.0, 1.0, 1.0], + [-15.93582, 0.9818394, 1.0], + [-9.735115, 0.91714615, 1.0], + [-2.8493056, 0.9654458, 1.0], + [-0.06305568, 0.99927205, 1.0], + [0.0, 1.0, 1.0], + [-14.386252, 0.98472387, 1.0], + [-8.998497, 0.9461114, 1.0], + [-2.1540806, 0.9810613, 1.0], + [-0.04269774, 0.99961257, 1.0], + [0.0, 1.0, 1.0], + [-15.852745, 0.9424422, 1.0], + [-13.541525, 0.9004132, 1.0], + [-5.0671234, 0.95313597, 1.0], + [-0.12552416, 0.99874544, 1.0], + [0.0, 1.0, 1.0], + [-17.605848, 0.8931836, 1.0], + [-11.864264, 0.9022595, 1.0], + [-3.5728223, 0.95726645, 1.0], + [-0.09980284, 0.9986085, 1.0], + [0.0, 1.0, 1.0], + [-18.33637, 0.8163921, 1.0], + [-12.252236, 0.8543749, 1.0], + [-3.4134076, 0.94611895, 1.0], + [-0.07950901, 0.9985124, 1.0], + [0.0, 1.0, 1.0], + [-19.060442, 0.7734047, 1.0], + [-15.23906, 0.8017895, 1.0], + [-5.954306, 0.91588944, 1.0], + [-0.12067752, 0.9982148, 1.0], + [0.0, 1.0, 1.0], + [-19.100754, 0.72602576, 1.0], + [-15.591848, 0.7819147, 1.0], + [-9.629234, 0.86622393, 1.0], + [-0.3522098, 0.99482995, 1.0], + [0.0, 1.0, 1.0], + [-18.568441, 0.6896306, 1.0], + [-14.766998, 0.7783395, 1.0], + [-11.441, 0.83353055, 1.0], + [-0.95255774, 0.98503613, 1.0], + [0.0, 1.0, 1.0], + [-17.873262, 0.66292804, 1.0], + [-11.700582, 0.7895474, 1.0], + [-7.4423504, 0.87738365, 1.0], + [-0.7649209, 0.9871464, 1.0], + [0.0, 1.0, 1.0], + [-15.81971, 0.65139574, 1.0], + [-9.3584585, 0.78892857, 1.0], + [-5.956205, 0.8730324, 1.0], + [-0.8720731, 0.9798331, 1.0], + [0.0, 1.0, 1.0], + [-9.644368, 0.7343603, 1.0], + [-5.3256245, 0.8688635, 1.0], + [-4.0904546, 0.905134, 1.0], + [-1.9802942, 0.942991, 1.0], + [0.0, 1.0, 1.0], + [-6.834648, 0.768598, 1.0], + [-3.7342772, 0.8917114, 1.0], + [-2.275454, 0.9256257, 1.0], + [-0.91388834, 0.94262, 1.0], + [0.0, 1.0, 1.0], + [-4.958538, 0.79897887, 1.0], + [-1.7752197, 0.92934436, 1.0], + [-0.052028436, 0.9775266, 1.0], + [1.0069743, 0.99931246, 1.0], + [0.0, 1.0, 1.0], + [-3.5435648, 0.83892524, 1.0], + [-0.45535767, 0.99585503, 1.0], + [1.1220992, 1.0467564, 1.0], + [1.688386, 1.0540297, 1.0], + [0.0, 1.0, 1.0], + [-2.584903, 0.7611296, 1.0], + [-0.29603556, 0.99536204, 1.0], + [1.0826626, 1.0670043, 1.0], + [1.5285076, 1.0699658, 1.0], + [0.0, 1.0, 1.0], + [-0.24621776, 0.6534611, 1.0], + [-0.5641967, 0.88916516, 1.0], + [-0.5208639, 1.0147351, 1.0], + [-0.17287369, 1.0311226, 1.0], + [0.0, 1.0, 1.0], + [1.6623358, 0.67162013, 1.0], + [-0.014613975, 0.90694416, 1.0], + [-0.7910066, 0.9813947, 1.0], + [-0.7020828, 0.9796464, 1.0], + [0.0, 1.0, 1.0], + [3.2134006, 0.70449984, 1.0], + [0.69320697, 0.9216747, 1.0], + [-0.55953246, 0.98736876, 1.0], + [-0.60563326, 0.9877621, 1.0], + [0.0, 1.0, 1.0], + [4.672629, 0.72619575, 1.0], + [2.541607, 0.90983087, 1.0], + [1.9858508, 0.9788352, 1.0], + [0.5622153, 1.0007318, 1.0], + [0.0, 1.0, 1.0], + [6.4891405, 0.7448458, 1.0], + [4.03939, 0.9308739, 1.0], + [3.3720996, 0.97321606, 1.0], + [1.2299676, 0.9903418, 1.0], + [0.0, 1.0, 1.0], + [7.157809, 0.77485734, 1.0], + [4.3740115, 0.9462917, 1.0], + [3.2057083, 0.98681796, 1.0], + [0.7071888, 0.997923, 1.0], + [0.0, 1.0, 1.0], + [7.228337, 0.79360247, 1.0], + [2.7674537, 0.9669471, 1.0], + [1.3489184, 1.003461, 1.0], + [0.16082476, 1.0005132, 1.0], + [0.0, 1.0, 1.0], + [6.9538918, 0.81647366, 1.0], + [3.1088212, 0.9820907, 1.0], + [1.4730394, 1.0125114, 1.0], + [0.15418816, 1.0018836, 1.0], + [0.0, 1.0, 1.0], + [7.6807427, 0.8534504, 1.0], + [3.718583, 0.99722385, 1.0], + [1.4714887, 1.0191232, 1.0], + [0.16044605, 1.0014175, 1.0], + [0.0, 1.0, 1.0], + [6.902218, 0.8794101, 1.0], + [2.855522, 0.99446875, 1.0], + [1.4610727, 1.0151895, 1.0], + [0.11758561, 1.0017471, 1.0], + [0.0, 1.0, 1.0], + [7.0196548, 0.9160186, 1.0], + [1.6999657, 1.0058115, 1.0], + [1.0062673, 1.0163534, 1.0], + [0.08308377, 1.0012561, 1.0], + [0.0, 1.0, 1.0], + [6.268169, 0.93658304, 1.0], + [1.035491, 0.99574167, 1.0], + [0.41364503, 1.0079995, 1.0], + [0.040540703, 1.0006597, 1.0], + [0.0, 1.0, 1.0], + [5.259043, 0.92028683, 1.0], + [0.23776771, 0.9797173, 1.0], + [0.07063755, 0.9973912, 1.0], + [0.066322915, 1.0000901, 1.0], + [0.0, 1.0, 1.0], + [6.786019, 0.91943693, 1.0], + [-0.12373619, 0.97899324, 1.0], + [-1.2265637, 0.9915, 1.0], + [-0.047353376, 0.9991457, 1.0], + [0.0, 1.0, 1.0], + [6.188807, 0.89457834, 1.0], + [-0.7516189, 0.9425688, 1.0], + [-1.1165174, 0.962824, 1.0], + [0.15711758, 0.99334264, 1.0], + [0.0, 1.0, 1.0], + [7.1094146, 0.89115375, 1.0], + [-0.14875633, 0.9282804, 1.0], + [-0.99777395, 0.94288254, 1.0], + [-0.13148743, 0.9870557, 1.0], + [0.0, 1.0, 1.0], + [6.441057, 0.90025866, 1.0], + [-0.48353556, 0.9186272, 1.0], + [-0.82087964, 0.9264453, 1.0], + [0.08318026, 0.9731093, 1.0], + [0.0, 1.0, 1.0], + [6.7111974, 0.91075236, 1.0], + [-0.36840874, 0.92250305, 1.0], + [-0.4315016, 0.9269158, 1.0], + [0.2359939, 0.96514857, 1.0], + [0.0, 1.0, 1.0], + [7.3763676, 0.92495906, 1.0], + [0.36888885, 0.9462593, 1.0], + [-1.5960634, 0.9405903, 1.0], + [-0.54044867, 0.9806632, 1.0], + [0.0, 1.0, 1.0], + [7.855471, 0.9369563, 1.0], + [1.1207947, 0.95559967, 1.0], + [-0.42048588, 0.9605789, 1.0], + [0.09410473, 0.9890609, 1.0], + [0.0, 1.0, 1.0], + [6.971675, 0.9428375, 1.0], + [1.4723355, 0.95907015, 1.0], + [-0.40449092, 0.9526683, 1.0], + [-0.034233358, 0.98458976, 1.0], + [0.0, 1.0, 1.0], + [6.2168164, 0.96651506, 1.0], + [0.45716965, 0.969795, 1.0], + [-1.276185, 0.95871323, 1.0], + [-0.46196827, 0.98386484, 1.0], + [0.0, 1.0, 1.0], + [1.5696334, 0.88579595, 1.0], + [-4.8797174, 0.8235008, 1.0], + [-5.7836246, 0.81873804, 1.0], + [-1.2262843, 0.9544453, 1.0], + [0.0, 1.0, 1.0], + [0.3305974, 0.86768496, 1.0], + [-4.0607495, 0.7667695, 1.0], + [-5.33198, 0.72661614, 1.0], + [-2.8205767, 0.83346885, 1.0], + [0.0, 1.0, 1.0], + [-0.10735662, 0.7961712, 1.0], + [-2.5164855, 0.746526, 1.0], + [-3.905786, 0.70082253, 1.0], + [-2.8722813, 0.7402977, 1.0], + [0.0, 1.0, 1.0], + [1.0955659, 0.7785365, 1.0], + [-0.020220347, 0.7376785, 1.0], + [-1.4722649, 0.72488576, 1.0], + [-1.4273844, 0.7533604, 1.0], + [0.0, 1.0, 1.0], + [3.1442819, 0.74805653, 1.0], + [2.5943675, 0.7122985, 1.0], + [1.4479681, 0.70843244, 1.0], + [-0.06824569, 0.74796575, 1.0], + [0.0, 1.0, 1.0], + [3.0809562, 0.7545235, 1.0], + [2.7876127, 0.7176543, 1.0], + [3.0311806, 0.6859141, 1.0], + [2.3895133, 0.7123276, 1.0], + [0.0, 1.0, 1.0], + [1.9816447, 0.78059196, 1.0], + [2.157276, 0.7259703, 1.0], + [2.2583017, 0.69609505, 1.0], + [2.6869667, 0.69099945, 1.0], + [0.0, 1.0, 1.0], + [1.1339029, 0.8082483, 1.0], + [0.9635001, 0.7438238, 1.0], + [1.7119975, 0.69262445, 1.0], + [2.8751085, 0.67264295, 1.0], + [0.0, 1.0, 1.0], + [0.7148004, 0.8236315, 1.0], + [0.15944664, 0.7895265, 1.0], + [0.8037349, 0.73090047, 1.0], + [2.2817705, 0.700999, 1.0], + [0.0, 1.0, 1.0], + [1.1721781, 0.823198, 1.0], + [0.42141455, 0.79766697, 1.0], + [1.731092, 0.72331715, 1.0], + [1.9070219, 0.70068294, 1.0], + [0.0, 1.0, 1.0], + [1.4472144, 0.82568, 1.0], + [0.7194572, 0.78889567, 1.0], + [1.2272654, 0.7160339, 1.0], + [1.3908615, 0.6934153, 1.0], + [0.0, 1.0, 1.0], + [0.9659478, 0.8547631, 1.0], + [0.23514794, 0.8048065, 1.0], + [0.6698278, 0.720812, 1.0], + [1.0716225, 0.6872872, 1.0], + [0.0, 1.0, 1.0], + [0.3646527, 0.8849565, 1.0], + [-0.07247575, 0.8116052, 1.0], + [0.0033967285, 0.72046185, 1.0], + [-0.12690859, 0.66787803, 1.0], + [0.0, 1.0, 1.0], + [0.14792149, 0.90996736, 1.0], + [-0.41231233, 0.8411673, 1.0], + [-0.4463926, 0.7333069, 1.0], + [-0.42133877, 0.6662905, 1.0], + [0.0, 1.0, 1.0], + [-0.5877023, 0.92537576, 1.0], + [-0.7669311, 0.85629356, 1.0], + [-0.6382856, 0.7815932, 1.0], + [-0.5130515, 0.72520864, 1.0], + [0.0, 1.0, 1.0], + [-0.8632642, 0.935545, 1.0], + [-0.6218721, 0.8610767, 1.0], + [-0.2218913, 0.7949077, 1.0], + [-0.026698561, 0.76374143, 1.0], + [0.0, 1.0, 1.0], + [-0.6670934, 0.95232236, 1.0], + [-0.013086532, 0.8753517, 1.0], + [0.3907465, 0.82373375, 1.0], + [0.6438301, 0.7755369, 1.0], + [0.0, 1.0, 1.0], + [-0.8246577, 0.96011007, 1.0], + [-0.022377672, 0.9073638, 1.0], + [0.5644851, 0.86494243, 1.0], + [1.0286213, 0.8235526, 1.0], + [0.0, 1.0, 1.0], + [-1.3516122, 0.97745264, 1.0], + [-0.5598576, 0.9293583, 1.0], + [0.04228649, 0.8763748, 1.0], + [-0.31584078, 0.8592634, 1.0], + [0.0, 1.0, 1.0], + [-1.9975094, 0.9925349, 1.0], + [-1.3282233, 0.9415561, 1.0], + [-0.52022403, 0.85210764, 1.0], + [-0.5763454, 0.8441975, 1.0], + [0.0, 1.0, 1.0], + [-3.0044324, 0.993117, 1.0], + [-2.32693, 0.9048576, 1.0], + [-1.0574396, 0.8305523, 1.0], + [-0.25919157, 0.86520964, 1.0], + [0.0, 1.0, 1.0], + [-2.8471406, 0.9966514, 1.0], + [-2.3174558, 0.8910772, 1.0], + [-1.6867507, 0.8372742, 1.0], + [-0.6674814, 0.9019048, 1.0], + [0.0, 1.0, 1.0], + [-2.7654781, 1.0002338, 1.0], + [-2.2775855, 0.92181945, 1.0], + [-1.6766036, 0.8592095, 1.0], + [-0.6071752, 0.91706604, 1.0], + [0.0, 1.0, 1.0], + [-3.2180955, 1.0019648, 1.0], + [-2.4868007, 0.9530428, 1.0], + [-0.8147237, 0.9659948, 1.0], + [-0.08990055, 0.99272156, 1.0], + [0.0, 1.0, 1.0], + [-2.6169603, 1.0069607, 1.0], + [-1.80826, 0.9822141, 1.0], + [-0.27318954, 0.99557036, 1.0], + [-0.0078226, 0.9997451, 1.0], + [0.0, 1.0, 1.0], + [-2.620088, 1.0105745, 1.0], + [-1.6558734, 0.99095327, 1.0], + [-0.34937736, 0.99785286, 1.0], + [-0.009377529, 0.9999541, 1.0], + [0.0, 1.0, 1.0], + [-1.9161261, 1.0054843, 1.0], + [-0.7893424, 0.9937573, 1.0], + [-0.21549447, 0.9983831, 1.0], + [-0.006033252, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-1.6860873, 1.000248, 1.0], + [-0.8517966, 0.99048835, 1.0], + [-0.1809431, 0.99742275, 1.0], + [-0.0035661473, 0.99995184, 1.0], + [0.0, 1.0, 1.0], + [-2.009457, 0.9998743, 1.0], + [-1.0168654, 0.9889097, 1.0], + [-0.14624618, 0.99738944, 1.0], + [-0.002028748, 0.99996185, 1.0], + [0.0, 1.0, 1.0], + [-1.343951, 0.9905693, 1.0], + [-0.7694108, 0.988736, 1.0], + [-0.13042787, 0.9973714, 1.0], + [-0.0019758502, 0.9999585, 1.0], + [0.0, 1.0, 1.0], + [-1.5010524, 0.9900646, 1.0], + [-0.6511199, 0.99298286, 1.0], + [-0.05297016, 0.9991895, 1.0], + [-0.0008825612, 0.99998635, 1.0], + [0.0, 1.0, 1.0], + [-1.6744328, 0.9952045, 1.0], + [-0.56495047, 0.9939041, 1.0], + [-0.072879314, 0.9991123, 1.0], + [-0.0022557385, 0.9999713, 1.0], + [0.0, 1.0, 1.0], + [-2.817171, 0.98676676, 1.0], + [-0.59550554, 0.9955451, 1.0], + [-0.10011909, 0.99888563, 1.0], + [-0.002785918, 0.9999608, 1.0], + [0.0, 1.0, 1.0], + [-4.859452, 0.9669093, 1.0], + [-0.5635375, 0.9964578, 1.0], + [-0.061867803, 0.99939847, 1.0], + [-0.001733142, 0.9999818, 1.0], + [0.0, 1.0, 1.0], + [-6.3632984, 0.9476491, 1.0], + [-0.9620065, 0.9895864, 1.0], + [-0.1275121, 0.9982268, 1.0], + [-0.0027327028, 0.9999624, 1.0], + [0.0, 1.0, 1.0], + [-3.8316839, 0.9652471, 1.0], + [-1.5537338, 0.9825404, 1.0], + [-0.27127823, 0.99667627, 1.0], + [-0.0039444612, 0.9999499, 1.0], + [0.0, 1.0, 1.0], + [-2.9406507, 0.9717847, 1.0], + [-1.1054865, 0.98933583, 1.0], + [-0.15259248, 0.9985821, 1.0], + [-0.0023587716, 0.99997854, 1.0], + [0.0, 1.0, 1.0], + [-4.1605053, 0.9520869, 1.0], + [-2.1564174, 0.9791306, 1.0], + [-0.33962235, 0.9967707, 1.0], + [-0.005252303, 0.9999497, 1.0], + [0.0, 1.0, 1.0], + [-4.6772366, 0.9365187, 1.0], + [-0.94710606, 0.98695683, 1.0], + [-0.11579239, 0.9980016, 1.0], + [-0.002743927, 0.9999484, 1.0], + [0.0, 1.0, 1.0], + [-5.730075, 0.9172761, 1.0], + [-2.2775342, 0.96206695, 1.0], + [-0.38358852, 0.9927294, 1.0], + [-0.0075762877, 0.9998489, 1.0], + [0.0, 1.0, 1.0], + [-6.438858, 0.8943925, 1.0], + [-5.09395, 0.91621476, 1.0], + [-1.3278813, 0.9786671, 1.0], + [-0.026160287, 0.99958307, 1.0], + [0.0, 1.0, 1.0], + [-8.272365, 0.8478823, 1.0], + [-3.386477, 0.9320077, 1.0], + [-0.5339506, 0.9900594, 1.0], + [-0.008624411, 0.9998474, 1.0], + [0.0, 1.0, 1.0], + [-11.802094, 0.75678855, 1.0], + [-6.5117326, 0.85963863, 1.0], + [-1.7044662, 0.96734977, 1.0], + [-0.04367834, 0.9992392, 1.0], + [0.0, 1.0, 1.0], + [-15.306793, 0.6631, 1.0], + [-9.06385, 0.7773203, 1.0], + [-2.7547972, 0.9315435, 1.0], + [-0.10010718, 0.99756825, 1.0], + [0.0, 1.0, 1.0], + [-14.635688, 0.62598675, 1.0], + [-11.576207, 0.6989409, 1.0], + [-7.9337134, 0.8109356, 1.0], + [-0.6527108, 0.98375565, 1.0], + [0.0, 1.0, 1.0], + [-9.552264, 0.68059266, 1.0], + [-8.43511, 0.7298391, 1.0], + [-8.363759, 0.7697578, 1.0], + [-3.363505, 0.9041366, 1.0], + [0.0, 1.0, 1.0], + [-7.9862127, 0.70823747, 1.0], + [-6.549984, 0.7803926, 1.0], + [-6.411847, 0.8035285, 1.0], + [-5.4986544, 0.83412504, 1.0], + [0.0, 1.0, 1.0], + [-6.795965, 0.7171654, 1.0], + [-4.852691, 0.821311, 1.0], + [-4.071751, 0.8528317, 1.0], + [-2.6617806, 0.89110637, 1.0], + [0.0, 1.0, 1.0], + [-5.3123918, 0.72757715, 1.0], + [-3.218556, 0.8593564, 1.0], + [-1.1645839, 0.93264043, 1.0], + [0.039813682, 0.9652358, 1.0], + [0.0, 1.0, 1.0], + [-3.6778567, 0.6624857, 1.0], + [-1.7741417, 0.8541198, 1.0], + [-0.0029635795, 0.9721248, 1.0], + [0.40605798, 0.98661965, 1.0], + [0.0, 1.0, 1.0], + [-0.8761976, 0.6101054, 1.0], + [-0.49433082, 0.8107166, 1.0], + [-0.104019575, 0.86723596, 1.0], + [0.20899503, 0.890897, 1.0], + [0.0, 1.0, 1.0], + [2.861542, 0.67296326, 1.0], + [0.71851164, 0.8735976, 1.0], + [0.8827842, 0.8889713, 1.0], + [1.2020328, 0.8868588, 1.0], + [0.0, 1.0, 1.0], + [5.1083093, 0.72884905, 1.0], + [1.4813685, 0.8916017, 1.0], + [0.98923063, 0.90779537, 1.0], + [1.0078008, 0.9088085, 1.0], + [0.0, 1.0, 1.0], + [5.061599, 0.7781577, 1.0], + [6.068938, 0.85700047, 1.0], + [7.827683, 0.85164875, 1.0], + [3.5587494, 0.9304464, 1.0], + [0.0, 1.0, 1.0], + [5.0541573, 0.80283755, 1.0], + [5.1185517, 0.9080198, 1.0], + [6.060745, 0.91160166, 1.0], + [2.6937423, 0.9596598, 1.0], + [0.0, 1.0, 1.0], + [4.3760943, 0.8454239, 1.0], + [2.5560715, 0.9567565, 1.0], + [2.1207442, 0.9821871, 1.0], + [0.37868842, 0.99643296, 1.0], + [0.0, 1.0, 1.0], + [3.3878245, 0.8679573, 1.0], + [0.71156067, 0.96918815, 1.0], + [0.12126909, 0.99451196, 1.0], + [0.001029497, 0.9995172, 1.0], + [0.0, 1.0, 1.0], + [3.1282337, 0.8924943, 1.0], + [0.5896895, 0.9759098, 1.0], + [-0.11373672, 0.9960285, 1.0], + [-0.01838675, 0.9997154, 1.0], + [0.0, 1.0, 1.0], + [4.3400493, 0.90476185, 1.0], + [1.3310212, 0.9786638, 1.0], + [0.17455858, 0.99855655, 1.0], + [0.005317117, 0.9999772, 1.0], + [0.0, 1.0, 1.0], + [2.8491004, 0.93242216, 1.0], + [1.6193472, 0.9691039, 1.0], + [0.80294114, 0.99088013, 1.0], + [0.029686294, 0.9997005, 1.0], + [0.0, 1.0, 1.0], + [1.4401128, 0.9450957, 1.0], + [-0.16860908, 0.97805417, 1.0], + [0.08968142, 0.9966332, 1.0], + [0.0058796364, 0.9999138, 1.0], + [0.0, 1.0, 1.0], + [0.9945528, 0.91562444, 1.0], + [-1.0134145, 0.97015774, 1.0], + [-0.3692183, 0.9947299, 1.0], + [-0.007456935, 0.9999008, 1.0], + [0.0, 1.0, 1.0], + [0.055624425, 0.9145302, 1.0], + [-2.5425062, 0.9648608, 1.0], + [-1.0486484, 0.98947704, 1.0], + [-0.02597243, 0.99974424, 1.0], + [0.0, 1.0, 1.0], + [1.5285127, 0.9355288, 1.0], + [-2.3404818, 0.95899427, 1.0], + [-1.5345422, 0.98380965, 1.0], + [-0.061189, 0.9995191, 1.0], + [0.0, 1.0, 1.0], + [0.72061044, 0.92197156, 1.0], + [-5.383721, 0.90637255, 1.0], + [-3.6513355, 0.9478104, 1.0], + [-0.16841647, 0.9974061, 1.0], + [0.0, 1.0, 1.0], + [-0.028428238, 0.89694136, 1.0], + [-7.034104, 0.86039716, 1.0], + [-6.6405563, 0.88451445, 1.0], + [-0.60662454, 0.98856294, 1.0], + [0.0, 1.0, 1.0], + [-1.0993353, 0.8637165, 1.0], + [-7.684664, 0.8347192, 1.0], + [-6.927881, 0.8624654, 1.0], + [-0.7485936, 0.98060685, 1.0], + [0.0, 1.0, 1.0], + [-0.6461796, 0.892773, 1.0], + [-7.967004, 0.8294017, 1.0], + [-6.8060684, 0.8550583, 1.0], + [-0.8756036, 0.9734578, 1.0], + [0.0, 1.0, 1.0], + [0.017491894, 0.8894634, 1.0], + [-13.43903, 0.7625003, 1.0], + [-16.398203, 0.7407526, 1.0], + [-4.292457, 0.92504275, 1.0], + [0.0, 1.0, 1.0], + [1.3580081, 0.8969149, 1.0], + [-12.114263, 0.7745682, 1.0], + [-15.785243, 0.74346626, 1.0], + [-3.6444035, 0.9348789, 1.0], + [0.0, 1.0, 1.0], + [1.7327255, 0.87856543, 1.0], + [-6.4373803, 0.8177134, 1.0], + [-10.325781, 0.78358805, 1.0], + [-2.567262, 0.9422052, 1.0], + [0.0, 1.0, 1.0], + [1.7113199, 0.8661993, 1.0], + [-6.172294, 0.80224586, 1.0], + [-8.819174, 0.79800993, 1.0], + [-2.020759, 0.95133585, 1.0], + [0.0, 1.0, 1.0], + [-0.265015, 0.9794298, 1.0], + [-2.8535793, 0.9150202, 1.0], + [-2.305115, 0.93263686, 1.0], + [-0.16463748, 0.9945547, 1.0], + [0.0, 1.0, 1.0], + [-0.040856205, 0.9830346, 1.0], + [-1.0551267, 0.93554235, 1.0], + [-1.7591288, 0.8933596, 1.0], + [-0.34586504, 0.97479594, 1.0], + [0.0, 1.0, 1.0], + [0.04883148, 0.94569665, 1.0], + [-0.56813735, 0.9045709, 1.0], + [-1.5370053, 0.81085134, 1.0], + [-0.67263573, 0.8751091, 1.0], + [0.0, 1.0, 1.0], + [0.3425455, 0.9003105, 1.0], + [0.7451464, 0.8549796, 1.0], + [0.20236868, 0.8441968, 1.0], + [0.07073531, 0.86383015, 1.0], + [0.0, 1.0, 1.0], + [2.8022106, 0.80535203, 1.0], + [4.376988, 0.70769286, 1.0], + [4.8166633, 0.707951, 1.0], + [2.8132818, 0.8092864, 1.0], + [0.0, 1.0, 1.0], + [2.3843334, 0.85017675, 1.0], + [5.65875, 0.6995908, 1.0], + [8.184944, 0.62843776, 1.0], + [5.3796787, 0.7383771, 1.0], + [0.0, 1.0, 1.0], + [0.9017272, 0.9307022, 1.0], + [3.9063222, 0.765117, 1.0], + [6.974823, 0.69017345, 1.0], + [8.185112, 0.6926818, 1.0], + [0.0, 1.0, 1.0], + [0.34200728, 0.96810126, 1.0], + [2.172312, 0.84994847, 1.0], + [8.207728, 0.66688955, 1.0], + [10.739886, 0.62480503, 1.0], + [0.0, 1.0, 1.0], + [0.2027068, 0.9796112, 1.0], + [0.44695723, 0.9623203, 1.0], + [2.7991126, 0.8646282, 1.0], + [5.550562, 0.77532023, 1.0], + [0.0, 1.0, 1.0], + [0.27983877, 0.96956384, 1.0], + [0.464609, 0.9587286, 1.0], + [2.1685085, 0.8807419, 1.0], + [3.9939811, 0.8027935, 1.0], + [0.0, 1.0, 1.0], + [0.32165113, 0.96697015, 1.0], + [0.6435883, 0.93713695, 1.0], + [2.400229, 0.8268258, 1.0], + [3.7155714, 0.7548948, 1.0], + [0.0, 1.0, 1.0], + [0.13139115, 0.98465353, 1.0], + [0.26204187, 0.9602435, 1.0], + [1.2602012, 0.87366205, 1.0], + [2.4989963, 0.7911792, 1.0], + [0.0, 1.0, 1.0], + [0.04954044, 0.990862, 1.0], + [0.15550658, 0.9640937, 1.0], + [0.25354654, 0.9320879, 1.0], + [0.37589833, 0.8832426, 1.0], + [0.0, 1.0, 1.0], + [0.045748647, 0.99577636, 1.0], + [0.049044535, 0.9851228, 1.0], + [0.006159843, 0.9524085, 1.0], + [0.02588024, 0.8944849, 1.0], + [0.0, 1.0, 1.0], + [0.0030355332, 0.99710935, 1.0], + [-0.011863776, 0.9914036, 1.0], + [-0.054379225, 0.972176, 1.0], + [-0.039397053, 0.9642003, 1.0], + [0.0, 1.0, 1.0], + [-0.008150871, 0.9936041, 1.0], + [-0.037190154, 0.9887421, 1.0], + [-0.06706805, 0.96539193, 1.0], + [-0.0317106, 0.9736436, 1.0], + [0.0, 1.0, 1.0], + [-0.0109262755, 0.9985575, 1.0], + [-0.024314428, 0.99118114, 1.0], + [-0.047992434, 0.9725764, 1.0], + [-0.012975532, 0.9771226, 1.0], + [0.0, 1.0, 1.0], + [-0.011895402, 0.99871415, 1.0], + [-0.05900639, 0.9952044, 1.0], + [-0.07541064, 0.9911421, 1.0], + [-0.010232872, 0.99422073, 1.0], + [0.0, 1.0, 1.0], + [-0.014660832, 0.9972283, 1.0], + [-0.028494045, 0.9946739, 1.0], + [-0.04470135, 0.9915807, 1.0], + [-0.08405579, 0.9945791, 1.0], + [0.0, 1.0, 1.0], + [-0.033616997, 0.9969934, 1.0], + [-0.045168266, 0.993965, 1.0], + [-0.059918057, 0.98309493, 1.0], + [-0.062201336, 0.98883307, 1.0], + [0.0, 1.0, 1.0], + [-0.2381658, 0.99073696, 1.0], + [-0.47134387, 0.97534996, 1.0], + [-0.15076974, 0.978513, 1.0], + [-0.027898252, 0.9923376, 1.0], + [0.0, 1.0, 1.0], + [-0.094118536, 0.99575883, 1.0], + [-0.22728153, 0.9851976, 1.0], + [-0.14446811, 0.98079014, 1.0], + [-0.026381236, 0.9947049, 1.0], + [0.0, 1.0, 1.0], + [-0.027151769, 0.9987402, 1.0], + [-0.055821914, 0.9954352, 1.0], + [-0.07118323, 0.9875545, 1.0], + [-0.024681333, 0.9936937, 1.0], + [0.0, 1.0, 1.0], + [-0.057406988, 0.9985795, 1.0], + [-0.075127326, 0.99687934, 1.0], + [-0.012029578, 0.99905646, 1.0], + [-0.0011061644, 0.99975455, 1.0], + [0.0, 1.0, 1.0], + [-0.02290934, 0.99975824, 1.0], + [-0.025390672, 0.9995626, 1.0], + [-0.0026628294, 0.99994296, 1.0], + [-3.556243e-05, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.017105369, 0.99992365, 1.0], + [-0.01626235, 0.9998567, 1.0], + [-0.0023494267, 0.9999825, 1.0], + [-3.9371847e-05, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.01157836, 0.999962, 1.0], + [-0.002812421, 0.99997854, 1.0], + [-0.0007163898, 0.9999961, 1.0], + [-1.7496699e-05, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.014018655, 0.99996704, 1.0], + [-0.002332822, 0.9999732, 1.0], + [-0.0003527423, 0.99999386, 1.0], + [-5.771106e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.010408213, 0.99997354, 1.0], + [-0.0040600216, 0.99995077, 1.0], + [-0.0005498676, 0.999989, 1.0], + [-7.1796394e-06, 0.9999998, 1.0], + [0.0, 1.0, 1.0], + [-0.0023898028, 0.9999025, 1.0], + [-0.0021971867, 0.99995613, 1.0], + [-0.00040569165, 0.99999076, 1.0], + [-5.7337793e-06, 0.9999999, 1.0], + [0.0, 1.0, 1.0], + [-0.006176083, 0.99988633, 1.0], + [-0.0033466935, 0.99995404, 1.0], + [-0.00018771198, 0.99999684, 1.0], + [-1.5582656e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.0078279935, 0.99997926, 1.0], + [-0.0021727988, 0.99997437, 1.0], + [-8.390261e-05, 0.9999989, 1.0], + [-7.6212564e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.011409035, 0.9999373, 1.0], + [-0.0012485707, 0.99999255, 1.0], + [-5.873547e-05, 0.99999946, 1.0], + [-1.1412731e-06, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.0320137, 0.9996883, 1.0], + [-0.0022234167, 0.99998236, 1.0], + [-4.2978718e-05, 0.99999964, 1.0], + [-8.9091026e-07, 1.0, 1.0], + [0.0, 1.0, 1.0], + [-0.060883675, 0.99925774, 1.0], + [-0.0041872207, 0.9999481, 1.0], + [-0.00016332162, 0.99999785, 1.0], + [-2.8002216e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.026953986, 0.999662, 1.0], + [-0.00503322, 0.9999406, 1.0], + [-0.0005006954, 0.9999944, 1.0], + [-6.1892397e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.019095117, 0.9997323, 1.0], + [-0.0048538097, 0.9999398, 1.0], + [-0.00037457192, 0.9999961, 1.0], + [-3.6742229e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.03751212, 0.9993693, 1.0], + [-0.010522507, 0.99987054, 1.0], + [-0.0009128494, 0.99999034, 1.0], + [-7.887352e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.05509656, 0.99895823, 1.0], + [-0.005975121, 0.99990004, 1.0], + [-0.00018607976, 0.9999967, 1.0], + [-2.4801245e-06, 0.99999994, 1.0], + [0.0, 1.0, 1.0], + [-0.06763289, 0.9990122, 1.0], + [-0.026763257, 0.99962497, 1.0], + [-0.0033132737, 0.9999459, 1.0], + [-5.9110884e-05, 0.999999, 1.0], + [0.0, 1.0, 1.0], + [-0.06290056, 0.9987752, 1.0], + [-0.06590056, 0.9988833, 1.0], + [-0.016112784, 0.9997368, 1.0], + [-0.00030407612, 0.9999951, 1.0], + [0.0, 1.0, 1.0], + [-0.06050088, 0.9985156, 1.0], + [-0.017029036, 0.99951345, 1.0], + [-0.0026492565, 0.9999436, 1.0], + [-4.364142e-05, 0.9999992, 1.0], + [0.0, 1.0, 1.0], + [-0.21668494, 0.99283016, 1.0], + [-0.19338678, 0.9937941, 1.0], + [-0.029703936, 0.99916244, 1.0], + [-0.00055253773, 0.9999868, 1.0], + [0.0, 1.0, 1.0], + [-1.0463094, 0.95176995, 1.0], + [-1.3973116, 0.9536363, 1.0], + [-0.50282496, 0.98618114, 1.0], + [-0.019256245, 0.9995079, 1.0], + [0.0, 1.0, 1.0], + [-3.5435355, 0.84623945, 1.0], + [-5.1535788, 0.84832674, 1.0], + [-2.824056, 0.9316529, 1.0], + [-0.15195742, 0.9963698, 1.0], + [0.0, 1.0, 1.0], + [-8.673897, 0.7131587, 1.0], + [-12.270074, 0.68430734, 1.0], + [-11.376253, 0.73719627, 1.0], + [-1.8281991, 0.95513356, 1.0], + [0.0, 1.0, 1.0], + [-12.039047, 0.6432662, 1.0], + [-13.724231, 0.6535759, 1.0], + [-14.0326, 0.6799867, 1.0], + [-9.946063, 0.780004, 1.0], + [0.0, 1.0, 1.0], + [-12.528839, 0.6221673, 1.0], + [-13.425968, 0.65002877, 1.0], + [-13.075199, 0.67871696, 1.0], + [-10.942493, 0.7442244, 1.0], + [0.0, 1.0, 1.0], + [-10.306666, 0.6389583, 1.0], + [-12.0551195, 0.66896164, 1.0], + [-9.823601, 0.7308576, 1.0], + [-5.281826, 0.83818936, 1.0], + [0.0, 1.0, 1.0], + [-5.3867707, 0.65209216, 1.0], + [-5.8347864, 0.71964204, 1.0], + [-3.59081, 0.8329669, 1.0], + [-1.4847257, 0.9156909, 1.0], + [0.0, 1.0, 1.0], + [-1.754784, 0.74886227, 1.0], + [-0.24797672, 0.74751395, 1.0], + [0.6299296, 0.7613501, 1.0], + [0.82416815, 0.8632212, 1.0], + [0.0, 1.0, 1.0], + [1.4033656, 0.9101428, 1.0], + [2.4547875, 0.81642723, 1.0], + [3.6370852, 0.787311, 1.0], + [3.6531272, 0.8031764, 1.0], + [0.0, 1.0, 1.0], + [1.9459922, 0.951957, 1.0], + [2.5553524, 0.8894862, 1.0], + [3.6345732, 0.81872904, 1.0], + [2.785436, 0.8552696, 1.0], + [0.0, 1.0, 1.0], + [0.679637, 0.9787555, 1.0], + [3.8709927, 0.9172711, 1.0], + [5.7068896, 0.8849159, 1.0], + [1.1118813, 0.9758798, 1.0], + [0.0, 1.0, 1.0], + [0.3420551, 0.98117673, 1.0], + [1.5517482, 0.967525, 1.0], + [2.653233, 0.95270234, 1.0], + [0.44305915, 0.9921006, 1.0], + [0.0, 1.0, 1.0], + [0.23155965, 0.9924181, 1.0], + [0.11397146, 0.99741286, 1.0], + [0.13187227, 0.9980809, 1.0], + [0.020392587, 0.9996969, 1.0], + [0.0, 1.0, 1.0], + [0.13352406, 0.99550617, 1.0], + [0.025518132, 0.99892914, 1.0], + [-0.0005152045, 0.99990207, 1.0], + [-0.000118601536, 0.99999446, 1.0], + [0.0, 1.0, 1.0], + [0.15706417, 0.99707514, 1.0], + [0.02578307, 0.9993154, 1.0], + [-0.00202729, 0.9999331, 1.0], + [-0.00021436776, 0.9999964, 1.0], + [0.0, 1.0, 1.0], + [0.3615966, 0.99366856, 1.0], + [0.087646805, 0.99867254, 1.0], + [0.002637883, 0.99996406, 1.0], + [3.6361645e-05, 0.99999946, 1.0], + [0.0, 1.0, 1.0], + [0.1291499, 0.9977887, 1.0], + [0.046300918, 0.99929285, 1.0], + [0.012817254, 0.9998544, 1.0], + [0.00041612334, 0.9999955, 1.0], + [0.0, 1.0, 1.0], + [0.011200566, 0.9983723, 1.0], + [-0.008214572, 0.99942863, 1.0], + [0.0021838315, 0.9999416, 1.0], + [0.000100621306, 0.9999986, 1.0], + [0.0, 1.0, 1.0], + [-0.060381968, 0.9930565, 1.0], + [-0.06464544, 0.9983384, 1.0], + [-0.007711393, 0.99990016, 1.0], + [-0.00011417703, 0.9999988, 1.0], + [0.0, 1.0, 1.0], + [-0.11693042, 0.99224305, 1.0], + [-0.11525121, 0.9982498, 1.0], + [-0.027416473, 0.99979055, 1.0], + [-0.00054621015, 0.99999607, 1.0], + [0.0, 1.0, 1.0], + [0.014975694, 0.99728876, 1.0], + [-0.056130975, 0.9989796, 1.0], + [-0.0222607, 0.9997437, 1.0], + [-0.00058228534, 0.9999938, 1.0], + [0.0, 1.0, 1.0], + [0.05060913, 0.99668014, 1.0], + [-0.2700596, 0.99520206, 1.0], + [-0.14083444, 0.9978208, 1.0], + [-0.005215654, 0.9999189, 1.0], + [0.0, 1.0, 1.0], + [-0.05793456, 0.99310505, 1.0], + [-1.3339288, 0.97664934, 1.0], + [-1.0737243, 0.98243177, 1.0], + [-0.06070526, 0.9990094, 1.0], + [0.0, 1.0, 1.0], + [-0.22500885, 0.9885798, 1.0], + [-2.394239, 0.9580366, 1.0], + [-1.8970352, 0.9690168, 1.0], + [-0.109246016, 0.99819875, 1.0], + [0.0, 1.0, 1.0], + [-0.39491686, 0.98909, 1.0], + [-2.9813097, 0.9401541, 1.0], + [-2.3468347, 0.9552055, 1.0], + [-0.15643519, 0.99701655, 1.0], + [0.0, 1.0, 1.0], + [-1.8292645, 0.9594883, 1.0], + [-13.511781, 0.763661, 1.0], + [-14.536935, 0.7543218, 1.0], + [-2.2238204, 0.95809615, 1.0], + [0.0, 1.0, 1.0], + [-1.0053811, 0.97643167, 1.0], + [-12.578439, 0.77343625, 1.0], + [-15.1597395, 0.73498416, 1.0], + [-2.625323, 0.9481359, 1.0], + [0.0, 1.0, 1.0], + [-0.55154705, 0.9794314, 1.0], + [-6.9208384, 0.85034823, 1.0], + [-10.053682, 0.79368883, 1.0], + [-1.6742866, 0.96229243, 1.0], + [0.0, 1.0, 1.0], + [-0.69403476, 0.96975285, 1.0], + [-3.2779868, 0.9127137, 1.0], + [-4.176942, 0.9027185, 1.0], + [-0.5743538, 0.98639596, 1.0] + ], + "srgb_value": true + } +} diff --git a/contributors/people.toml b/contributors/people.toml index 27a8c8d9c..08d002e8b 100644 --- a/contributors/people.toml +++ b/contributors/people.toml @@ -25,9 +25,35 @@ sync_github_name = false [people.rock-meister] sync_github_name = true +[people.Th4nat0s] +display_name = "Thanat0s" + [people.sanyamsingh001] display_name = "sanyamsingh001" [people.ArekatlaNishanthchowdary] real_name = "Arekatla Nishanth Chowdary" sync_github_name = true + +[people.Losminek] +sync_github_name = true + +[people.FooVoo] +real_name = "Aleksandr Batyrev" +sync_github_name = true + +[people.Silvertree2010] +display_name = "Netter" +sync_github_name = false + +[people.nledford] +real_name = "Nathaniel Ledford" + +[people.marcoarnulfo] +real_name = "Marco Arnulfo" +display_name = "Marco Arnulfo" +sync_github_name = false + +[people.egegl] +real_name = "Ege Gursel" +sync_github_name = true diff --git a/crates/catalog/proptest-regressions/tests_index.txt b/crates/catalog/proptest-regressions/tests_index.txt new file mode 100644 index 000000000..9c827f0c0 --- /dev/null +++ b/crates/catalog/proptest-regressions/tests_index.txt @@ -0,0 +1,8 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc b91df6f4f23ec90cae3c0fb934b04b806181b61570f00ba25826e2232b7e1932 # shrinks to steps = [(3, 0, 0, false)] +cc b11415bdf5e4133b65e00645d4051129c71bf927747d6384aa50107440e92e07 # shrinks to steps = [(0, 10, 166, false), (7, 1, 0, false), (1, 14, 0, false)] diff --git a/crates/catalog/src/dates.rs b/crates/catalog/src/dates.rs index d180d013d..4d16c2f4d 100644 --- a/crates/catalog/src/dates.rs +++ b/crates/catalog/src/dates.rs @@ -49,7 +49,9 @@ pub fn shift_iso(iso: &str, secs: i64) -> Option { if !(first..=last).contains(&t) { return None; } - let tail = iso.trim().get(19..).unwrap_or(""); + let iso = iso.trim(); + // the seconds are optional (`10:00+02:00`): without them the tail starts right after the minutes + let tail = iso.get(if iso.get(16..17) == Some(":") { 19 } else { 16 }..).unwrap_or(""); Some(format!("{}{tail}", civil(t))) } @@ -222,6 +224,15 @@ mod tests { assert_eq!(normalize_iso("2026-02-30"), None); } + #[test] + fn shift_keeps_the_zone_of_a_time_without_seconds() { + assert_eq!(shift_iso("2026-01-01T10:00+02:00", 3600).as_deref(), Some("2026-01-01T11:00:00+02:00")); + assert_eq!(shift_iso("2026-01-01T10:00Z", 60).as_deref(), Some("2026-01-01T10:01:00Z")); + assert_eq!(shift_iso("2026-01-01T10:00", 3600).as_deref(), Some("2026-01-01T11:00:00")); + assert_eq!(shift_iso("2026-01-01T10:00:00.5-05:00", 3600).as_deref(), Some("2026-01-01T11:00:00.5-05:00")); + assert_eq!(iso_seconds("2026-01-01T10:00+02:00"), iso_seconds("2026-01-01T10:00")); + } + #[test] fn runs_by_day_month_year_keep_stacks_together() { let mut c = Catalog::new(); diff --git a/crates/catalog/src/index.rs b/crates/catalog/src/index.rs new file mode 100644 index 000000000..2c347473b --- /dev/null +++ b/crates/catalog/src/index.rs @@ -0,0 +1,101 @@ +//! Lookups derived from the catalog's own data, so questions asked once per photo of a big library +//! don't scan a list each time: which stack a photo is in, whether a regular album holds it. +//! +//! They are never saved (the on-disk format is unchanged). Each is built on first use from what +//! the catalog holds, so a catalog read from a snapshot, replayed from the journal or cloned starts +//! without them and builds them when asked. Once built, [`crate::Catalog::apply`] keeps them up to +//! date: the ops that change stacks or album photos (`AddStack`, `RemoveStack`, `SetStack`, +//! `AddAlbum`, `RemoveAlbum`, `SetAlbumPhotos`) update them as they apply, inverses and the +//! rollback of a failed batch included, as those are ops too. + +use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; + +use crate::{AlbumId, Catalog, PhotoId, StackId}; + +#[derive(Default)] +pub(crate) struct Indexes { + /// Photo → the stack it is in; `None` when the catalog was read with a photo in two stacks + /// (only a hand-edited file can hold that: ops refuse it), where a scan says which one counts. + stacks: OnceLock>>, + /// Album → its photos (regular albums; a smart album's list is empty). + albums: OnceLock>>, +} + +/// A copy builds its own when asked (copies are made to be saved, not queried). +impl Clone for Indexes { + fn clone(&self) -> Self { + Indexes::default() + } +} + +/// Derived from the rest of the catalog: two catalogs with the same data are equal whatever was +/// built. +impl PartialEq for Indexes { + fn eq(&self, _: &Self) -> bool { + true + } +} + +impl std::fmt::Debug for Indexes { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let stacks = self.stacks.get().map(|m| m.as_ref().map(HashMap::len)); + f.debug_struct("Indexes").field("stacks", &stacks).field("albums", &self.albums.get().map(HashMap::len)).finish() + } +} + +impl Indexes { + /// Stack `id` held `old` and holds `new` now (empty: it was added, or is gone). + pub(crate) fn stack_changed(&mut self, id: StackId, old: &[PhotoId], new: &[PhotoId]) { + let Some(Some(m)) = self.stacks.get_mut() else { return }; + for p in old { + if m.get(p) == Some(&id) { + m.remove(p); + } + } + for p in new { + m.insert(*p, id); + } + } + + /// Album `id` holds `photos` now (`None`: it is gone). + pub(crate) fn album_changed(&mut self, id: AlbumId, photos: Option<&[PhotoId]>) { + let Some(m) = self.albums.get_mut() else { return }; + match photos { + Some(photos) => { + m.insert(id, photos.iter().copied().collect()); + } + None => { + m.remove(&id); + } + } + } +} + +impl Catalog { + /// The stack photo `photo` is in, by the index (built on first use). + pub(crate) fn stack_id_of(&self, photo: PhotoId) -> Option { + let m = self.index.stacks.get_or_init(|| { + let mut m = HashMap::new(); + for s in self.stacks.values() { + for p in &s.photos { + if m.insert(*p, s.id).is_some_and(|other| other != s.id) { + return None; + } + } + } + Some(m) + }); + match m { + Some(m) => m.get(&photo).copied(), + // in id order, the first stack listing the photo has it + None => self.stacks.values().find(|s| s.photos.contains(&photo)).map(|s| s.id), + } + } + + /// Whether album `album`'s own list holds `photo`, by the index (built on first use). + pub(crate) fn album_lists(&self, album: AlbumId, photo: PhotoId) -> bool { + let m = self.index.albums.get_or_init(|| self.albums.values().map(|a| (a.id, a.photos.iter().copied().collect())).collect()); + m.get(&album).is_some_and(|s| s.contains(&photo)) + } +} diff --git a/crates/catalog/src/journal.rs b/crates/catalog/src/journal.rs index 98fd616a3..edcfae22a 100644 --- a/crates/catalog/src/journal.rs +++ b/crates/catalog/src/journal.rs @@ -51,6 +51,7 @@ //! | 6 | folder labels | `Op::SetFolderRecord`, `Catalog.folder_records` | //! | 7 | Kelvin white balance for raws whose camera has matrices (#730) | `Op::SetAsShotWb`, `WhiteBalance.scale` | //! | 8 | History cursor: Go to History Step adds no step | `Op::SetHistoryCursor`, `Photo.history_cursor` | +//! | 9 | preview-only recheck | `Photo.preview_checked`, `Op::SetContent.preview_checked` | //! //! Rules: //! - **Bump [`VERSION`]** (and add a row above) in the change that adds an [`Op`] variant or a @@ -75,7 +76,7 @@ pub const LOG: &str = "catalog.log"; const FORMAT: &str = "lightcraft-catalog"; /// The catalog format this build writes (and the newest it reads). See the module docs → /// *Format versions*; bump it whenever an [`Op`] variant or a serialized field is added. -pub const VERSION: u32 = 8; +pub const VERSION: u32 = 9; /// When [`Journal::wants_snapshot`] says it's time to compact the log. #[derive(Clone, Copy, Debug)] @@ -270,7 +271,7 @@ fn resync(line: &[u8], last: u64) -> Option<(usize, (u64, Op))> { const START: &[u8] = b"{\"seq\":"; (1..line.len().saturating_sub(START.len() - 1)).filter(|&i| line[i..].starts_with(START)).find_map(|i| { let rec = std::str::from_utf8(&line[i..]).ok().map(|l| l.trim_end_matches('\r')).and_then(decode_record)?; - (rec.0 <= last + 1).then_some((i, rec)) + (rec.0 <= last.saturating_add(1)).then_some((i, rec)) }) } @@ -297,6 +298,9 @@ fn write_snapshot(store: &mut dyn Store, seq: u64, catalog: &Catalog) -> std::io Ok(SnapshotTiming { serialize_ms, write_sync_ms: (ms_since(t0) - serialize_ms).max(0.0), bytes, ..Default::default() }) } +/// The highest `seq` a snapshot may hold; beyond it the counter is treated as corrupt, far from overflow. +const MAX_SEQ: u64 = u64::MAX / 2; + /// Just the identification of `catalog.snap` (read before the catalog itself). #[derive(serde::Deserialize)] struct SnapHeader { @@ -330,6 +334,10 @@ impl Journal { return Err(newer(format!("{SNAPSHOT} is catalog format v{}", h.version))); } let s: SnapFile = serde_json::from_slice(&bytes).map_err(|e| CatalogError::Corrupt(format!("{SNAPSHOT}: {e}")))?; + // a seq this high can't come from real use; the next record's seq would wrap + if s.seq > MAX_SEQ { + return Err(CatalogError::Corrupt(format!("{SNAPSHOT}: seq {} is out of range", s.seq))); + } let mut catalog = s.catalog; report.repaired = catalog.repair(); for r in &report.repaired { @@ -391,7 +399,7 @@ impl Journal { let _ = op; report.stale += 1; } - Some((seq, op)) if seq == j.seq + 1 => { + Some((seq, op)) if j.seq.checked_add(1) == Some(seq) => { if catalog.apply(op).is_err() { report.failed += 1; } else { @@ -475,7 +483,7 @@ impl Journal { let mut buf = String::new(); let mut seq = self.seq; for op in ops { - seq += 1; + seq = seq.checked_add(1).ok_or_else(|| CatalogError::Corrupt(format!("{LOG}: op counter overflow")))?; buf.push_str(&encode_record(seq, op)); buf.push('\n'); } @@ -526,7 +534,7 @@ impl Journal { log::warn!("catalog: background snapshot failed ({e}); writing one now"); } let t0 = web_time::Instant::now(); - let seq = self.seq + unlogged; + let seq = self.seq.checked_add(unlogged).ok_or_else(|| CatalogError::Corrupt(format!("{SNAPSHOT}: op counter overflow")))?; let mut timing = match write_snapshot(self.store.as_mut(), seq, catalog) { Ok(t) => t, Err(e) => { diff --git a/crates/catalog/src/lib.rs b/crates/catalog/src/lib.rs index ed4cb75b4..1776c895c 100644 --- a/crates/catalog/src/lib.rs +++ b/crates/catalog/src/lib.rs @@ -15,6 +15,7 @@ pub mod dates; pub mod folders; +mod index; pub mod journal; pub mod keywords; pub mod local; @@ -25,6 +26,7 @@ pub mod rules; pub mod safe_file; pub mod stacks; pub mod store; +pub mod tally; use std::collections::BTreeMap; use std::sync::Arc; @@ -214,6 +216,10 @@ pub enum Op { /// `None` = its raw data decodes (or it isn't a raw). #[serde(default, skip_serializing_if = "Option::is_none")] preview_only: Option, + /// The app version that decided `preview_only` (see [`Photo::preview_checked`]); empty + /// when it is `None`. + #[serde(default, skip_serializing_if = "String::is_empty")] + preview_checked: String, }, /// The lens data a photo's file carries (what Reload finds when it was read after import). SetEmbeddedLens { @@ -281,6 +287,74 @@ pub struct Catalog { /// Increments on every applied op. #[serde(skip)] pub revision: u64, + /// Which photos the latest ops touched ([`Catalog::touched_since`]). + #[serde(skip)] + changes: Changes, + /// Lookups derived from the above (never saved), see [`index`]. + #[serde(skip)] + index: index::Indexes, +} + +/// What of a photo an op changed: enough to tell which tallies of the library an edit leaves as they were (a +/// rating changes no folder's count; a slider changes none at all). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Aspects(u16); + +impl Aspects { + pub const NONE: Aspects = Aspects(0); + pub const RATING: Aspects = Aspects(1); + pub const FLAG: Aspects = Aspects(1 << 1); + pub const LABEL: Aspects = Aspects(1 << 2); + /// Develop settings, versions and history. + pub const DEVELOP: Aspects = Aspects(1 << 3); + /// Title, caption, keywords, people, place and the rest of the metadata. + pub const META: Aspects = Aspects(1 << 4); + pub const CAPTURED: Aspects = Aspects(1 << 5); + /// Where its file is. + pub const FILE: Aspects = Aspects(1 << 6); + pub const ANALYSIS: Aspects = Aspects(1 << 7); + /// Whether it is in the library at all: added, removed, deleted, restored, taken in from Local. + pub const PRESENCE: Aspects = Aspects(1 << 8); + /// Everything: the photo may count differently for any tally. + pub const ALL: Aspects = Aspects(u16::MAX); + + pub const fn with(self, other: Aspects) -> Aspects { + Aspects(self.0 | other.0) + } + + /// Whether any of `other` is among these. + pub const fn any_of(self, other: Aspects) -> bool { + self.0 & other.0 != 0 + } +} + +/// The most photos remembered as touched; older changes are then answered with "anything may have changed". +const MAX_TOUCHED: usize = 8192; + +/// A short memory of what the latest ops changed, so that what is derived from the catalog (the grid's list) can +/// follow a small edit without being worked out again from every photo. +#[derive(Clone, Debug, Default)] +struct Changes { + /// The photos ops changed, each with the revision the op made and what of the photo it changed. Oldest first. + touched: std::collections::VecDeque<(u64, PhotoId, Aspects)>, + /// What the op being applied changes of its photo. + now: Aspects, + /// The latest revision made by an op that changed more than photos of its own (an album, a stack, a keyword of + /// the list…): what it changed for which photos is not known. + broad: u64, + /// Changes up to this revision are no longer all in `touched`. + floor: u64, + /// The revision the last op recorded here made. When the catalog's revision is another, it was set from + /// outside (a library just loaded takes a range of its own, so that nothing kept from another library is + /// taken for this one's): nothing is known of how the catalog got to where it is. + seen: u64, +} + +impl PartialEq for Changes { + /// Two catalogs are the same catalog whatever each remembers of how it got there. + fn eq(&self, _: &Self) -> bool { + true + } } impl Catalog { @@ -365,13 +439,18 @@ impl Catalog { /// no smart album). pub fn album_contains(&self, id: AlbumId, p: &Photo) -> bool { match self.albums.get(&id) { - // guarded: a smart album testing smart albums can't loop or recurse without end - Some(Album { smart: Some(rules), .. }) => !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches(p, self)), - Some(a) => a.photos.contains(&p.id), + Some(Album { smart: Some(rules), .. }) => self.smart_album_has(id, rules, &rules.prepare_one(), p), + Some(a) => self.album_lists(a.id, p.id), None => false, } } + /// Whether smart album `id` with `rules` (as [`Filter::prepare`]d) holds `p`. + fn smart_album_has(&self, id: AlbumId, rules: &Filter, prepared: &query::Prepared, p: &Photo) -> bool { + // guarded: a smart album testing smart albums can't loop or recurse without end + !p.deleted && rules::smart_album_holds(id, p.id, || rules.matches_in(p, self, prepared)) + } + /// Whether smart album `from` tests `to`, directly or through the smart albums it tests (the /// rules of `from` would then change when those of `to` do). Loops in saved rules end the /// search, they don't repeat it. @@ -419,7 +498,10 @@ impl Catalog { /// The photos of an album: the stored list, or a smart album's current matches (id order). pub fn album_photos(&self, id: AlbumId) -> Vec { match self.albums.get(&id) { - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).map(|p| p.id).collect(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).map(|p| p.id).collect() + } Some(a) => a.photos.clone(), None => Vec::new(), } @@ -429,7 +511,10 @@ impl Catalog { pub fn album_count(&self, id: AlbumId) -> usize { match self.albums.get(&id) { // counted in place: no id list is built just for its length - Some(Album { smart: Some(_), .. }) => self.photos.values().filter(|p| self.album_contains(id, p)).count(), + Some(Album { smart: Some(rules), .. }) => { + let prepared = rules.prepare(); + self.photos.values().filter(|p| self.smart_album_has(id, rules, &prepared, p)).count() + } Some(a) => a.photos.iter().filter(|p| self.photos.get(p).is_some_and(|p| !p.deleted)).count(), None => 0, } @@ -479,20 +564,110 @@ impl Catalog { // ---- writes fn photo_mut(&mut self, id: PhotoId) -> Result<&mut Photo> { + self.touch(id); self.photos.get_mut(&id).map(Arc::make_mut).ok_or(CatalogError::NoPhoto(id)) } + + /// The op being applied changes photo `id` (or adds or removes it). + fn touch(&mut self, id: PhotoId) { + // (the revision the op will make: `apply` counts up once it is done) + self.changes.touched.push_back((self.revision.saturating_add(1), id, self.changes.now)); + while self.changes.touched.len() > MAX_TOUCHED { + if let Some((revision, _, _)) = self.changes.touched.pop_front() { + self.changes.floor = revision; + } + } + } + + /// What of its photos the ops since `revision` changed, taken together ([`Aspects::NONE`] when there were + /// none). `None` when that is not known ([`Catalog::touched_since`]). A tally of the library that reads only + /// other things of a photo is still right. + pub fn aspects_since(&self, revision: u64) -> Option { + if !self.remembers(revision) { + return None; + } + Some(self.changes.touched.iter().filter(|(r, _, _)| *r > revision).fold(Aspects::NONE, |all, (_, _, a)| all.with(*a))) + } + + /// Whether every change since the catalog was at `revision` is remembered photo by photo. + fn remembers(&self, revision: u64) -> bool { + self.changes.seen == self.revision && revision <= self.revision && revision >= self.changes.floor && self.changes.broad <= revision + } + + /// The photos changed, added or removed since the catalog was at `revision`, each once. `None` when that is not + /// known: an op since then changed more than photos of its own, or it was too long ago. Then anything may + /// have changed. + pub fn touched_since(&self, revision: u64) -> Option> { + if !self.remembers(revision) { + return None; + } + let mut ids: Vec = self.changes.touched.iter().filter(|(r, _, _)| *r > revision).map(|(_, id, _)| *id).collect(); + ids.sort_unstable(); + ids.dedup(); + Some(ids) + } fn album_mut(&mut self, id: AlbumId) -> Result<&mut Album> { self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id)) } /// Apply an op; returns its inverse. On error nothing changes. pub fn apply(&mut self, op: Op) -> Result { + if self.changes.seen != self.revision { + // the revision was set from outside: what is remembered is of another past + self.changes = Changes { floor: self.revision, seen: self.revision, ..Default::default() }; + } let inv = self.apply_inner(op)?; self.revision += 1; + self.changes.seen = self.revision; Ok(inv) } fn apply_inner(&mut self, op: Op) -> Result { + // An op that changes one photo says which (`photo_mut`, and adding or removing it below). Every other kind + // can change what any photo matches or where it sorts: an album's photos or rules, a stack, a keyword's + // name. Listed kind by kind, so a new one has to be put on one side. + match &op { + // (a photo that comes, goes, or changes what it is to the library counts for every tally; so do the + // rare kinds whose reach nobody has to think about) + Op::AddPhoto { photo } => { + self.changes.now = Aspects::ALL; + self.touch(photo.id); + } + Op::RemovePhoto { id } => { + self.changes.now = Aspects::ALL; + self.touch(*id); + } + Op::SetDeleted { .. } | Op::SetLocal { .. } | Op::SetContent { .. } | Op::SetEmbeddedLens { .. } | Op::SetAsShotWb { .. } => { + self.changes.now = Aspects::ALL + } + Op::SetRating { .. } => self.changes.now = Aspects::RATING, + Op::SetFlag { .. } => self.changes.now = Aspects::FLAG, + Op::SetLabel { .. } => self.changes.now = Aspects::LABEL, + Op::SetMeta { .. } => self.changes.now = Aspects::META, + Op::SetCaptured { .. } => self.changes.now = Aspects::CAPTURED, + Op::SetFile { .. } | Op::Relink { .. } => self.changes.now = Aspects::FILE, + Op::SetAnalysis { .. } => self.changes.now = Aspects::ANALYSIS, + Op::SetDevelop { .. } | Op::SetVersions { .. } | Op::SetHistory { .. } | Op::PushHistory { .. } | Op::SetHistoryCursor { .. } => { + self.changes.now = Aspects::DEVELOP + } + // (its ops say for themselves, one by one) + Op::Batch { .. } => {} + Op::AddAlbum { .. } + | Op::RemoveAlbum { .. } + | Op::RenameAlbum { .. } + | Op::MoveAlbum { .. } + | Op::SetAlbumOrder { .. } + | Op::SetAlbumPhotos { .. } + | Op::SetAlbumCover { .. } + | Op::SetAlbumRules { .. } + | Op::AddStack { .. } + | Op::RemoveStack { .. } + | Op::SetStack { .. } + | Op::SetLabelName { .. } + | Op::SetBrowsed { .. } + | Op::SetKeyword { .. } + | Op::SetFolderRecord { .. } => self.changes.broad = self.revision.saturating_add(1), + } Ok(match op { Op::AddPhoto { photo } => { if self.photos.contains_key(&photo.id) { @@ -590,6 +765,7 @@ impl Catalog { } let id = album.id; self.next_album = self.next_album.max(id.0.saturating_add(1)); + self.index.album_changed(id, Some(&album.photos)); self.albums.insert(id, album); Op::RemoveAlbum { id } } @@ -598,6 +774,7 @@ impl Catalog { return Err(CatalogError::Invalid("folder is not empty".into())); } let a = self.albums.remove(&id).ok_or(CatalogError::NoAlbum(id))?; + self.index.album_changed(id, None); Op::AddAlbum { album: a } } Op::RenameAlbum { id, name } => { @@ -637,13 +814,16 @@ impl Catalog { Op::SetAlbumOrder { id, order: std::mem::replace(&mut a.order, order) } } Op::SetAlbumPhotos { id, photos } => { - let a = self.album_mut(id)?; + // the field, not `album_mut`: the index is updated while the album is borrowed + let a = self.albums.get_mut(&id).ok_or(CatalogError::NoAlbum(id))?; if (a.folder || a.smart.is_some()) && !photos.is_empty() { return Err(CatalogError::Invalid( if a.folder { "folders can't hold photos" } else { "smart albums update automatically" }.into(), )); } - Op::SetAlbumPhotos { id, photos: std::mem::replace(&mut a.photos, photos) } + let old = std::mem::replace(&mut a.photos, photos); + self.index.album_changed(id, Some(&a.photos)); + Op::SetAlbumPhotos { id, photos: old } } Op::SetAlbumCover { id, cover } => { let a = self.album_mut(id)?; @@ -664,11 +844,13 @@ impl Catalog { self.validate_stack(stack.id, &stack.photos)?; let id = stack.id; self.next_stack = self.next_stack.max(id.0.saturating_add(1)); + self.index.stack_changed(id, &[], &stack.photos); self.stacks.insert(id, stack); Op::RemoveStack { id } } Op::RemoveStack { id } => { let s = self.stacks.remove(&id).ok_or(CatalogError::NoStack(id))?; + self.index.stack_changed(id, &s.photos, &[]); Op::AddStack { stack: s } } Op::SetStack { id, photos, collapsed } => { @@ -677,7 +859,9 @@ impl Catalog { } self.validate_stack(id, &photos)?; let s = self.stacks.get_mut(&id).ok_or(CatalogError::NoStack(id))?; - let old = Op::SetStack { id, photos: std::mem::replace(&mut s.photos, photos), collapsed: s.collapsed }; + let old_photos = std::mem::replace(&mut s.photos, photos); + self.index.stack_changed(id, &old_photos, &s.photos); + let old = Op::SetStack { id, photos: old_photos, collapsed: s.collapsed }; s.collapsed = collapsed; old } @@ -700,7 +884,7 @@ impl Catalog { let old_format = format.map(|f| std::mem::replace(&mut p.format, f)); Op::Relink { id, file_name: old_name, source: std::mem::replace(&mut p.source, source), format: old_format } } - Op::SetContent { id, width, height, file_size, content_hash, preview_only } => { + Op::SetContent { id, width, height, file_size, content_hash, preview_only, preview_checked } => { let p = self.photo_mut(id)?; Op::SetContent { id, @@ -709,6 +893,7 @@ impl Catalog { file_size: std::mem::replace(&mut p.file_size, file_size), content_hash: std::mem::replace(&mut p.content_hash, content_hash), preview_only: std::mem::replace(&mut p.preview_only, preview_only), + preview_checked: std::mem::replace(&mut p.preview_checked, preview_checked), } } Op::SetEmbeddedLens { id, lens } => { @@ -914,6 +1099,10 @@ impl Catalog { self.next_album = self.next_album.max(album); self.next_stack = self.next_stack.max(stack); } + if !changed.is_empty() { + // stacks and albums changed outside `apply`: the lookups are built again when asked + self.index = index::Indexes::default(); + } changed } @@ -963,6 +1152,8 @@ mod tests_folders; #[cfg(test)] mod tests_format_version; #[cfg(test)] +mod tests_index; +#[cfg(test)] mod tests_journal; #[cfg(test)] mod tests_local; @@ -971,4 +1162,6 @@ mod tests_lock; #[cfg(test)] mod tests_repair; #[cfg(test)] +mod tests_scale; +#[cfg(test)] mod tests_torn_append; diff --git a/crates/catalog/src/local.rs b/crates/catalog/src/local.rs index 9cfc14f35..093c22427 100644 --- a/crates/catalog/src/local.rs +++ b/crates/catalog/src/local.rs @@ -183,15 +183,7 @@ impl Catalog { let mut plan = ForgetPlan { now: now.to_string(), ..Default::default() }; let now_s = crate::stacks::iso_seconds(now); let max_age = i64::from(days) * 86_400; - let mut referenced: HashSet = HashSet::new(); - for a in self.albums.values() { - referenced.extend(a.photos.iter().copied()); - referenced.extend(a.cover); - } - for s in self.stacks.values() { - referenced.extend(s.photos.iter().copied()); - } - referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + let referenced = self.referenced(); // the fingerprints are the costly part: work them out for the candidates in parallel let candidates: Vec<&Photo> = self .photos @@ -237,6 +229,30 @@ impl Catalog { plan.unstamp = self.browsed.keys().filter(|f| !remaining.contains(*f)).cloned().collect(); plan } + + /// Of `ids`, the untouched Local records nothing refers to (no album, stack or virtual copy): + /// the ones [`Catalog::forget_local_plan`] could forget, whatever their folder's time. + pub fn forgettable_local(&self, ids: &[PhotoId]) -> Vec { + let referenced = self.referenced(); + let candidates: Vec<&Photo> = + ids.iter().filter(|id| !referenced.contains(id)).filter_map(|id| self.photos.get(id)).filter(|p| p.local).map(|p| &**p).collect(); + let untouched = untouched_of(&candidates); + ids.iter().copied().filter(|id| untouched.contains(id)).collect() + } + + /// Photos an album (as a member or its cover), a stack or a virtual copy refers to. + fn referenced(&self) -> HashSet { + let mut referenced: HashSet = HashSet::new(); + for a in self.albums.values() { + referenced.extend(a.photos.iter().copied()); + referenced.extend(a.cover); + } + for s in self.stacks.values() { + referenced.extend(s.photos.iter().copied()); + } + referenced.extend(self.photos.values().filter_map(|p| p.copy_of)); + referenced + } } /// A folder browsed at `at` is past the period (never when `days` is 0 or a time is unreadable). diff --git a/crates/catalog/src/model.rs b/crates/catalog/src/model.rs index 434ea3a1e..6ac3544ee 100644 --- a/crates/catalog/src/model.rs +++ b/crates/catalog/src/model.rs @@ -199,6 +199,11 @@ pub struct Meta { /// Left out of the catalog JSON when empty (most photos), so older catalogs read unchanged. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub regions: Vec, + /// Where the camera focused (read from the file's maker notes at import or Reload from Disk; Sony + /// bodies whose files have been checked), on the upright (EXIF-oriented) photo. Read-only: never + /// written to XMP. Left out of the catalog JSON when unknown, so older catalogs read unchanged. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -293,6 +298,12 @@ pub struct Photo { /// picture style baked in — so it is treated as a rendered (non-raw) source. #[serde(default, skip_serializing_if = "Option::is_none")] pub preview_only: Option, + /// The app version (`CARGO_PKG_VERSION`) whose decoder set [`Photo::preview_only`]; empty when + /// it isn't set, and in catalogs from before format v9. A preview-only photo whose stamp + /// differs from the running version is rechecked when its library opens (issue #662): newer + /// builds may decode the file, older ones may decode less. + #[serde(default, skip_serializing_if = "String::is_empty")] + pub preview_checked: String, /// A Local record: the fingerprint of its state when the browse catalogued it, to tell /// whether the user changed anything since (see [`crate::local`]). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -318,7 +329,7 @@ pub struct Analysis { /// file's white balance is edited relative to its as-shot look or in Kelvin depends on the camera: /// [`relative_wb_camera`]. pub fn relative_wb_format(format: &str) -> bool { - ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF"].iter().any(|f| format.eq_ignore_ascii_case(f)) + ["ARW", "NEF", "NRW", "RW2", "RWL", "RAW", "RAF", "CR3", "CR2", "PEF", "SRW", "ORF", "ERF"].iter().any(|f| format.eq_ignore_ascii_case(f)) } /// Whether a raw of `format` shot on `camera` (Exif make and model, as [`Meta::camera`] keeps them) is developed @@ -332,6 +343,16 @@ pub fn relative_wb_camera(format: &str, camera: &str) -> bool { relative_wb_format(format) && lightcraft_raw::spectral::find_camera(camera).is_none() && !lightcraft_raw::kelvin_presets::covers_camera(camera) } +/// This build's version: what a photo's [`Photo::preview_checked`] stamp is compared with. Every +/// workspace crate shares one version, so it is the app's. +pub const APP_VERSION: &str = env!("CARGO_PKG_VERSION"); + +/// The [`Photo::preview_checked`] stamp for a `preview_only` decided by this build: its version +/// when the photo is preview-only, empty otherwise. +pub fn preview_stamp(preview_only: &Option) -> String { + if preview_only.is_some() { APP_VERSION.to_string() } else { String::new() } +} + impl Photo { /// The History step the settings are at (the cursor, clamped to the list; `None` = no history). pub fn history_index(&self) -> Option { @@ -371,9 +392,20 @@ impl Photo { import_look: None, analysis: None, preview_only: None, + preview_checked: String::new(), local_baseline: None, } } + /// Sets [`Photo::preview_only`] as this build decided it, with its stamp. + pub fn set_preview_only(&mut self, preview_only: Option) { + self.preview_checked = preview_stamp(&preview_only); + self.preview_only = preview_only; + } + /// A preview-only photo whose flag was set by another app version (or before stamps existed), + /// so this build should probe its file again (issue #662). + pub fn preview_only_stale(&self) -> bool { + self.preview_only.is_some() && self.preview_checked != APP_VERSION + } /// A raw file developed from its sensor data: not a rendered image, and not a raw shown from /// its embedded preview ([`Photo::preview_only`]). pub fn develops_raw(&self) -> bool { @@ -577,6 +609,7 @@ mod edited_tests { ("a.cr2", "CR2", true), ("a.pef", "pef", true), ("a.orf", "ORF", true), + ("a.erf", "ERF", true), ("a.dng", "DNG", false), ] { let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 0 }, name, format, 10, 10, "2026-10-01T00:00:00"); @@ -609,6 +642,9 @@ mod edited_tests { ("SONY ILCE-7RM4A", "ARW", false), ("NIKON CORPORATION NIKON D850", "NEF", false), ("Canon Canon EOS 5D Mark III", "CR2", false), + ("Canon Canon EOS R6m2", "CR3", false), + ("Canon Canon EOS R6 Mark II", "CR3", false), + ("Canon Canon EOS R7", "CR3", true), ("FUJIFILM X-T4", "RAF", false), ("ILCE-7M3", "ARW", false), ("SONY ILCE-7M2", "ARW", true), @@ -624,7 +660,11 @@ mod edited_tests { p.as_shot_wb = Some((4986.0, -2.0)); assert_eq!(p.relative_wb(), relative, "{camera} {format}"); assert_eq!(relative_wb_camera(format, camera), relative, "{camera} {format}"); - assert_eq!(p.fitted_look(), format == "ARW" || format == "NEF" || format == "CR2" || format == "RAF", "{camera} {format}"); + assert_eq!( + p.fitted_look(), + format == "ARW" || format == "NEF" || format == "CR2" || format == "CR3" || format == "RAF", + "{camera} {format}" + ); let wb = p.camera_defaults().wb; assert_eq!((wb.temp, wb.tint), if relative { (6500.0, 0.0) } else { (4986.0, -2.0) }, "{camera} {format}"); assert_eq!(wb.scale, if relative { WbScale::Legacy } else { WbScale::Kelvin }, "{camera} {format}"); diff --git a/crates/catalog/src/query.rs b/crates/catalog/src/query.rs index a9956703d..7625c9ab6 100644 --- a/crates/catalog/src/query.rs +++ b/crates/catalog/src/query.rs @@ -94,6 +94,27 @@ pub struct Sort { pub seed: u64, } +impl Sort { + /// Which of two photos comes first in this order (never `Equal` for two photos: the id decides last). + pub fn order(&self, a: &Photo, b: &Photo) -> std::cmp::Ordering { + let o = match self.key { + SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), + SortKey::ImportDate => a.imported.cmp(&b.imported), + SortKey::EditDate => a.edited.cmp(&b.edited), + SortKey::FileName => cmp_name(&a.file_name, &b.file_name), + SortKey::Rating => a.rating.cmp(&b.rating), + SortKey::FileSize => a.file_size.cmp(&b.file_size), + SortKey::Random => shuffle_rank(self.seed, a.id).cmp(&shuffle_rank(self.seed, b.id)), + } + // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the + // order cameras number frames, so the order doesn't depend on how ids were allocated; + // the id only keeps it stable. + .then_with(|| if self.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) + .then(a.id.cmp(&b.id)); + if self.ascending { o } else { o.reverse() } + } +} + impl Default for Sort { fn default() -> Self { Sort { key: SortKey::CaptureDate, ascending: false, group: crate::GroupBy::Auto, seed: 0 } @@ -114,6 +135,12 @@ fn shuffle_rank(seed: u64, id: PhotoId) -> u64 { mix64(mix64(seed) ^ id.0) } +/// What [`Filter::prepare`] works out once for a pass over many photos. +pub(crate) struct Prepared { + root: Option, + only: Option>, +} + fn token_matches(p: &Photo, tok: &str) -> bool { let t = tok.to_lowercase(); if let Some((field, val)) = t.split_once(':') { @@ -130,13 +157,20 @@ fn token_matches(p: &Photo, tok: &str) -> bool { Some((_, v)) => (x - v).abs() < 1e-9, None => false, }; + // Fields stored as `f32` compare at f32 precision: 2.8f32 widened to f64 is not 2.8. + let cmp32 = |x: f32, s: &str| match num(s) { + Some(('>', v)) => x > v as f32, + Some(('<', v)) => x < v as f32, + Some((_, v)) => x == v as f32, + None => false, + }; return match field { "rating" | "stars" => cmp(p.rating as f64, val), "flag" => Flag::parse(val) == Some(p.flag), "label" | "color" => p.label.is_some_and(|l| format!("{l:?}").eq_ignore_ascii_case(val)), "iso" => p.meta.iso.is_some_and(|i| cmp(i as f64, val)), - "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp(a as f64, val)), - "focal" => p.meta.focal_mm.is_some_and(|a| cmp(a as f64, val)), + "f" | "aperture" => p.meta.aperture.is_some_and(|a| cmp32(a, val)), + "focal" => p.meta.focal_mm.is_some_and(|a| cmp32(a, val)), "camera" => p.meta.camera.to_lowercase().contains(val), "lens" => p.meta.lens.to_lowercase().contains(val), "keyword" | "kw" => p.meta.keywords.iter().any(|k| crate::keywords::is_under(k, val)), @@ -146,11 +180,18 @@ fn token_matches(p: &Photo, tok: &str) -> bool { "date" => p.captured.as_deref().is_some_and(|c| c.starts_with(val)), "copy" | "virtual" => (val == "true" || val == "yes") == p.copy_of.is_some(), "name" | "file" => p.file_name.to_lowercase().contains(val), - _ => false, + // Not a known field: the colon is part of a plain word ("10:30", "Re:"). + _ => text_matches(p, &t), }; } + text_matches(p, &t) +} + +/// Whether the lower-cased word `t` is in the file name, title, caption, camera, lens, location, +/// format or a keyword of `p`. +fn text_matches(p: &Photo, t: &str) -> bool { let hay = [&p.file_name, &p.meta.title, &p.meta.caption, &p.meta.camera, &p.meta.lens, &p.meta.location, &p.format]; - hay.iter().any(|h| h.to_lowercase().contains(&t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(&t)) + hay.iter().any(|h| h.to_lowercase().contains(t)) || p.meta.keywords.iter().any(|k| k.to_lowercase().contains(t)) } /// Whether `p` has a named face region called `name` (case-insensitive). @@ -236,7 +277,18 @@ impl Filter { } pub fn matches(&self, p: &Photo, cat: &Catalog) -> bool { - self.matches_in(p, cat, self.library_root().as_deref()) + self.matches_in(p, cat, &self.prepare_one()) + } + + /// [`Filter::prepare`] for testing one photo (the `only` ids are looked through, not hashed). + pub(crate) fn prepare_one(&self) -> Prepared { + Prepared { root: self.library_root(), only: None } + } + + /// What [`Filter::matches_in`] needs worked out once for a pass over many photos rather than + /// once per photo: the library folder's identity and the `only` ids as a set. + pub(crate) fn prepare(&self) -> Prepared { + Prepared { root: self.library_root(), only: (!self.only.is_empty()).then(|| self.only.iter().copied().collect()) } } /// [`Filter::library_folder`] as a folder identity ([`folder_key`]); `None` without a choice. @@ -246,9 +298,9 @@ impl Filter { self.library_folder.as_deref().filter(|d| !d.trim().is_empty()).map(folder_key) } - /// [`Filter::matches`] with the library folder already turned into its identity, so a query - /// over a big library does that once, not once per photo. - fn matches_in(&self, p: &Photo, cat: &Catalog, root: Option<&str>) -> bool { + /// [`Filter::matches`] with what [`Filter::prepare`] works out done already, so a query over a + /// big library does that once, not once per photo. + pub(crate) fn matches_in(&self, p: &Photo, cat: &Catalog, prepared: &Prepared) -> bool { if p.deleted != self.deleted { return false; } @@ -287,7 +339,7 @@ impl Filter { if !self.labels.is_empty() && !p.label.is_some_and(|l| self.labels.contains(&l)) { return false; } - if !self.only.is_empty() && !self.only.contains(&p.id) { + if !self.only.is_empty() && !prepared.only.as_ref().map_or_else(|| self.only.contains(&p.id), |only| only.contains(&p.id)) { return false; } if let Some(want) = &self.merged { @@ -327,7 +379,7 @@ impl Filter { { return false; } - if let Some(root) = root + if let Some(root) = prepared.root.as_deref() && !photo_in_root(p, root) { return false; @@ -369,38 +421,66 @@ pub fn cmp_name(a: &str, b: &str) -> std::cmp::Ordering { impl Catalog { /// Photos matching `filter`, in `sort` order (ties broken by file name, then id). pub fn query(&self, filter: &Filter, sort: &Sort) -> Vec { - let root = filter.library_root(); - let mut v: Vec<&Photo> = self.photos().map(|p| p.as_ref()).filter(|p| filter.matches_in(p, self, root.as_deref())).collect(); - v.sort_by(|a, b| { - let o = match sort.key { - SortKey::CaptureDate => a.captured.cmp(&b.captured).then_with(|| a.imported.cmp(&b.imported)), - SortKey::ImportDate => a.imported.cmp(&b.imported), - SortKey::EditDate => a.edited.cmp(&b.edited), - SortKey::FileName => cmp_name(&a.file_name, &b.file_name), - SortKey::Rating => a.rating.cmp(&b.rating), - SortKey::FileSize => a.file_size.cmp(&b.file_size), - SortKey::Random => shuffle_rank(sort.seed, a.id).cmp(&shuffle_rank(sort.seed, b.id)), - } - // Ties (one import batch, a burst within a second, equal ratings) sort by file name, the - // order cameras number frames, so the order doesn't depend on how ids were allocated; - // the id only keeps it stable. - .then_with(|| if sort.key == SortKey::FileName { Equal } else { cmp_name(&a.file_name, &b.file_name) }) - .then(a.id.cmp(&b.id)); - if sort.ascending { o } else { o.reverse() } - }); + self.query_keeping(filter, sort, &std::collections::HashSet::new(), filter) + } + + /// [`Catalog::query`], and with them the photos of `keep` that still match `within`, each at its place in `sort` + /// order: a view that holds the photos it showed when an edit makes them stop matching `filter` (a smart + /// album's rules, the filter bar), as long as they are still where the view looks (`within`: in the library, + /// the album, the folder; not deleted). + pub fn query_keeping(&self, filter: &Filter, sort: &Sort, keep: &std::collections::HashSet, within: &Filter) -> Vec { + let prepared = filter.prepare(); + // `within` only decides for the few kept photos: worked out per photo, not hashed + let within_prepared = within.prepare_one(); + let mut v: Vec<&Photo> = self + .photos() + .map(|p| p.as_ref()) + .filter(|p| filter.matches_in(p, self, &prepared) || (keep.contains(&p.id) && within.matches_in(p, self, &within_prepared))) + .collect(); + v.sort_by(|a, b| sort.order(a, b)); v.into_iter().map(|p| p.id).collect() } + /// The list `was` (a [`Catalog::query`] of `filter` in `sort` order, or a [`Catalog::query_keeping`]), after ops + /// that touched only the photos `touched` (sorted by id, each once: [`Catalog::touched_since`]): those photos + /// taken out, and the ones that match `filter` now put back at their place in the order. With `within`, a + /// touched photo that was in the list and still matches `within` stays, as `query_keeping` would keep it. What + /// the query would give, without asking every photo again. + pub fn follow(&self, was: &[PhotoId], touched: &[PhotoId], filter: &Filter, sort: &Sort, within: Option<&Filter>) -> Vec { + let listed: std::collections::HashSet = was.iter().copied().filter(|id| touched.binary_search(id).is_ok()).collect(); + let mut ids: Vec = was.iter().copied().filter(|id| !listed.contains(id)).collect(); + let (prepared, within_prepared) = (filter.prepare_one(), within.map(Filter::prepare_one)); + for id in touched { + let Some(p) = self.photo(*id) else { continue }; + let kept = within.zip(within_prepared.as_ref()).is_some_and(|(w, wp)| listed.contains(id) && w.matches_in(p, self, wp)); + if filter.matches_in(p, self, &prepared) || kept { + // (the list is in order without the touched photos, and stays so as each is put back) + let at = ids.partition_point(|other| self.photo(*other).is_some_and(|o| sort.order(o, p).is_lt())); + ids.insert(at, *id); + } + } + ids + } + + /// How many photos match `filter`: [`Catalog::query`]'s length without its sorting. + pub fn count(&self, filter: &Filter) -> usize { + let prepared = filter.prepare(); + self.photos().filter(|p| filter.matches_in(p, self, &prepared)).count() + } + /// Year → month → day counts for the "By Date" section (newest first). pub fn date_groups(&self) -> Vec { use std::collections::BTreeMap; let mut years: BTreeMap<&str, (BTreeMap<&str, usize>, BTreeMap<&str, usize>)> = BTreeMap::new(); for p in self.photos().filter(|p| p.in_library()) { let Some(d) = p.captured.as_deref() else { continue }; - if d.len() >= 10 { - let (months, days) = years.entry(&d[..4]).or_default(); - *months.entry(&d[..7]).or_default() += 1; - *days.entry(&d[..10]).or_default() += 1; + // `get`, not `[..]`: a corrupt or foreign `captured` (a Lightroom catalog's + // captureTime is copied verbatim) may not be ASCII, and slicing it on a byte index + // would panic — the same rule as `dates::group_label`. + if let (Some(year), Some(month), Some(day)) = (d.get(..4), d.get(..7), d.get(..10)) { + let (months, days) = years.entry(year).or_default(); + *months.entry(month).or_default() += 1; + *days.entry(day).or_default() += 1; } } let newest_first = |m: BTreeMap<&str, usize>| m.into_iter().rev().map(|(k, n)| (k.to_string(), n)).collect::>(); @@ -441,8 +521,8 @@ impl Catalog { pub fn people_in(&self, filter: &Filter) -> Vec { let filter = Filter { person: None, ..filter.clone() }; let mut m: std::collections::HashMap = Default::default(); - let root = filter.library_root(); - for p in self.photos().filter(|p| filter.matches_in(p, self, root.as_deref())) { + let prepared = filter.prepare(); + for p in self.photos().filter(|p| filter.matches_in(p, self, &prepared)) { let mut seen: Vec = Vec::new(); for r in p.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { let Some(name) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; diff --git a/crates/catalog/src/rules.rs b/crates/catalog/src/rules.rs index f17f12e01..a87c9effc 100644 --- a/crates/catalog/src/rules.rs +++ b/crates/catalog/src/rules.rs @@ -598,6 +598,35 @@ pub fn now() -> String { } } +/// A flag as rules name it (its variant, lower case), without building a string per photo. +fn flag_name(f: crate::Flag) -> &'static str { + match f { + crate::Flag::None => "none", + crate::Flag::Pick => "pick", + crate::Flag::Reject => "reject", + } +} + +/// A colour label's colour as rules name it (lower case). +fn label_colour(l: crate::ColorLabel) -> &'static str { + match l { + crate::ColorLabel::Red => "red", + crate::ColorLabel::Yellow => "yellow", + crate::ColorLabel::Green => "green", + crate::ColorLabel::Blue => "blue", + crate::ColorLabel::Purple => "purple", + } +} + +/// A media kind as rules name it (lower case). +fn kind_name(k: crate::MediaKind) -> &'static str { + match k { + crate::MediaKind::Image => "image", + crate::MediaKind::Raw => "raw", + crate::MediaKind::Video => "video", + } +} + fn lower(v: &Value) -> String { match v { Value::String(s) => s.trim().to_lowercase(), @@ -778,16 +807,16 @@ impl Rule { match field { "rating" => num_op(op, Some(p.rating as f64), value), "flag" => { - let f = format!("{:?}", p.flag).to_lowercase(); + let f = flag_name(p.flag); (f == want || (want == "picked" && f == "pick") || (want == "rejected" && f == "reject")) == (op == "is") } "label" => { - let l = p.label.map(|l| format!("{l:?}").to_lowercase()).unwrap_or_else(|| "none".into()); + let l = p.label.map_or("none", label_colour); // custom label names count too - let named = p.label.is_some_and(|l| cat.label_name(l).to_lowercase() == want); + let named = p.label.is_some_and(|l| cat.custom_label_name(l).map_or(label_colour(l) == want, |n| n.to_lowercase() == want)); (l == want || named) == (op == "is") } - "kind" => (format!("{:?}", p.kind).to_lowercase() == want) == (op == "is"), + "kind" => (kind_name(p.kind) == want) == (op == "is"), "edited" => yes(p.is_edited()), "hasGps" => yes(m.gps.is_some()), "virtualCopy" => yes(p.copy_of.is_some()), diff --git a/crates/catalog/src/stacks.rs b/crates/catalog/src/stacks.rs index 6ffb11f52..eb7e89ef4 100644 --- a/crates/catalog/src/stacks.rs +++ b/crates/catalog/src/stacks.rs @@ -21,7 +21,7 @@ impl Catalog { } /// The stack a photo belongs to. pub fn stack_of(&self, photo: PhotoId) -> Option<&Stack> { - self.stacks.values().find(|s| s.photos.contains(&photo)) + self.stack_id_of(photo).and_then(|id| self.stacks.get(&id)) } /// Photo → (stack, position in the stack), for every stacked photo. pub fn stack_index(&self) -> HashMap { @@ -41,7 +41,7 @@ impl Catalog { return Err(CatalogError::NoPhoto(*p)); } } - if self.stacks.values().any(|s| s.id != id && s.photos.iter().any(|p| seen.contains(p))) { + if photos.iter().any(|p| self.stack_id_of(*p).is_some_and(|s| s != id)) { return Err(CatalogError::Invalid("a photo can be in only one stack".into())); } Ok(()) @@ -231,6 +231,60 @@ impl Catalog { } } +impl Catalog { + /// The bracket sets among `ids`, an HDR merge each: every stack one of them is in (its photos, less earlier merge + /// results), then the unstacked ones in capture order, a set running while each starts within about a second of the + /// previous one's end (its shutter time; capture times are whole seconds). A run is a set when its exposures span at + /// least ½ EV: a burst at one exposure is no bracket. Photos without a capture time or shutter speed are left out. + pub fn bracket_sets(&self, ids: &[PhotoId]) -> Vec> { + let merged = |p: &crate::Photo| crate::query::merged_kind(&p.file_name).is_some(); + let mut sets: Vec> = Vec::new(); + let mut stacks = HashSet::new(); + let mut loose = Vec::new(); + for id in ids { + match self.stack_of(*id) { + Some(s) if stacks.insert(s.id) => { + let set: Vec = s.photos.iter().copied().filter(|p| self.photos.get(p).is_some_and(|p| !merged(p))).collect(); + if set.len() >= 2 { + sets.push(set); + } + } + Some(_) => {} + None => loose.push(*id), + } + } + // (capture second, file name, id, shutter seconds, EV) + let mut timed: Vec<(i64, &str, PhotoId, f64, f64)> = loose + .iter() + .filter_map(|id| self.photos.get(id)) + .filter(|p| !p.deleted && !merged(p)) + .filter_map(|p| { + let t = iso_seconds(p.captured.as_deref()?)?; + let s = crate::model::parse_shutter_seconds(&p.meta.shutter)?; + let n = p.meta.aperture.filter(|n| n.is_finite() && *n > 0.0).unwrap_or(1.0) as f64; + let iso = p.meta.iso.filter(|i| *i > 0).unwrap_or(100) as f64; + Some((t, p.file_name.as_str(), p.id, s, (s * iso / (n * n)).log2())) + }) + .collect(); + timed.sort_by(|a, b| a.0.cmp(&b.0).then_with(|| cmp_name(a.1, b.1)).then(a.2.cmp(&b.2))); + timed.dedup_by_key(|x| x.2); + let mut runs: Vec> = Vec::new(); + for x in timed { + match runs.last_mut() { + Some(r) if r.last().is_some_and(|l| (x.0 - l.0) as f64 <= l.3.ceil() + 1.0) => r.push(x), + _ => runs.push(vec![x]), + } + } + for r in runs { + let (lo, hi) = r.iter().fold((f64::INFINITY, f64::NEG_INFINITY), |(lo, hi), x| (lo.min(x.4), hi.max(x.4))); + if r.len() >= 2 && hi - lo >= 0.5 { + sets.push(r.iter().map(|x| x.2).collect()); + } + } + sets + } +} + /// Seconds since 1970-01-01 for an ISO 8601 local time (`2026-04-01T10:00:00`, `2026-04-01 10:00`, /// `2026-04-01`; fractions and zone suffixes are ignored). `None` if it doesn't parse. pub fn iso_seconds(s: &str) -> Option { @@ -240,7 +294,8 @@ pub fn iso_seconds(s: &str) -> Option { if !(1..=12).contains(&m) || !(1..=31).contains(&d) { return None; } - let (hh, mm, ss) = if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, num(17, 19).unwrap_or(0)) } else { (0, 0, 0) }; + let (hh, mm, ss) = + if s.len() >= 16 { (num(11, 13)?, num(14, 16)?, if s.get(16..17) == Some(":") { num(17, 19).unwrap_or(0) } else { 0 }) } else { (0, 0, 0) }; // days from civil (proleptic Gregorian) let y2 = if m <= 2 { y - 1 } else { y }; let era = y2.div_euclid(400); @@ -358,6 +413,46 @@ mod tests { assert_eq!(back.clone().alloc_stack_id(), c.clone().alloc_stack_id()); } + #[test] + fn bracket_sets_by_time_and_exposure() { + // (time, shutter, ISO, file name) + let shots = [ + ("2026-08-01T09:20:36", "1/125", 640, "A1.ARW"), // a handheld bracket + ("2026-08-01T09:20:36", "1/125", 200, "A2.ARW"), + ("2026-08-01T09:20:37", "1/125", 2000, "A3.ARW"), + ("2026-08-01T09:20:37", "", 0, "A3-HDR.dng"), // an earlier merge of it + ("2026-08-01T09:20:44", "1/125", 800, "B1.ARW"), // 7 s later: the next bracket + ("2026-08-01T09:20:44", "1/125", 250, "B2.ARW"), + ("2026-08-01T09:20:50", "1/1000", 160, "C1.ARW"), // a burst at one exposure + ("2026-08-01T09:20:50", "1/1000", 160, "C2.ARW"), + ("2026-06-27T23:34:59", "2", 320, "N1.ARW"), // 2 s night frames, 2.1 s apart + ("2026-06-27T23:35:01", "2", 100, "N2.ARW"), + ("2026-06-27T23:35:04", "2", 800, "N3.ARW"), + ("2026-06-27T23:36:00", "1/40", 250, "S1.ARW"), // stacked by hand: merged whatever its times + ("2026-06-27T23:39:00", "1/40", 250, "S2.ARW"), + ]; + let mut c = Catalog::new(); + let mut ids = Vec::new(); + for (t, sh, iso, name) in shots { + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::Demo { scene: 1 }, name, "ARW", 60, 40, "2026-01-01T00:00:00"); + p.captured = Some(t.into()); + p.meta.shutter = sh.into(); + p.meta.iso = Some(iso); + p.meta.aperture = Some(5.6); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let op = c.group_ops(ids[11], &[ids[12]], true).unwrap(); + c.apply(op).unwrap(); + let sets = c.bracket_sets(&ids); + assert_eq!(sets, vec![vec![ids[11], ids[12]], vec![ids[8], ids[9], ids[10]], vec![ids[0], ids[1], ids[2]], vec![ids[4], ids[5]]]); + // a stack's merge result on top is left out; a stack selected twice is merged once + let op = c.stack_with_ops(ids[3], &[ids[0], ids[1], ids[2]]).unwrap(); + c.apply(op).unwrap(); + assert_eq!(c.bracket_sets(&[ids[3], ids[0]]), vec![vec![ids[0], ids[1], ids[2]]]); + } + #[test] fn auto_stack_by_capture_time() { let times = ["2026-04-01T10:00:00", "2026-04-01T10:00:02", "2026-04-01T10:00:09", "2026-04-01T11:00:00", "2026-04-01T11:00:01"]; diff --git a/crates/catalog/src/store.rs b/crates/catalog/src/store.rs index a672d00e7..2f3dc4912 100644 --- a/crates/catalog/src/store.rs +++ b/crates/catalog/src/store.rs @@ -73,6 +73,16 @@ impl FsStore { } } +/// A new staging file at `tmp`: whatever is already there (a leftover from a crashed save, or a link) is removed first +/// (a link itself, never what it points to), then the file is created exclusively, so a write never follows a link +/// planted at that name into another file (issue #1502). +fn create_staging(tmp: &std::path::Path) -> io::Result { + if std::fs::symlink_metadata(tmp).is_ok() { + std::fs::remove_file(tmp)?; + } + std::fs::OpenOptions::new().write(true).create_new(true).open(tmp) +} + impl Store for FsStore { fn read(&mut self, name: &str) -> io::Result>> { match std::fs::read(self.path(name)) { @@ -89,7 +99,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); { - let mut f = std::fs::File::create(&tmp)?; + let mut f = create_staging(&tmp)?; f.write_all(data)?; f.sync_all()?; } @@ -105,7 +115,7 @@ impl Store for FsStore { } let tmp = self.path(&format!("{name}.tmp")); let written = (|| { - let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, std::fs::File::create(&tmp)?), n: 0 }; + let mut w = Counter { inner: io::BufWriter::with_capacity(1 << 20, create_staging(&tmp)?), n: 0 }; fill(&mut w)?; w.flush()?; let f = w.inner.into_inner().map_err(|e| e.into_error())?; @@ -231,3 +241,29 @@ impl Store for MemStore { self.background.then(|| Box::new(self.clone()) as Box) } } + +#[cfg(test)] +mod tests_staging { + use super::*; + + /// A link planted at a staging name (`.tmp`) is removed, never followed: the file it points to keeps its + /// bytes and the save still lands (issue #1502). + #[cfg(unix)] + #[test] + fn a_link_at_the_staging_name_is_never_written_through() { + let dir = std::env::temp_dir().join(format!("lc-staging-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let mut store = FsStore::open(&dir).unwrap(); + let victim = dir.join("victim.txt"); + std::fs::write(&victim, b"keep").unwrap(); + std::os::unix::fs::symlink(&victim, dir.join("catalog.snap.tmp")).unwrap(); + store.write_atomic("catalog.snap", b"{}").unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.snap")).unwrap(), b"{}"); + std::os::unix::fs::symlink(&victim, dir.join("catalog.log.tmp")).unwrap(); + store.write_atomic_with("catalog.log", &mut |w| w.write_all(b"[1]")).unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), b"keep"); + assert_eq!(std::fs::read(dir.join("catalog.log")).unwrap(), b"[1]"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/catalog/src/tally.rs b/crates/catalog/src/tally.rs new file mode 100644 index 000000000..8f15e5ec4 --- /dev/null +++ b/crates/catalog/src/tally.rs @@ -0,0 +1,234 @@ +//! How many photos each album shows, kept up to date from what the latest ops touched. +//! +//! A smart album's count asks every photo whether its rules hold. Asked again after every edit, that is a pass over +//! the library for each smart album each time a keyword is given. [`AlbumTally`] remembers which photos each smart +//! album holds, and after a small edit asks only the photos the edit touched ([`Catalog::touched_since`]). An op +//! that changes more than photos of its own (an album's rules or photos, a stack, the keyword list) has it counted +//! whole again, as before. + +use std::collections::{HashMap, HashSet}; + +use crate::{AlbumId, Aspects, Catalog, PhotoId}; + +/// The most photos an edit may touch for the counts to follow it photo by photo. +const MAX_FOLLOWED: usize = 256; +/// Bits are kept while they cost no more than this many words a photo held, and these to start with. Past that +/// (photo ids far apart: a library whose ids don't count up from 1) a set is kept instead, so what an album +/// remembers is always in proportion to the photos it holds, never to the largest id in the library. +const WORDS_A_MEMBER: usize = 4; +const WORDS_TO_START: usize = 1024; + +/// The photos a smart album holds. +#[derive(Clone, Debug)] +enum Members { + /// One bit a photo id, and how many are set. + Bits { + words: Vec, + len: usize, + }, + Set(HashSet), +} + +impl Members { + fn none() -> Members { + Members::Bits { words: Vec::new(), len: 0 } + } + + fn len(&self) -> usize { + match self { + Members::Bits { len, .. } => *len, + Members::Set(set) => set.len(), + } + } + + #[cfg(test)] + fn holds(&self, id: PhotoId) -> bool { + match self { + Members::Bits { words, .. } => { + usize::try_from(id.0 / 64).ok().and_then(|at| words.get(at)).is_some_and(|word| word & (1u64 << (id.0 % 64)) != 0) + } + Members::Set(set) => set.contains(&id), + } + } + + /// Put `id` in or take it out. + fn set(&mut self, id: PhotoId, held: bool) { + if let Members::Bits { words, len } = self { + let bit = 1u64 << (id.0 % 64); + match usize::try_from(id.0 / 64) { + Ok(at) if at < words.len() => { + if let Some(word) = words.get_mut(at) { + match (*word & bit != 0, held) { + (false, true) => { + *word |= bit; + *len = len.saturating_add(1); + } + (true, false) => { + *word &= !bit; + *len = len.saturating_sub(1); + } + _ => {} + } + } + return; + } + // (past the end: not held, so there is nothing to take out) + _ if !held => return, + // room for it, as long as the bits stay in proportion to the photos held + Ok(at) if at < len.saturating_mul(WORDS_A_MEMBER).saturating_add(WORDS_TO_START) => { + words.resize(at + 1, 0); + if let Some(word) = words.get_mut(at) { + *word |= bit; + *len = len.saturating_add(1); + } + return; + } + // ids too far apart for bits: a set from here on + _ => { + let ids = words + .iter() + .enumerate() + .flat_map(|(at, word)| (0..64u64).filter(move |b| word & (1u64 << b) != 0).map(move |b| PhotoId(at as u64 * 64 + b))); + *self = Members::Set(ids.collect()); + } + } + } + if let Members::Set(set) = self { + if held { + set.insert(id); + } else { + set.remove(&id); + } + } + } +} + +/// [`Catalog::album_count`] of every album, as of one revision of the catalog. +#[derive(Clone, Debug)] +pub struct AlbumTally { + revision: u64, + counts: HashMap, + /// The smart albums' photos. + members: HashMap, +} + +impl AlbumTally { + /// Counted from every photo. + pub fn of(cat: &Catalog) -> AlbumTally { + let mut counts = HashMap::new(); + let mut members = HashMap::new(); + for album in cat.albums() { + if album.is_smart() { + let mut held = Members::none(); + for p in cat.photos().filter(|p| cat.album_contains(album.id, p)) { + held.set(p.id, true); + } + counts.insert(album.id, held.len()); + members.insert(album.id, held); + } else { + counts.insert(album.id, cat.album_count(album.id)); + } + } + AlbumTally { revision: cat.revision, counts, members } + } + + /// The count of each album. + pub fn counts(&self) -> &HashMap { + &self.counts + } + + /// The catalog's revision the counts are of. + pub fn revision(&self) -> u64 { + self.revision + } + + /// Bring the counts up to `cat` as it is now. `true` when that was done from the photos the ops since touched + /// (or nothing had changed), `false` when every photo was asked again. + /// + /// `cat` has to be the catalog the counts were made of, further along. Another catalog is told apart only by + /// its revisions: a library that is loaded takes a range of its own (and then everything is counted again), but + /// two catalogs both counting from 0 look like one. + pub fn update(&mut self, cat: &Catalog) -> bool { + if self.revision == cat.revision { + return true; + } + let followed = match (cat.touched_since(self.revision), cat.aspects_since(self.revision)) { + (Some(touched), Some(changed)) if touched.len() <= MAX_FOLLOWED => { + self.follow(cat, &touched, changed); + true + } + _ => false, + }; + if !followed { + *self = AlbumTally::of(cat); + } + followed + } + + /// The counts after ops that touched only `touched`: each smart album asks those photos again; an album of + /// chosen photos counts again only when a photo came, went or was deleted (who is in it did not change: that + /// is an op on the album, and not followed). + fn follow(&mut self, cat: &Catalog, touched: &[PhotoId], changed: Aspects) { + for (album, held) in &mut self.members { + for id in touched { + held.set(*id, cat.photo(*id).is_some_and(|p| cat.album_contains(*album, p))); + } + self.counts.insert(*album, held.len()); + } + if changed.any_of(Aspects::PRESENCE) { + for album in cat.albums().filter(|a| !a.is_smart()) { + self.counts.insert(album.id, cat.album_count(album.id)); + } + } + self.revision = cat.revision; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(m: &Members, of: &[u64]) -> Vec { + of.iter().copied().filter(|id| m.holds(PhotoId(*id))).collect() + } + + #[test] + fn members_are_kept_as_bits_at_the_edges_of_a_word_and_past_the_end() { + let mut m = Members::none(); + let probe = [0, 1, 63, 64, 65, 127, 128, 5000]; + assert!(ids(&m, &probe).is_empty() && m.len() == 0); + for id in [0, 63, 64, 5000] { + m.set(PhotoId(id), true); + } + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 64, 5000], 4)); + // put in twice, taken out twice, taken out where nothing is, far past the end: counted once each way + m.set(PhotoId(63), true); + m.set(PhotoId(64), false); + m.set(PhotoId(64), false); + m.set(PhotoId(1), false); + m.set(PhotoId(900_000), false); + assert_eq!((ids(&m, &probe), m.len()), (vec![0, 63, 5000], 3)); + assert!(matches!(m, Members::Bits { .. })); + } + + #[test] + fn photo_ids_far_apart_are_kept_as_a_set_in_proportion_to_the_photos_held() { + // one photo with an id in the thousands of millions: no bit for every id up to it + let mut m = Members::none(); + for id in [3, 70, 9_000_000_000] { + m.set(PhotoId(id), true); + } + assert!(matches!(m, Members::Set(_)), "a set once the ids are too far apart for bits"); + assert_eq!((ids(&m, &[3, 70, 71, 9_000_000_000, u64::MAX]), m.len()), (vec![3, 70, 9_000_000_000], 3)); + m.set(PhotoId(u64::MAX), true); + m.set(PhotoId(3), false); + assert_eq!((ids(&m, &[3, 70, 9_000_000_000, u64::MAX]), m.len()), (vec![70, 9_000_000_000, u64::MAX], 3)); + // an album of many photos with ids counting up stays bits, however many + let mut dense = Members::none(); + for id in 1..=200_000u64 { + dense.set(PhotoId(id), true); + } + assert!(matches!(&dense, Members::Bits { words, .. } if words.len() <= 200_000 / 64 + 1)); + assert_eq!(dense.len(), 200_000); + } +} diff --git a/crates/catalog/src/tests.rs b/crates/catalog/src/tests.rs index acfbb1bb1..a32b5acf5 100644 --- a/crates/catalog/src/tests.rs +++ b/crates/catalog/src/tests.rs @@ -123,6 +123,65 @@ fn filter_search_sort() { assert_eq!(c.keywords(), vec![("ocean".to_string(), 1), ("summer".to_string(), 1)]); } +/// A search word with a colon whose left part is not a known field ("10:30", "Re:") is plain +/// text, not a fielded token that matches nothing. +#[test] +fn free_text_with_a_colon_searches_the_text_fields() { + let mut c = Catalog::new(); + let a = photo(&mut c, "meeting.jpg", "2026-05-01T10:00:00"); + let _b = photo(&mut c, "other.jpg", "2026-04-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.caption = "Meeting at 10:30 with Re: notes".into(); + m.iso = Some(1600); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |text: &str| c.query(&Filter { text: text.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("meeting"), vec![a]); + assert_eq!(q("10:30"), vec![a], "a free-text token with a colon"); + assert_eq!(q("re:"), vec![a]); + assert_eq!(q("RE: notes"), vec![a], "case-insensitive, one token per word"); + assert_eq!(q("iso:>800"), vec![a], "known fields stay fielded"); + assert_eq!(q("iso:>2000"), Vec::::new()); + assert_eq!(q("11:45"), Vec::::new(), "an unknown field matching no text finds nothing"); +} + +/// `f:`/`aperture:` and `focal:` are stored as `f32`; a typed value that isn't exact in binary +/// (2.8, 5.6) must still match, and `<`/`>` must not count the photo's own value. +#[test] +fn search_tokens_match_f32_aperture_and_focal() { + let mut c = Catalog::new(); + let a = photo(&mut c, "a.jpg", "2026-05-01T10:00:00"); + let mut m = c.photo(a).unwrap().meta.clone(); + m.aperture = Some(2.8); + m.focal_mm = Some(5.6); + c.apply(Op::SetMeta { id: a, meta: Box::new(m) }).unwrap(); + let q = |t: &str| c.query(&Filter { text: t.into(), ..Default::default() }, &Sort::default()); + assert_eq!(q("f:2.8"), vec![a], "f:2.8"); + assert_eq!(q("aperture:=2.8"), vec![a], "aperture:=2.8"); + assert_eq!(q("focal:5.6"), vec![a], "focal:5.6"); + assert!(q("f:2.9").is_empty(), "f:2.9"); + assert!(q("f:<2.8").is_empty(), "f:<2.8"); + assert!(q("f:>2.8").is_empty(), "f:>2.8"); + assert!(q("focal:<5.6").is_empty(), "focal:<5.6"); + assert_eq!(q("f:>2.7"), vec![a], "f:>2.7"); +} + +/// A `captured` string that is not ASCII (the Lightroom importer copies `captureTime` into an +/// `AddPhoto` op verbatim, and a JSON snapshot is valid UTF-8 whatever it holds) must not be +/// sliced on a byte index in `date_groups`: grouping counts the well-formed dates and ignores +/// the rest instead of panicking on a char boundary. +#[test] +fn date_groups_ignores_a_non_ascii_captured() { + let mut c = Catalog::new(); + let ok = photo(&mut c, "ok.jpg", "2026-05-01T10:00:00"); + // `AddPhoto` takes the field as it is, like `library.importLightroom` building the op + let bad = photo(&mut c, "bad.jpg", "日本語日本語"); + assert_ne!(ok, bad); + let g = c.date_groups(); + assert_eq!(g.len(), 1, "the one well-formed date still groups"); + assert_eq!((g[0].year.as_str(), g[0].count), ("2026", 1)); + assert_eq!(g[0].days.iter().map(|d| d.1).sum::(), 1); +} + #[test] fn snapshot_and_log_replay() { let mut c = Catalog::new(); @@ -265,13 +324,30 @@ fn preview_only_survives_serde_and_set_content_undo() { let old: Catalog = serde_json::from_value(v).unwrap(); assert_eq!(old.photo(a).unwrap().preview_only, None); assert!(old.photo(a).unwrap().develops_raw()); - // Reload: the file decodes now → cleared; undo (through the journal's JSON) restores it - let op = Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None }; + // a newer build still can't decode it: the reason and the stamp of the version that decided + // it are recorded together (#662) + let op = Op::SetContent { + id: a, + width: 6000, + height: 4000, + file_size: 1, + content_hash: None, + preview_only: Some("Nikon Huffman-compressed NEF".into()), + preview_checked: "0.6.0".into(), + }; + c.apply(op).unwrap(); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); + // Reload: the file decodes now → cleared, with its stamp; undo (through the journal's JSON) + // restores both + let op = + Op::SetContent { id: a, width: 6016, height: 4000, file_size: 1, content_hash: None, preview_only: None, preview_checked: String::new() }; let inv = c.apply(op).unwrap(); assert!(c.photo(a).unwrap().develops_raw()); + assert_eq!(c.photo(a).unwrap().preview_checked, ""); let inv: Op = serde_json::from_str(&serde_json::to_string(&inv).unwrap()).unwrap(); c.apply(inv).unwrap(); assert_eq!(c.photo(a).unwrap().preview_only.as_deref(), Some("Nikon Huffman-compressed NEF")); + assert_eq!(c.photo(a).unwrap().preview_checked, "0.6.0"); assert_eq!(c.photo(a).unwrap().width, 6000); } @@ -391,6 +467,28 @@ fn empty_regions_are_not_serialized_and_default_when_missing() { assert_eq!(back, with); } +/// The camera's focus point is optional camera data: absent from the catalog JSON when unknown (so a +/// catalog from before it existed reads with none, and writes the same), kept exactly when known. +#[test] +fn focus_point_is_not_serialized_when_unknown_and_round_trips() { + let m = Meta { camera: "SONY ILCE-1".into(), ..Default::default() }; + let v = serde_json::to_value(&m).unwrap(); + assert!(v.get("focus").is_none(), "{v}"); + let back: Meta = serde_json::from_value(v).unwrap(); + assert!(back.focus.is_none()); + let mut with = m.clone(); + with.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: 0.36, y0: 0.33, x1: 0.38, y1: 0.36 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: None, + }); + let json = serde_json::to_string(&with).unwrap(); + assert!(json.contains("\"focus\"") && !json.contains("tracking"), "{json}"); + let back: Meta = serde_json::from_str(&json).unwrap(); + assert_eq!(back, with); +} + // ---- random sort: Given a seed, the order is a stable, reproducible shuffle of the matching photos fn many(c: &mut Catalog, n: usize) -> Vec { @@ -737,3 +835,81 @@ fn cmp_name_ignores_case_without_allocating() { assert_eq!(cmp_name("DSC_0001", "DSC_0002"), Less); assert_eq!(cmp_name("a", "ab"), Less); } + +/// What the catalog remembers of its latest ops: which photos each changed and what of them, as long as that can be +/// said photo by photo. +#[test] +fn the_catalog_remembers_which_photos_its_latest_ops_changed_and_what_of_them() { + let mut c = Catalog::default(); + let photo = |c: &mut Catalog, name: &str| { + let id = c.alloc_photo_id(); + c.apply(Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: format!("/p/{name}") }, name, "JPEG", 60, 40, "2026-01-01T10:00:00")), + }) + .unwrap(); + id + }; + let (a, b) = (photo(&mut c, "a.jpg"), photo(&mut c, "b.jpg")); + let start = c.revision; + assert_eq!(c.touched_since(start), Some(vec![])); + assert_eq!(c.aspects_since(start), Some(Aspects::NONE)); + // a rating, then a flag on another photo, as one batch + c.apply(Op::SetRating { id: a, rating: 3 }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a])); + assert_eq!(c.aspects_since(start), Some(Aspects::RATING)); + let after_rating = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetFlag { id: b, flag: Flag::Pick }, Op::SetRating { id: a, rating: 4 }] }).unwrap(); + assert_eq!(c.touched_since(start), Some(vec![a, b]), "each once"); + assert_eq!(c.touched_since(after_rating), Some(vec![a, b])); + assert_eq!(c.aspects_since(after_rating), Some(Aspects::FLAG.with(Aspects::RATING))); + assert_eq!(c.touched_since(c.revision), Some(vec![])); + // a photo that goes counts for everything + let before = c.revision; + c.apply(Op::RemovePhoto { id: b }).unwrap(); + assert_eq!(c.touched_since(before), Some(vec![b])); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::PRESENCE) && x.any_of(Aspects::FILE))); + // an op that changes more than photos of its own: nothing is said of what came before it + let before = c.revision; + let album = c.alloc_album_id(); + c.apply(Op::AddAlbum { album: Album::new(album, "Trip") }).unwrap(); + assert_eq!(c.touched_since(before), None); + assert_eq!(c.aspects_since(start), None); + assert_eq!(c.touched_since(c.revision), Some(vec![]), "from after it, it is known again"); + // a revision that is not this catalog's past + assert_eq!(c.touched_since(c.revision + 5), None); + // a revision set from outside (a library just loaded): nothing is known of how the catalog got there, and after + // the next op nothing of before it + let old = c.revision; + c.revision = 7 << 32; + assert_eq!(c.touched_since(old), None); + assert_eq!(c.aspects_since(c.revision), None); + let loaded = c.revision; + c.apply(Op::SetRating { id: a, rating: 1 }).unwrap(); + assert_eq!(c.touched_since(loaded), Some(vec![a])); + assert_eq!(c.touched_since(old), None); + // an op that fails changes nothing, and what is remembered never says less than what happened + let before = c.revision; + assert!(c.apply(Op::SetRating { id: PhotoId(9_999), rating: 1 }).is_err()); + assert_eq!(c.revision, before); + c.apply(Op::SetFlag { id: a, flag: Flag::Pick }).unwrap(); + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + assert!(c.aspects_since(before).is_some_and(|x| x.any_of(Aspects::FLAG))); + // a batch that fails midway is undone as a whole; a batch in a batch is remembered photo by photo; a batch + // with an op that changes more than photos is not + let before = c.revision; + assert!(c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 5 }, Op::SetRating { id: PhotoId(9_999), rating: 1 }] }).is_err()); + assert_eq!((c.revision, c.photo(a).map(|p| p.rating)), (before, Some(1))); + c.apply(Op::Batch { ops: vec![Op::Batch { ops: vec![Op::SetRating { id: a, rating: 2 }] }] }).unwrap(); + // (the photo the failed batch asked for and never found may be named too: more is said, never less) + assert!(c.touched_since(before).is_some_and(|ids| ids.contains(&a))); + let before = c.revision; + c.apply(Op::Batch { ops: vec![Op::SetRating { id: a, rating: 3 }, Op::RenameAlbum { id: album, name: "Journey".into() }] }).unwrap(); + assert_eq!(c.touched_since(before), None); + // more ops than are remembered: the oldest are answered with "anything" + let long_ago = c.revision; + for i in 0..9000u32 { + c.apply(Op::SetRating { id: a, rating: (i % 5) as u8 }).unwrap(); + } + assert_eq!(c.touched_since(long_ago), None); + assert_eq!(c.touched_since(c.revision - 10), Some(vec![a])); +} diff --git a/crates/catalog/src/tests_format_version.rs b/crates/catalog/src/tests_format_version.rs index e2461781a..e31d7fe0c 100644 --- a/crates/catalog/src/tests_format_version.rs +++ b/crates/catalog/src/tests_format_version.rs @@ -74,6 +74,29 @@ fn v3_library_loads_and_is_upgraded() { assert_eq!(snapshot_version(&m), u64::from(VERSION)); } +/// A v6 library (before preview-only photos recorded which version decided it, #662) opens with +/// an empty stamp on its preview-only photos, so they are rechecked once, and is rewritten in the +/// current format. +#[test] +fn v6_preview_only_photo_loads_unstamped_and_is_upgraded() { + let mut c = Catalog::new(); + let id = c.alloc_photo_id(); + let mut p = Photo::new(id, Source::File { path: "/p/a.cr3".into() }, "a.cr3", "CR3", 160, 120, "2026-01-01"); + p.kind = MediaKind::Raw; + p.preview_only = Some("Cr3 files are not decoded yet".into()); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + let snap = c.to_snapshot(); + assert!(!snap.contains("preview_checked"), "an empty stamp isn't written"); + let m = MemStore::new(); + m.set(SNAPSHOT, format!("{{\"format\":\"lightcraft-catalog\",\"version\":6,\"seq\":1,\"catalog\":{snap}}}\n").into_bytes()); + let (_, loaded, r) = Journal::open(Box::new(m.clone())).unwrap(); + let p = loaded.photo(id).unwrap(); + assert_eq!(p.preview_only.as_deref(), Some("Cr3 files are not decoded yet")); + assert_eq!(p.preview_checked, ""); + assert_eq!(r.upgraded_from, Some(6)); + assert_eq!(snapshot_version(&m), u64::from(VERSION)); +} + #[test] fn versionless_log_only_library_is_upgraded() { let mut c = Catalog::new(); @@ -171,7 +194,8 @@ fn bad_crc_is_still_a_torn_tail() { /// Guard for the version rule (see `journal` → *Format versions*): adding an [`Op`] variant breaks /// this match. When it does, bump `journal::VERSION`, add a row to the format table, and add the -/// variant here under the new version. +/// variant here under the new version. A version that only adds serialized fields raises +/// `NEWEST_FIELDS` instead. #[test] fn op_variants_are_versioned() { fn since(op: &Op) -> u32 { @@ -214,6 +238,8 @@ fn op_variants_are_versioned() { Op::SetHistoryCursor { .. } => 8, } } + /// v9: `Photo.preview_checked` and `Op::SetContent.preview_checked`. + const NEWEST_FIELDS: u32 = 9; let newest = since(&Op::SetHistoryCursor { id: PhotoId(1), cursor: None }); - assert_eq!(newest, VERSION, "the newest op's version must be the current format version"); + assert_eq!(newest.max(NEWEST_FIELDS), VERSION, "the newest op's or fields' version must be the current format version"); } diff --git a/crates/catalog/src/tests_index.rs b/crates/catalog/src/tests_index.rs new file mode 100644 index 000000000..f1f41501b --- /dev/null +++ b/crates/catalog/src/tests_index.rs @@ -0,0 +1,211 @@ +//! The derived indexes ([`crate::index`]) answer as a scan of the catalog would, through every op, +//! inverse, failed batch, snapshot, replay and clone; and the faster query paths give the same +//! results as before. + +use proptest::prelude::*; + +use super::*; + +const PHOTOS: u64 = 8; + +fn library() -> Catalog { + let mut c = Catalog::new(); + for i in 0..PHOTOS { + let p = Photo::new(PhotoId(i + 1), Source::Demo { scene: 1 }, &format!("p{i}.jpg"), "JPEG", 60, 40, "2026-01-01T00:00:00"); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + c +} + +/// What the indexes must say, worked out by scanning (as the catalog did before it had them). +fn scanned(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stacks().find(|s| s.photos.contains(p)).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.photo(*p).is_some() && c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none() && al.photos.contains(p))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// What the indexes say. +fn indexed(c: &Catalog) -> (Vec>, Vec>) { + let photos: Vec = (1..=PHOTOS + 1).map(PhotoId).collect(); + let stacks = photos.iter().map(|p| c.stack_of(*p).map(|s| s.id)).collect(); + let albums = (1..=4) + .map(|a| { + photos + .iter() + .map(|p| c.album(AlbumId(a)).is_some_and(|al| al.smart.is_none()) && c.photo(*p).is_some_and(|ph| c.album_contains(AlbumId(a), ph))) + .collect() + }) + .collect(); + (stacks, albums) +} + +/// Photo ids from bits (the extra id names a photo that isn't there: such ops fail). +fn ids(bits: u16) -> Vec { + (0..=PHOTOS).filter(|i| bits & (1 << i) != 0).map(|i| PhotoId(i + 1)).collect() +} + +fn op(c: &Catalog, kind: u8, n: u64, bits: u16, collapsed: bool) -> Op { + match kind % 8 { + 0 => Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed } }, + 1 => Op::RemoveStack { id: StackId(n % 4 + 1) }, + 2 => Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits), collapsed }, + 3 => { + let mut a = Album::new(AlbumId(n % 4 + 1), "A"); + a.photos = ids(bits); + Op::AddAlbum { album: a } + } + 4 => Op::RemoveAlbum { id: AlbumId(n % 4 + 1) }, + 5 => Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + // a batch whose last op fails half the time: what it did is rolled back + 6 => Op::Batch { + ops: vec![ + Op::SetAlbumPhotos { id: AlbumId(n % 4 + 1), photos: ids(bits) }, + Op::SetStack { id: StackId(n % 4 + 1), photos: ids(bits.rotate_left(3)), collapsed }, + Op::AddStack { stack: Stack { id: StackId(n % 4 + 1), photos: ids(!bits), collapsed } }, + ], + }, + // as the engine deletes a photo: out of its albums and stacks too, in one batch + _ => c.delete_permanently_ops(PhotoId(n % (PHOTOS + 1) + 1)), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + #[test] + fn indexes_agree_with_a_scan(steps in proptest::collection::vec((0u8..8, 0u64..16, 0u16..512, any::()), 1..40)) { + let mut c = library(); + let start = c.to_snapshot(); + // built before the ops, so every op has to keep them up to date + prop_assert_eq!(indexed(&c), scanned(&c)); + let mut log = String::new(); + let mut inverses = Vec::new(); + for (kind, n, bits, collapsed) in steps { + let op = op(&c, kind, n, bits, collapsed); + if let Ok(inv) = c.apply(op.clone()) { + log.push_str(&Catalog::op_to_log_line(&op)); + inverses.push(inv); + } + prop_assert_eq!(indexed(&c), scanned(&c)); + } + // a snapshot, a replayed journal and a clone build their own + let snap = Catalog::from_snapshot(&c.to_snapshot()).unwrap(); + prop_assert_eq!(indexed(&snap), scanned(&c)); + let mut replayed = Catalog::from_snapshot(&start).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&replayed)); + replayed.replay(&log).unwrap(); + prop_assert_eq!(indexed(&replayed), scanned(&c)); + prop_assert_eq!(indexed(&c.clone()), scanned(&c)); + prop_assert!(c.clone() == c, "the indexes don't take part in equality"); + // undone step by step + for inv in inverses.into_iter().rev() { + c.apply(inv).unwrap(); + prop_assert_eq!(indexed(&c), scanned(&c)); + } + prop_assert!(c.stacks().next().is_none() && c.albums().next().is_none(), "all undone"); + } +} + +#[test] +fn a_photo_in_another_stack_is_refused_by_the_index() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + let e = c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false } }).unwrap_err(); + assert_eq!(e, CatalogError::Invalid("a photo can be in only one stack".into())); + // its own stack may keep it + c.apply(Op::SetStack { id: StackId(1), photos: vec![PhotoId(2), PhotoId(1), PhotoId(3)], collapsed: true }).unwrap(); + assert_eq!(c.stack_of(PhotoId(3)).map(|s| s.id), Some(StackId(1))); +} + +/// The sort keys are worked out once per photo now; the order is the one the comparisons gave. +#[test] +fn sorted_queries_keep_their_order() { + let mut c = Catalog::new(); + let names = ["b.jpg", "A.jpg", "a.jpg", "Été.jpg", "été.jpg", "C.JPG", "b.jpg"]; + for (i, n) in names.iter().enumerate() { + let mut p = Photo::new(PhotoId(i as u64 + 1), Source::Demo { scene: 1 }, n, "JPEG", 60, 40, &format!("2026-01-0{}T00:00:00", 7 - i % 3)); + p.captured = (i % 2 == 0).then(|| format!("2025-0{}-01T00:00:00", 1 + i % 4)); + p.rating = (i % 3) as u8; + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let all = Filter::default(); + for key in [SortKey::CaptureDate, SortKey::ImportDate, SortKey::EditDate, SortKey::FileName, SortKey::Rating, SortKey::FileSize, SortKey::Random] + { + for ascending in [true, false] { + let sort = Sort { key, ascending, seed: 7, ..Default::default() }; + // the comparison the query sorts by + let mut want: Vec<&Photo> = c.photos().map(|p| p.as_ref()).collect(); + want.sort_by(|a, b| sort.order(a, b)); + let want: Vec = want.iter().map(|p| p.id).collect(); + assert_eq!(c.query(&all, &sort), want, "{key:?} ascending {ascending}"); + } + } + // `only` as a set gives the photos it names + let only = Filter { only: vec![PhotoId(3), PhotoId(1), PhotoId(3)], ..Default::default() }; + assert_eq!(c.query(&only, &Sort { key: SortKey::FileName, ascending: true, ..Default::default() }), vec![PhotoId(3), PhotoId(1)]); +} + +/// Rules name flags, labels and kinds as their variants in lower case (what `{:?}` gave). +#[test] +fn rule_names_are_the_variants_in_lower_case() { + let mut c = Catalog::new(); + let mut n = 0; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + for label in + [None, Some(ColorLabel::Red), Some(ColorLabel::Yellow), Some(ColorLabel::Green), Some(ColorLabel::Blue), Some(ColorLabel::Purple)] + { + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + n += 1; + let mut p = Photo::new(PhotoId(n), Source::Demo { scene: 1 }, "x.jpg", "JPEG", 60, 40, "2026-01-01T00:00:00"); + (p.flag, p.label, p.kind) = (flag, label, kind); + c.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + } + } + c.apply(Op::SetLabelName { label: ColorLabel::Blue, name: Some("Sky".into()) }).unwrap(); + let count = |field: &str, value: String| { + let rules: RuleSet = + serde_json::from_value(serde_json::json!({"match": "all", "rules": [{"field": field, "op": "is", "value": value}]})).unwrap(); + c.query(&Filter { rule_set: Some(rules), ..Default::default() }, &Sort::default()).len() + }; + for flag in [Flag::None, Flag::Pick, Flag::Reject] { + assert_eq!(count("flag", format!("{flag:?}").to_lowercase()), 18, "{flag:?}"); + } + assert_eq!(count("flag", "picked".into()), 18); + for label in ColorLabel::ALL { + assert_eq!(count("label", format!("{label:?}").to_uppercase()), 9, "{label:?}"); + } + assert_eq!(count("label", "sky".into()), 9, "a custom name"); + assert_eq!(count("label", "none".into()), 9); + for kind in [MediaKind::Image, MediaKind::Raw, MediaKind::Video] { + assert_eq!(count("kind", format!("{kind:?}")), 18, "{kind:?}"); + } +} + +/// A hand-edited file can put a photo in two stacks (ops refuse it): loading repairs it (the photo +/// stays in the first) and the stack index gives the answers a scan of the stacks gives, before +/// and after either stack goes. +#[test] +fn a_photo_in_two_stacks_read_from_a_file_answers_as_a_scan() { + let mut c = library(); + c.apply(Op::AddStack { stack: Stack { id: StackId(1), photos: vec![PhotoId(1), PhotoId(2)], collapsed: false } }).unwrap(); + c.apply(Op::AddStack { stack: Stack { id: StackId(2), photos: vec![PhotoId(3), PhotoId(4)], collapsed: false } }).unwrap(); + let mut v: serde_json::Value = serde_json::from_str(&c.to_snapshot()).unwrap(); + v["stacks"]["2"]["photos"] = serde_json::json!([2, 3, 4]); + let mut c = Catalog::from_snapshot(&v.to_string()).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), Some(StackId(1))); + // the second stack can't be changed to keep it, as before + assert!(c.apply(Op::SetStack { id: StackId(2), photos: vec![PhotoId(2), PhotoId(3)], collapsed: false }).is_err()); + c.apply(Op::RemoveStack { id: StackId(1) }).unwrap(); + assert_eq!(indexed(&c), scanned(&c)); + assert_eq!(c.stack_of(PhotoId(2)).map(|s| s.id), None, "the repair took it out of the second stack"); +} diff --git a/crates/catalog/src/tests_journal.rs b/crates/catalog/src/tests_journal.rs index 8570bd077..26b4e902a 100644 --- a/crates/catalog/src/tests_journal.rs +++ b/crates/catalog/src/tests_journal.rs @@ -376,3 +376,18 @@ fn a_damaged_process_value_loads_as_v1_with_the_edits() { let d = &loaded.photo(id).unwrap().develop; assert_eq!((d.process, d.light.exposure), (ProcessVersion::V1, 0.8)); } + +/// A snapshot whose `seq` is near `u64::MAX` is refused as corrupt: the next record's seq would +/// wrap (and every later edit would be skipped as stale on reopen) or overflow-panic in debug. +#[test] +fn snapshot_with_seq_near_u64_max_is_corrupt() { + for seq in [u64::MAX, u64::MAX - 1, u64::MAX / 2 + 1] { + let m = MemStore::new(); + m.set( + SNAPSHOT, + format!("{{\"format\":\"lightcraft-catalog\",\"version\":1,\"seq\":{seq},\"catalog\":{}}}\n", Catalog::new().to_snapshot()).into_bytes(), + ); + let e = Journal::open(Box::new(m)).err(); + assert!(matches!(e, Some(CatalogError::Corrupt(_))), "seq {seq}: {e:?}"); + } +} diff --git a/crates/catalog/src/tests_lock.rs b/crates/catalog/src/tests_lock.rs index d464d5c25..baa8da659 100644 --- a/crates/catalog/src/tests_lock.rs +++ b/crates/catalog/src/tests_lock.rs @@ -126,33 +126,40 @@ fn the_library_is_free_once_dropped_even_if_a_child_holds_a_copy() { /// Issue #680, as it happens in practice: one thread spawns cheap children while another acquires, /// drops and re-acquires the lock on its own directory. Every acquire must succeed (before the fix -/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, pass or fail. +/// a few per cent were refused with `InUse(None)`). Bounded: about two seconds of cycles, and on a +/// slow or loaded machine (the FreeBSD CI VM, where a spawn can take longer than that) until a few +/// children have been spawned during them, at most a minute. #[cfg(unix)] #[test] fn dropping_the_lock_frees_the_library_while_another_thread_spawns_processes() { use std::sync::Arc; - use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + use std::time::{Duration, Instant}; + + /// Children spawned while the lock cycles, at least, before the test may stop. + const CHILDREN: usize = 5; let dir = temp_dir("spawn"); let stop = Arc::new(AtomicBool::new(false)); + let count = Arc::new(AtomicUsize::new(0)); let spawner = { - let stop = Arc::clone(&stop); + let (stop, count) = (Arc::clone(&stop), Arc::clone(&count)); std::thread::spawn(move || { - let mut spawned = 0usize; while !stop.load(Ordering::Relaxed) { if let Ok(mut c) = std::process::Command::new("true").stdout(std::process::Stdio::null()).stderr(std::process::Stdio::null()).spawn() { let _ = c.wait(); - spawned += 1; + count.fetch_add(1, Ordering::Relaxed); } } - spawned + count.load(Ordering::Relaxed) }) }; - let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2); + let t0 = Instant::now(); + let enough = |cycles: usize| (cycles >= 200_000 || t0.elapsed() >= Duration::from_secs(2)) && count.load(Ordering::Relaxed) >= CHILDREN; let mut cycles = 0usize; let mut refused = Vec::new(); - while cycles < 200_000 && std::time::Instant::now() < deadline { + while !enough(cycles) && t0.elapsed() < Duration::from_secs(60) { match LibraryLock::acquire(&dir, "LightCraft") { Ok(l) => assert!(l.held()), // dropped here: the next cycle must get it back Err(e) => refused.push(format!("cycle {cycles}: {e:?}")), diff --git a/crates/catalog/src/tests_scale.rs b/crates/catalog/src/tests_scale.rs new file mode 100644 index 000000000..81ddccc41 --- /dev/null +++ b/crates/catalog/src/tests_scale.rs @@ -0,0 +1,92 @@ +//! What library queries cost at 100k photos (ignored: +//! `cargo test --release -p lightcraft-catalog --lib scale_100k -- --ignored --nocapture`). + +use std::time::Instant; + +use serde_json::json; + +use super::*; + +/// 100k photos, 10k two-photo stacks, a 20k-photo album, keywords and flags. +fn library(n: u64) -> (Catalog, AlbumId) { + let mut c = Catalog::new(); + let mut ops: Vec = (0..n) + .map(|i| { + let mut p = Photo::new( + PhotoId(i + 1), + Source::Demo { scene: (i % 20) as u32 }, + &format!("IMG_{:06}.jpg", mix64(i) % 1_000_000), + "JPEG", + 6000, + 4000, + "2026-01-01T00:00:00", + ); + p.captured = Some(format!("20{:02}-{:02}-{:02}T{:02}:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28, i % 24)); + p.rating = (i % 6) as u8; + p.flag = if i % 7 == 0 { Flag::Pick } else { Flag::None }; + p.meta.keywords = vec![format!("kw{}", i % 500), "travel|italy".into()]; + Op::AddPhoto { photo: Box::new(p) } + }) + .collect(); + ops.extend( + (0..n / 10) + .map(|s| Op::AddStack { stack: Stack { id: StackId(s + 1), photos: vec![PhotoId(s * 10 + 1), PhotoId(s * 10 + 2)], collapsed: false } }), + ); + let album = AlbumId(1); + let mut a = Album::new(album, "Big"); + a.photos = (0..n).step_by(5).map(|i| PhotoId(i + 1)).collect(); + ops.push(Op::AddAlbum { album: a }); + let t = Instant::now(); + c.apply(Op::Batch { ops }).unwrap(); + eprintln!("build {n} photos, {} stacks: {:.0} ms", n / 10, t.elapsed().as_secs_f64() * 1e3); + (c, album) +} + +fn smart(c: &mut Catalog, id: u64, rules: serde_json::Value) -> AlbumId { + let mut a = Album::new(AlbumId(id), format!("Smart {id}")); + a.smart = Some(Box::new(Filter { rule_set: Some(serde_json::from_value(rules).unwrap()), ..Default::default() })); + c.apply(Op::AddAlbum { album: a }).unwrap(); + AlbumId(id) +} + +/// Best of five runs (the machine may be busy with other work). +fn time(what: &str, f: impl Fn() -> T) -> T { + let mut best = f64::MAX; + let mut r = None; + for _ in 0..5 { + let t = Instant::now(); + r = Some(f()); + best = best.min(t.elapsed().as_secs_f64() * 1e3); + } + eprintln!("{what}: {best:.1} ms"); + r.unwrap() +} + +#[test] +#[ignore] +fn scale_100k() { + let (mut c, album) = library(100_000); + let all = Filter::default(); + time("query, capture date", || c.query(&all, &Sort::default())); + time("query, file name", || c.query(&all, &Sort { key: SortKey::FileName, ..Default::default() })); + time("query, text", || c.query(&Filter { text: "kw42".into(), ..Default::default() }, &Sort::default())); + time("query, album of 20k", || c.query(&Filter { album: Some(album), ..Default::default() }, &Sort::default())); + let only: Vec = (0..5000).map(|i| PhotoId(i * 7 + 1)).collect(); + time("query, only 5k ids", || c.query(&Filter { only: only.clone(), ..Default::default() }, &Sort::default())); + let stacked = smart(&mut c, 10, json!({"match": "all", "rules": [{"field": "stacked", "op": "is", "value": true}]})); + let flagged = smart( + &mut c, + 11, + json!({"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}, {"field": "kind", "op": "is", "value": "image"}]}), + ); + let keyword = smart(&mut c, 12, json!({"match": "all", "rules": [{"field": "keywords", "op": "contains", "value": "kw42"}]})); + let in_album = smart(&mut c, 13, json!({"match": "all", "rules": [{"field": "album", "op": "is", "value": album.0}]})); + let recent = smart(&mut c, 14, json!({"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": {"n": 2, "unit": "years"}}]})); + for (what, a) in [("stacked", stacked), ("flag + kind", flagged), ("keyword", keyword), ("in album", in_album), ("in the last 2 years", recent)] { + let n = time(&format!("smart album count, {what}"), || c.album_count(a)); + eprintln!(" = {n}"); + } + time("albums_of", || c.albums_of(PhotoId(6))); + time("arrange stacks", || c.arrange_stacks(&c.query(&all, &Sort::default()))); + time("stack_of × 1000", || (0..1000u64).filter(|i| c.stack_of(PhotoId(i * 97 + 1)).is_some()).count()); +} diff --git a/crates/codecs/Cargo.toml b/crates/codecs/Cargo.toml index 166fbf4dc..589d285fa 100644 --- a/crates/codecs/Cargo.toml +++ b/crates/codecs/Cargo.toml @@ -48,8 +48,13 @@ heif = ["dep:lightcraft-heif"] avif = ["dep:ravif", "dep:rav1e", "dep:avif-serialize"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # JPEG XL tests read encoded samples without our colour conversion jxl-oxide = { workspace = true } proptest = { workspace = true } # Lossless synthetic HEIC files for the `heif` feature's tests. lightcraft-heif = { workspace = true, features = ["testdata"] } + +[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies] +dhat = { workspace = true } diff --git a/crates/codecs/README.md b/crates/codecs/README.md index 6ea5866b7..5902243fc 100644 --- a/crates/codecs/README.md +++ b/crates/codecs/README.md @@ -73,6 +73,10 @@ ICC profiles are parsed with `moxcms` (v2/v4, `curv`/`para` TRCs, `chad`); sRGB, Adobe RGB (1998), ProPhoto (ROMM) and Rec.2020 are recognised by colorants. `icc::write_matrix_trc` emits v4 display profiles (D50 PCS, Bradford `chad`) for export embedding. +Before colour conversion, positive-infinity float samples become 1.0; NaN and negative infinity +become 0.0. This also applies to alpha. Finite samples keep their existing range: matrix/TRC paths +preserve values above 1.0 and below 0.0, while CMS inputs and alpha remain bounded to 0..1. + ## Dependencies and licences All pure Rust, verified with `cargo info` / `cargo tree`: diff --git a/crates/codecs/src/convert.rs b/crates/codecs/src/convert.rs index 02652db5a..836aed548 100644 --- a/crates/codecs/src/convert.rs +++ b/crates/codecs/src/convert.rs @@ -44,11 +44,23 @@ impl Buf { match self { Buf::U8(v) => v[i] as f32 / 255.0, Buf::U16(v) => v[i] as f32 / 65535.0, - Buf::F32(v) => v[i], + Buf::F32(v) => normalize_float(v[i]), } } } +/// Clip non-finite device samples before colour conversion, preserving finite extended range. +#[inline] +fn normalize_float(value: f32) -> f32 { + if value == f32::INFINITY { + 1.0 + } else if value.is_finite() { + value + } else { + 0.0 + } +} + /// Interleaved device samples as produced by a format decoder. pub(crate) struct Raw { pub width: usize, @@ -218,10 +230,7 @@ fn linearize(raw: &Raw, ch: usize, trc: &[Trc; 3]) -> Rgb32f { par_rows(&mut img.data, w, |y, row| { for (x, o) in row.iter_mut().enumerate() { let i = (y * w + x) * ch; - let s = |c: usize| { - let val = v[i + c]; - if val.is_finite() { val } else { 0.0 } - }; + let s = |c: usize| normalize_float(v[i + c]); *o = if gray { let l = if lin { s(0) } else { trc[1].to_linear(s(0)) }; [l, l, l] diff --git a/crates/codecs/src/jpeg.rs b/crates/codecs/src/jpeg.rs index e40cf7e73..b8de12bdd 100644 --- a/crates/codecs/src/jpeg.rs +++ b/crates/codecs/src/jpeg.rs @@ -75,14 +75,12 @@ pub(crate) fn parse_markers(b: &[u8]) -> Option { let end = (p + 2 + len).min(b.len()); let seg = &b[start..end]; match marker { - 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC => { - if seg.len() >= 6 && m.components == 0 { - m.precision = seg[0]; - m.sof = marker; - m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; - m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; - m.components = seg[5]; - } + 0xC0..=0xCF if marker != 0xC4 && marker != 0xC8 && marker != 0xCC && seg.len() >= 6 && m.components == 0 => { + m.precision = seg[0]; + m.sof = marker; + m.height = u16::from_be_bytes([seg[1], seg[2]]) as u32; + m.width = u16::from_be_bytes([seg[3], seg[4]]) as u32; + m.components = seg[5]; } 0xE1 => { if seg.starts_with(b"Exif\0\0") && m.exif.is_none() { @@ -182,9 +180,11 @@ pub(crate) fn decode_with_fallback(bytes: &[u8], opts: &DecodeOptions, fallback: check_size(F, m.width as u64, m.height as u64, opts)?; let scale_to = opts.max_size.filter(|&(mw, mh)| mw > 0 && mh > 0 && (m.width >= mw.saturating_mul(2) || m.height >= mh.saturating_mul(2))); // zune-jpeg handles the common cases fastest; jpeg-decoder covers DCT scaling, CMYK/YCCK, - // 12-bit and non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling). + // 12-bit, non-interleaved sequential scans (which zune-jpeg 0.5 mis-decodes with subsampling) + // and every lossless (SOF3) file, which zune-jpeg refuses outright. + let lossless = m.sof == 0xC3; let non_interleaved = matches!(m.sof, 0xC0 | 0xC1) && m.first_scan_components < m.components; - let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved { + let raw = if m.components == 4 || scale_to.is_some() || m.precision > 8 || non_interleaved || lossless { decode_jpeg_decoder(bytes, &m, scale_to)? } else { decode_zune(bytes, &m)? @@ -221,7 +221,9 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) use jpeg_decoder::PixelFormat; let mut d = jpeg_decoder::Decoder::new(bytes); d.read_info().map_err(|e| Error::Malformed(F, e.to_string()))?; - if let Some((mw, mh)) = scale_to { + // `scale` is the DCT size, not a lossless resample: on a lossless frame it would cut the + // output to the top-left of each plane. Those decode at full size; `finish` scales after. + if let Some((mw, mh)) = scale_to.filter(|_| m.sof != 0xC3) { // Ask for the smallest DCT scale still covering the target box. let (sw, sh) = scaled_request(m.width, m.height, mw, mh); d.scale(sw, sh).map_err(|e| Error::Malformed(F, e.to_string()))?; @@ -231,16 +233,25 @@ fn decode_jpeg_decoder(bytes: &[u8], m: &Markers, scale_to: Option<(u32, u32)>) let (w, h) = (info.width as usize, info.height as usize); let (model, buf, depth) = match info.pixel_format { PixelFormat::L8 => (Model::Gray, Buf::U8(px), 8), - PixelFormat::L16 => { - let v = px.as_chunks::<2>().0.iter().map(|c| u16::from_be_bytes([c[0], c[1]])).collect(); - (Model::Gray, Buf::U16(v), 16) - } + PixelFormat::L16 => (Model::Gray, Buf::U16(lossless_samples(&px, m.precision)), 16), + // A lossless frame over 8 bits per sample still reports `RGB24` (or `CMYK32`), but its + // buffer holds two native-endian bytes per sample like L16. + PixelFormat::RGB24 if m.precision > 8 => (Model::Rgb, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::RGB24 => (Model::Rgb, Buf::U8(px), 8), + PixelFormat::CMYK32 if m.precision > 8 => (Model::Cmyk, Buf::U16(lossless_samples(&px, m.precision)), 16), PixelFormat::CMYK32 => (Model::Cmyk, Buf::U8(px), 8), }; Ok(Raw { width: w, height: h, model, alpha: false, premultiplied: false, buf, bit_depth: depth }) } +/// jpeg-decoder's lossless samples: one native-endian `u16` per sample holding the raw +/// `precision`-bit value. Left-justify it into the 16-bit range the rest of the pipeline reads, +/// as every 16-bit decoder feeding [`Buf::U16`] does. +fn lossless_samples(px: &[u8], precision: u8) -> Vec { + let shift = 16u8.saturating_sub(precision); + px.as_chunks::<2>().0.iter().map(|c| u16::from_ne_bytes([c[0], c[1]]) << shift).collect() +} + /// The first `0xFF` byte, eight bytes at a time (scan data has one every few hundred bytes: this /// is most of [`header`]'s time). fn find_ff(b: &[u8]) -> Option { @@ -450,4 +461,94 @@ mod tests { let m = parse_markers(&[0xFF, 0xD8, 0xFF, 0xE1, 0, 200, 1, 2]).unwrap(); assert!(m.exif.is_none()); } + + // --- lossless (SOF3) synthetic files: one DHT table with a single 1-bit category-0 code, so + // every pixel is a zero difference and decodes to the predictor's initial sample. No such + // encoder exists here; the bytes are the smallest a decoder accepts. + + fn lossless_header(out: &mut Vec, components: u8, w: u16, precision: u8) { + // DHT: one DC table (id 0) with a single 1-bit code for the category-0 symbol. + out.extend_from_slice(&[0xFF, 0xC4]); + out.extend_from_slice(&(2u16 + 1 + 16 + 1).to_be_bytes()); + out.push(0x00); + let mut counts = [0u8; 16]; + counts[0] = 1; + out.extend_from_slice(&counts); + out.push(0x00); + // SOF3 + out.extend_from_slice(&[0xFF, 0xC3]); + out.extend_from_slice(&(8u16 + 3 * components as u16).to_be_bytes()); + out.push(precision); + out.extend_from_slice(&1u16.to_be_bytes()); + out.extend_from_slice(&w.to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0x11, 0]); + } + // SOS: predictor Ra, Se = 0, Ah/Al = 0 + out.extend_from_slice(&[0xFF, 0xDA]); + out.extend_from_slice(&(6u16 + 2 * components as u16).to_be_bytes()); + out.push(components); + for c in 1..=components { + out.extend_from_slice(&[c, 0]); + } + out.extend_from_slice(&[1, 0, 0]); + } + + /// A minimal lossless JPEG, `w` x 1, `components` samples per pixel, every sample the initial + /// prediction `1 << (precision - 1)`. + fn lossless_jpeg(components: u8, w: u16, precision: u8) -> Vec { + let mut out = vec![0xFF, 0xD8]; + lossless_header(&mut out, components, w, precision); + // entropy: one zero bit per sample (a category-0 code), padded to whole bytes + out.extend_from_slice(&vec![0u8; (components as usize * w as usize).div_ceil(8)]); + out.extend_from_slice(&[0xFF, 0xD9]); + out + } + + /// jpeg-decoder emits lossless samples native-endian (its `to_ne_bytes`) holding the raw + /// `precision`-bit value, so the old `from_be_bytes` swapped every sample (2048 -> 8) and the + /// raw value was never left-justified into the 16-bit buffer. + #[test] + fn lossless_grayscale_samples_are_native_endian_and_left_justified() { + let bytes = lossless_jpeg(1, 8, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8], "0x0800 native-endian, left-justified"); + assert_eq!((raw.model, raw.bit_depth), (Model::Gray, 16)); + } + + /// A lossless frame over 8 bits per sample reports `RGB24`, but its buffer is two bytes per + /// sample: it must become `Buf::U16`, not `Buf::U8` over double-length rows. + #[test] + fn lossless_high_precision_rgb_is_16_bit() { + let bytes = lossless_jpeg(3, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 6]); + assert_eq!(raw.model, Model::Rgb); + } + + /// The same 16-bit wrapping for four components: `CMYK32` over 8 bits is two bytes a sample. + #[test] + fn lossless_high_precision_cmyk_is_16_bit() { + let bytes = lossless_jpeg(4, 2, 12); + let m = parse_markers(&bytes).unwrap(); + let raw = decode_jpeg_decoder(&bytes, &m, None).unwrap(); + let Buf::U16(v) = &raw.buf else { panic!("expected 16-bit samples") }; + assert_eq!(v, &vec![2048u16 << 4; 8]); + assert_eq!((raw.model, raw.bit_depth), (Model::Cmyk, 16)); + } + + /// zune-jpeg refuses SOF3 ("Lossless Huffman"), so an 8-bit lossless file must route to + /// jpeg-decoder; `header` accepts it, so `decode` used to fail a file we listed as readable. + #[test] + fn eight_bit_lossless_jpeg_decodes() { + let bytes = lossless_jpeg(1, 8, 8); + let d = crate::decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!((d.width, d.height), (8, 1)); + assert_eq!(d.bit_depth, 8); + } } diff --git a/crates/codecs/src/jpeg_par.rs b/crates/codecs/src/jpeg_par.rs index 6ec9b6a80..c8d9e5023 100644 --- a/crates/codecs/src/jpeg_par.rs +++ b/crates/codecs/src/jpeg_par.rs @@ -3,8 +3,11 @@ //! The image is cut into bands of MCU rows separated by restart markers (`DRI` + `RSTn`): DC //! prediction restarts at every marker, so bands are entropy-coded independently on all cores and //! concatenated. Quantization uses the example tables of T.81 Annex K scaled by the usual 1–100 -//! quality convention; Huffman coding uses the Annex K example tables (interleaved baseline, readable -//! by every decoder). Supports grayscale and YCbCr 4:4:4 / 4:2:0. +//! quality convention. Huffman tables are made for each image from its own symbol counts (T.81 +//! Annex K.2): a first pass over the bands counts the symbols, a second codes them. The file stays +//! interleaved baseline with the tables in its `DHT` segments, readable by every decoder, and is +//! smaller than with the Annex K example tables at the same pixels. Supports grayscale and YCbCr +//! 4:4:4 / 4:2:0. use crate::encode::ChromaSubsampling; @@ -61,16 +64,103 @@ impl Huff { let (mut c, mut k) = (0u16, 0usize); for (len, &n) in bits.iter().enumerate() { for _ in 0..n { - code[vals[k] as usize] = (c, len as u8 + 1); - c += 1; + if let Some(&v) = vals.get(k) { + code[v as usize] = (c, len as u8 + 1); + } + c = c.wrapping_add(1); k += 1; } - c <<= 1; + c = c.wrapping_shl(1); } Huff { code } } } +/// Symbol counts of one Huffman table: index = symbol (DC size category, or AC run/size byte). +type Freq = [u64; 256]; + +/// The code lengths (`BITS`, counts per length 1..16) and symbols (`HUFFVAL`) of an optimal +/// Huffman table for `freq`, limited to 16-bit codes and never assigning the all-ones code +/// (T.81 Annex K.2, figures K.1–K.4). Symbols with a zero count get no code. At least one symbol +/// is always present (a table nothing used gets symbol 0), so the result is a valid `DHT`. +fn optimal_table(freq: &Freq) -> ([u8; 16], Vec) { + // 256 is the reserved pseudo-symbol: counted once, it takes the longest code, which is + // then dropped so no real code is all ones (K.2) + let mut f = [0u64; 257]; + f[..256].copy_from_slice(freq); + if f[..256].iter().all(|&c| c == 0) { + f[0] = 1; + } + f[256] = 1; + let mut size = [0usize; 257]; + let mut next: [Option; 257] = [None; 257]; + loop { + // the two smallest non-zero counts; on equal counts the larger symbol goes first (K.1) + let mut c1: Option = None; + for i in 0..257 { + if f[i] > 0 && c1.is_none_or(|c| f[i] <= f[c]) { + c1 = Some(i); + } + } + let Some(c1) = c1 else { break }; + let mut c2: Option = None; + for i in 0..257 { + if i != c1 && f[i] > 0 && c2.is_none_or(|c| f[i] <= f[c]) { + c2 = Some(i); + } + } + let Some(c2) = c2 else { break }; + f[c1] = f[c1].saturating_add(f[c2]); + f[c2] = 0; + // every symbol in both trees goes one level deeper; c2's chain is appended to c1's + let mut k = c1; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + next[k] = Some(c2); + let mut k = c2; + size[k] += 1; + while let Some(n) = next[k] { + k = n; + size[k] += 1; + } + } + // counts per code length; 257 symbols need at most 256 levels + let mut bits = [0usize; 257]; + for &s in &size { + if s > 0 { + bits[s.min(256)] += 1; + } + } + // limit to 16 bits (K.3): move pairs of the longest codes up, one level at a time (the + // deepest level of a full tree always holds an even number of codes) + for i in (17..257).rev() { + while bits[i] >= 2 { + let Some(j) = (1..i - 1).rev().find(|&j| bits[j] > 0) else { break }; + bits[i] -= 2; + bits[i - 1] += 1; + bits[j + 1] += 2; + bits[j] -= 1; + } + } + // drop the reserved symbol's code: it is one of the longest + if let Some(i) = (1..=16).rev().find(|&i| bits[i] > 0) { + bits[i] -= 1; + } + // symbols by code length, then by value (K.4); the reserved symbol is not listed + let mut vals = Vec::new(); + for len in 1..257 { + vals.extend((0..256).filter(|&s| size[s] == len).map(|s| s as u8)); + } + let mut out = [0u8; 16]; + for (o, &b) in out.iter_mut().zip(&bits[1..=16]) { + *o = b.min(255) as u8; + } + (out, vals) +} + fn scaled_quant(base: &[u16; 64], quality: u8) -> [u16; 64] { let q = quality.clamp(1, 100) as u32; let scale = if q < 50 { 5000 / q } else { 200 - 2 * q }; @@ -177,13 +267,15 @@ fn magnitude(v: i32) -> (u32, u32) { (size, bits & ((1u32 << size) - 1)) } -fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huff) { +/// The Huffman symbols of one quantized block in coding order: `sym(ac, symbol, bits, n)` for the +/// DC size category (`ac` false) and each AC run/size, ZRL and EOB (`ac` true), each followed by +/// its `n` extra bits `bits`. +#[inline(always)] +fn block_symbols(q: &[i16; 64], pred: &mut i32, mut sym: impl FnMut(bool, usize, u32, u32)) { let d = q[0] as i32 - *pred; *pred = q[0] as i32; let (s, b) = magnitude(d); - let (c, l) = dc.code[s as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(false, s as usize, b, s); let mut run = 0u32; for &zi in &ZIGZAG[1..] { let v = q[zi] as i32; @@ -192,22 +284,45 @@ fn encode_block(w: &mut Bits, q: &[i16; 64], pred: &mut i32, dc: &Huff, ac: &Huf continue; } while run >= 16 { - let (c, l) = ac.code[0xF0]; - w.put(c as u32, l as u32); + sym(true, 0xF0, 0, 0); run -= 16; } let (s, b) = magnitude(v); - let (c, l) = ac.code[((run << 4) | s) as usize]; - w.put(c as u32, l as u32); - w.put(b, s); + sym(true, ((run << 4) | s) as usize, b, s); run = 0; } if run > 0 { - let (c, l) = ac.code[0x00]; - w.put(c as u32, l as u32); + sym(true, 0x00, 0, 0); } } +/// One coded symbol of the entropy-coded data: bits 0–7 the symbol, 8–9 its table (0 DC luma, +/// 1 AC luma, 2 DC chroma, 3 AC chroma), 10–13 the number of extra bits, 14–29 those bits (a +/// baseline coefficient needs at most 11). +type Token = u32; + +/// Append one block's symbols to `out` and count them in `freq` (by table, as in [`Token`]). +fn tokenize_block(q: &[i16; 64], pred: &mut i32, chroma: bool, out: &mut Vec, freq: &mut [Freq; 4]) { + let base = if chroma { 2 } else { 0 }; + block_symbols(q, pred, |is_ac, s, bits, n| { + let table = base + is_ac as usize; + freq[table][s & 0xFF] += 1; + out.push((s as u32 & 0xFF) | (table as u32) << 8 | (n & 0xF) << 10 | (bits & 0xFFFF) << 14); + }); +} + +/// Code a band's symbols with `tables` (indexed as in [`Token`]). +fn write_tokens(tokens: &[Token], tables: &[Huff; 4]) -> Vec { + let mut w = Bits::new(); + for &t in tokens { + let (s, table, n, bits) = (t & 0xFF, (t >> 8) & 3, (t >> 10) & 0xF, t >> 14); + let (c, l) = tables[table as usize].code[s as usize]; + w.put(c as u32, l as u32); + w.put(bits, n); + } + w.finish() +} + /// Planar component samples (level-shifted to −128..127) with edge replication on read. struct Plane { w: usize, @@ -249,7 +364,8 @@ fn dht(out: &mut Vec, class_id: u8, bits: &[u8; 16], vals: &[u8]) { segment(out, 0xC4, &p); } -/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped). +/// Encode interleaved 8-bit samples (`channels` 1, 3 or 4; alpha is dropped), with Huffman +/// tables made for the image. pub fn encode( data: &[u8], width: usize, @@ -258,6 +374,21 @@ pub fn encode( quality: u8, sub: ChromaSubsampling, app_segments: &[(u8, Vec)], +) -> Vec { + encode_with(data, width, height, channels, quality, sub, app_segments, true) +} + +/// [`encode`], with optimized Huffman tables or (`optimize` false) the Annex K example tables. +#[allow(clippy::too_many_arguments)] +fn encode_with( + data: &[u8], + width: usize, + height: usize, + channels: usize, + quality: u8, + sub: ChromaSubsampling, + app_segments: &[(u8, Vec)], + optimize: bool, ) -> Vec { let gray = channels == 1; let (hs, vs) = match (gray, sub) { @@ -331,42 +462,67 @@ pub fn encode( let ql = scaled_quant(&Q_LUMA, quality); let qc = scaled_quant(&Q_CHROMA, quality); let (rl, rc) = (recip_table(&ql), recip_table(&qc)); - let (dcl, acl) = (Huff::new(&DC_LUMA_BITS, &DC_VALS), Huff::new(&AC_LUMA_BITS, &AC_LUMA_VALS)); - let (dcc, acc) = (Huff::new(&DC_CHROMA_BITS, &DC_VALS), Huff::new(&AC_CHROMA_BITS, &AC_CHROMA_VALS)); - let (mcu_w, mcu_h) = (8 * hs, 8 * vs); let (mx, my) = (width.div_ceil(mcu_w), height.div_ceil(mcu_h)); // restart interval: whole MCU rows per band, ≤ 65535 MCUs, enough bands to keep all cores busy let rows_per_band = (my / 64).clamp(1, (65_535 / mx.max(1)).max(1)); let bands: Vec = (0..my.div_ceil(rows_per_band)).collect(); - let encode_band = |band: &usize| -> Vec { - let mut w = Bits::new(); - let mut pred = [0i32; 3]; + // first pass: each band's quantized blocks as symbols, with their counts per table (DC + // prediction restarts per band, as the restart markers say) + let tokenize_band = |band: &usize| -> (Vec, [Freq; 4]) { let (mut blk, mut q) = ([0f32; 64], [0i16; 64]); + let (mut tokens, mut freq) = (Vec::new(), [[0u64; 256]; 4]); + let mut pred = [0i32; 3]; for mcu_y in band * rows_per_band..((band + 1) * rows_per_band).min(my) { for mcu_x in 0..mx { for vy in 0..vs { for hx in 0..hs { planes[0].block(mcu_x * hs + hx, mcu_y * vs + vy, &mut blk); fdct_quant(&blk, &rl, &mut q); - encode_block(&mut w, &q, &mut pred[0], &dcl, &acl); + tokenize_block(&q, &mut pred[0], false, &mut tokens, &mut freq); } } if !gray { - for k in 1..3 { - planes[k].block(mcu_x, mcu_y, &mut blk); + for (plane, pred) in planes.iter().zip(pred.iter_mut()).take(3).skip(1) { + plane.block(mcu_x, mcu_y, &mut blk); fdct_quant(&blk, &rc, &mut q); - encode_block(&mut w, &q, &mut pred[k], &dcc, &acc); + tokenize_block(&q, pred, true, &mut tokens, &mut freq); } } } } - w.finish() + (tokens, freq) }; #[cfg(feature = "parallel")] - let coded: Vec> = bands.par_iter().map(encode_band).collect(); + let tokenized: Vec<(Vec, [Freq; 4])> = bands.par_iter().map(tokenize_band).collect(); #[cfg(not(feature = "parallel"))] - let coded: Vec> = bands.iter().map(encode_band).collect(); + let tokenized: Vec<(Vec, [Freq; 4])> = bands.iter().map(tokenize_band).collect(); + drop(planes); + // the tables: DC luma, AC luma, DC chroma, AC chroma + let tables: [([u8; 16], Vec); 4] = if optimize { + let mut total = [[0u64; 256]; 4]; + for (_, f) in &tokenized { + for (t, f) in total.iter_mut().zip(f) { + for (t, f) in t.iter_mut().zip(f) { + *t = t.saturating_add(*f); + } + } + } + total.each_ref().map(optimal_table) + } else { + [ + (DC_LUMA_BITS, DC_VALS.to_vec()), + (AC_LUMA_BITS, AC_LUMA_VALS.to_vec()), + (DC_CHROMA_BITS, DC_VALS.to_vec()), + (AC_CHROMA_BITS, AC_CHROMA_VALS.to_vec()), + ] + }; + let huff = tables.each_ref().map(|(bits, vals)| Huff::new(bits, vals)); + // second pass: code the symbols + #[cfg(feature = "parallel")] + let coded: Vec> = tokenized.into_par_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); + #[cfg(not(feature = "parallel"))] + let coded: Vec> = tokenized.into_iter().map(|(t, _)| write_tokens(&t, &huff)).collect(); // headers let mut out = Vec::with_capacity(coded.iter().map(Vec::len).sum::() + 4096); @@ -396,11 +552,12 @@ pub fn encode( sof.extend_from_slice(&[3, 1, ((hs as u8) << 4) | vs as u8, 0, 2, 0x11, 1, 3, 0x11, 1]); } segment(&mut out, 0xC0, &sof); - dht(&mut out, 0x00, &DC_LUMA_BITS, &DC_VALS); - dht(&mut out, 0x10, &AC_LUMA_BITS, &AC_LUMA_VALS); + let [(dlb, dlv), (alb, alv), (dcb, dcv), (acb, acv)] = &tables; + dht(&mut out, 0x00, dlb, dlv); + dht(&mut out, 0x10, alb, alv); if !gray { - dht(&mut out, 0x01, &DC_CHROMA_BITS, &DC_VALS); - dht(&mut out, 0x11, &AC_CHROMA_BITS, &AC_CHROMA_VALS); + dht(&mut out, 0x01, dcb, dcv); + dht(&mut out, 0x11, acb, acv); } let interval = (rows_per_band * mx) as u16; if coded.len() > 1 { @@ -495,6 +652,100 @@ mod tests { } } + /// The code lengths of `(bits, vals)`, checked: every listed symbol once, at most 16 bits, + /// and room left in the code space (the all-ones code is never assigned, Kraft sum < 1). + fn check_table(bits: &[u8; 16], vals: &[u8]) { + assert_eq!(bits.iter().map(|&b| b as usize).sum::(), vals.len()); + let mut seen = [false; 256]; + for &v in vals { + assert!(!seen[v as usize], "symbol {v} listed twice"); + seen[v as usize] = true; + } + let kraft: f64 = bits.iter().enumerate().map(|(i, &n)| n as f64 / 2f64.powi(i as i32 + 1)).sum(); + assert!(kraft < 1.0, "code space full: {kraft}"); + let h = Huff::new(bits, vals); + for &v in vals { + let (c, l) = h.code[v as usize]; + assert!((1..=16).contains(&l)); + assert_ne!(c as u32, (1u32 << l) - 1, "symbol {v} has the all-ones code"); + } + } + + #[test] + fn optimal_tables_are_valid_and_bounded() { + // one symbol, two, a typical spread and every symbol + let mut one = [0u64; 256]; + one[7] = 5; + let mut two = [0u64; 256]; + two[0] = 1; + two[0xF0] = 1_000_000; + let typical: Freq = std::array::from_fn(|s| if s % 3 == 0 { (s as u64 * 7919) % 1000 + 1 } else { 0 }); + let all: Freq = std::array::from_fn(|s| s as u64 + 1); + // Fibonacci counts make an unbounded Huffman tree ~40 levels deep: the 16-bit limit applies + let mut fib = [0u64; 256]; + let (mut a, mut b) = (1u64, 1u64); + for f in fib.iter_mut().take(40) { + *f = a; + (a, b) = (b, a + b); + } + for (name, f) in [("one", one), ("two", two), ("typical", typical), ("all", all), ("fib", fib), ("none", [0; 256])] { + let (bits, vals) = optimal_table(&f); + check_table(&bits, &vals); + for s in 0..256 { + assert_eq!(vals.contains(&(s as u8)), f[s] > 0 || (name == "none" && s == 0), "{name}: symbol {s}"); + } + } + // more frequent symbols never get longer codes + let (bits, vals) = optimal_table(&typical); + let h = Huff::new(&bits, &vals); + for &x in &vals { + for &y in &vals { + if typical[x as usize] > typical[y as usize] { + assert!(h.code[x as usize].1 <= h.code[y as usize].1, "{x} vs {y}"); + } + } + } + // the Annex K example tables pass the same checks + check_table(&AC_LUMA_BITS, &AC_LUMA_VALS); + check_table(&DC_CHROMA_BITS, &DC_VALS); + } + + /// Issue #1373: tables made for the image give a smaller file with exactly the same pixels as + /// the Annex K example tables (the quantized coefficients are the same), at every quality and + /// subsampling, and both decoders read it. + #[test] + fn optimized_tables_shrink_files_without_changing_pixels() { + let (w, h) = (517, 333); + // gradients plus deterministic noise, like a photo's grain + let mut seed = 0x2545_f491_u32; + let src: Vec = test_rgb(w, h) + .into_iter() + .map(|v| { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + v.saturating_add((seed % 23) as u8) + }) + .collect(); + for (q, sub) in [(100, ChromaSubsampling::S444), (92, ChromaSubsampling::S444), (75, ChromaSubsampling::S420), (30, ChromaSubsampling::S420)] + { + let std = encode_with(&src, w, h, 3, q, sub, &[], false); + let opt = encode(&src, w, h, 3, q, sub, &[]); + assert!(opt.len() < std.len(), "q{q} {sub:?}: {} ≥ {}", opt.len(), std.len()); + let a = jpeg_decoder::Decoder::new(&std[..]).decode().expect("standard tables"); + let b = jpeg_decoder::Decoder::new(&opt[..]).decode().expect("optimized tables"); + assert_eq!(a, b, "q{q} {sub:?}: pixels differ"); + let z = zune_jpeg::JpegDecoder::new(zune_core::bytestream::ZCursor::new(&opt[..])).decode().expect("zune-jpeg"); + assert_eq!(z.len(), b.len()); + } + // grayscale too + let gray: Vec = src.iter().step_by(3).copied().collect(); + let std = encode_with(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[], false); + let opt = encode(&gray, w, h, 1, 90, ChromaSubsampling::S444, &[]); + assert!(opt.len() < std.len()); + assert_eq!(jpeg_decoder::Decoder::new(&std[..]).decode().unwrap(), jpeg_decoder::Decoder::new(&opt[..]).decode().unwrap()); + } + #[test] fn grayscale_and_quality_monotone() { let (w, h) = (300, 200); diff --git a/crates/codecs/src/jxl.rs b/crates/codecs/src/jxl.rs index 26e144aed..adf59c0aa 100644 --- a/crates/codecs/src/jxl.rs +++ b/crates/codecs/src/jxl.rs @@ -8,7 +8,7 @@ use crate::convert::{Buf, Meta, Model, Raw, check_size, finish}; use crate::space::{NamedSpace, SourceSpace, SpaceOrigin, Trc}; use crate::{DecodeOptions, Decoded, Error, Format, Result}; use jxl_oxide::color::{ColourSpace, EnumColourEncoding, Primaries, RenderingIntent, TransferFunction, WhitePoint}; -use jxl_oxide::{AuxBoxData, JxlImage}; +use jxl_oxide::{AllocTracker, AuxBoxData, InitializeResult, JxlImage}; const F: Format = Format::Jxl; @@ -16,10 +16,60 @@ fn err(e: impl std::fmt::Display) -> Error { Error::Malformed(F, e.to_string()) } +/// Tracked allocation budget per allowed pixel for frames, extra channels and modular trees. +/// This bounds allocations covered by `AllocTracker`, not every allocation made by the decoder (#1517). +const TRACKED_BYTES_PER_PIXEL: u64 = 4 * 4 * 8; + +/// The allocation budget never drops below this, so small images keep the decoder's fixed +/// overhead (group buffers, entropy tables) whatever `max_pixels` is. +const MIN_TRACKED_BYTES: u64 = 64 << 20; + +/// Bytes handed to the decoder at a time (the size `JxlImageBuilder::read` reads in). +const INPUT_CHUNK: usize = 4096; + +/// Initializes from bounded input, checks dimensions before rendering, then loads remaining frames +/// within a tracked allocation budget. +fn open(bytes: &[u8], opts: &DecodeOptions) -> Result { + let budget = opts.max_pixels.saturating_mul(TRACKED_BYTES_PER_PIXEL).max(MIN_TRACKED_BYTES); + let tracker = AllocTracker::with_limit(usize::try_from(budget).unwrap_or(usize::MAX)); + let mut uninit = JxlImage::builder().alloc_tracker(tracker).build_uninit(); + // Initialization can load already-buffered frame data, so small feeds limit work before + // dimension checking. + let mut rest = bytes; + let mut window = INPUT_CHUNK; + let mut img = loop { + let consumed = uninit.feed_bytes(rest.get(..window).unwrap_or(rest)).map_err(err)?; + rest = rest.get(consumed..).unwrap_or_default(); + match uninit.try_init().map_err(err)? { + InitializeResult::Initialized(img) => break img, + InitializeResult::NeedMoreData(more) => uninit = more, + } + if consumed == 0 { + // the parser wants more than the window holds at once + if window >= rest.len() { + return Err(err("truncated JPEG XL header")); + } + window = window.saturating_mul(2); + } + }; + check_size(F, img.width() as u64, img.height() as u64, opts)?; + // Match `JxlImageBuilder::read`: bounded feeds until the final frame, not merely the first + // keyframe. `feed_bytes` copies codestream data into an untracked Vec, including any tail + // after the last frame; feeding the remaining file at once bypasses the allocation budget. + while !img.is_loading_done() && !rest.is_empty() { + let consumed = img.feed_bytes(rest.get(..INPUT_CHUNK).unwrap_or(rest)).map_err(err)?; + if consumed == 0 { + break; + } + rest = rest.get(consumed..).unwrap_or_default(); + } + img.finalize().map_err(err)?; + Ok(img) +} + pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { - let mut img = JxlImage::builder().read(std::io::Cursor::new(bytes)).map_err(err)?; + let mut img = open(bytes, opts)?; let (w, h) = (img.width(), img.height()); - check_size(F, w as u64, h as u64, opts)?; let header = img.image_header(); let bits = header.metadata.bit_depth.bits_per_sample() as u8; let float = matches!(header.metadata.bit_depth, jxl_oxide::image::BitDepth::FloatSample { .. }); diff --git a/crates/codecs/src/lib.rs b/crates/codecs/src/lib.rs index e524cffe8..d14b1812f 100644 --- a/crates/codecs/src/lib.rs +++ b/crates/codecs/src/lib.rs @@ -12,6 +12,7 @@ //! - [`encode`] writes JPEG, PNG, TIFF, lossless WebP and, on native targets, AVIF (feature `avif`) and //! JPEG XL ([`encode_jxl`], feature `jxl`), embedding //! ICC/EXIF/XMP. [`icc::write_matrix_trc`] builds profiles for export. +//! - [`video`] encodes AV1 video (native, feature `avif`) and writes it as MP4 or WebM. //! //! No decoder panics on malformed input (property-tested). #![forbid(unsafe_code)] @@ -31,11 +32,13 @@ mod jxl; mod jxl_encode; mod other; mod png_codec; +pub mod print; pub mod proof; mod psd; mod sniff; pub mod space; mod tiff_codec; +pub mod video; mod webp; pub use encode::{ diff --git a/crates/codecs/src/print.rs b/crates/codecs/src/print.rs new file mode 100644 index 000000000..560e2710d --- /dev/null +++ b/crates/codecs/src/print.rs @@ -0,0 +1,375 @@ +//! Print colour: a composed print page (16-bit RGB encoded with the BT.2020 / BT.709 curve, as the +//! `Rec2020` output space renders it) to the samples a print-ready file carries, in the colour space +//! of the chosen printer / paper profile. +//! +//! - **Target**: a printer / paper ICC profile (RGB, CMYK or grey; matrix or LUT based) converts the +//! page to the device's own values. When the printer manages colour instead, the page is sent in a +//! well-known RGB space (sRGB, Adobe RGB) and that space's profile travels with it. +//! - **Rendering intent**: perceptual or relative colorimetric, as for soft proofing ([`ProofIntent`]). +//! - **Black point compensation** (relative colorimetric only): the darkest colour the device holds is +//! measured by a round trip of black through the profile, and the page's linear values are scaled +//! so its black lands there instead of every shadow below it clipping to the same ink. Linear +//! Rec.2020 is linear in XYZ, so with source black 0 and white 1 this is the published BPC mapping +//! `Y' = Y·(1 − Yb) + Yb` for a neutral destination black. Perceptual tables carry their own black +//! mapping and are left alone. +//! - **Print adjustment**: brightness and contrast (−100..100) for prints that come out darker or +//! flatter than the screen, on the sRGB-encoded values with black and white held. It is not shown +//! on screen (the print is what it corrects). +//! +//! Everything before the profile conversion is per channel, so it folds into one 65,536-entry table +//! indexed by the 16-bit sample; the conversion itself is moxcms's. +//! +//! Sources: ICC.1:2022 (rendering intents, profile classes); Adobe Systems, "Adobe Black Point +//! Compensation" (2006, public white paper) for the black point mapping. Own design otherwise. + +use std::sync::Arc; + +use moxcms::{ColorProfile, DataColorSpace, Layout, RenderingIntent, TransformExecutor, TransformOptions}; + +use crate::display::MAX_PROFILE_BYTES; +use crate::proof::ProofIntent; +use crate::space::{NamedSpace, Trc}; + +/// Where a print's colours go. +#[derive(Clone, Debug, PartialEq)] +pub enum PrintTarget { + /// The printer manages colour: the page is sent in this RGB space, with its profile embedded. + Managed(NamedSpace), + /// A printer / paper ICC profile: the page is converted to the device's values. + Profile(Arc>), +} + +/// How the page is rendered into the target. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct PrintColor { + pub intent: ProofIntent, + /// Map the page's black to the device's darkest (relative colorimetric only). + pub black_point_compensation: bool, + /// −100..100; 0 leaves the page as rendered. + pub brightness: f32, + /// −100..100; 0 leaves the page as rendered. + pub contrast: f32, +} + +impl Default for PrintColor { + fn default() -> Self { + PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, brightness: 0.0, contrast: 0.0 } + } +} + +/// Pixels per conversion batch: bounds the temporary float buffers whatever the page size. +const BATCH: usize = 1 << 16; + +/// The darkest black point compensation will lift the page's black to. A profile whose round trip +/// says more is broken or not a print profile; leave the page alone rather than wash it out. +const MAX_BLACK: f32 = 0.5; + +type Executor = Arc + Send + Sync>; + +/// A page conversion, built once per print job and applied to every page. +pub struct PrintTransform { + forward: Executor, + layout: Layout, + channels: usize, + icc: Arc>, + /// 16-bit sample → linear Rec.2020 after the print adjustment and black point compensation. + pre: Vec, + black: f32, + description: Option, +} + +impl std::fmt::Debug for PrintTransform { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PrintTransform") + .field("channels", &self.channels) + .field("black", &self.black) + .field("description", &self.description) + .finish_non_exhaustive() + } +} + +fn guarded(f: impl FnOnce() -> Option) -> Option { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).ok().flatten() +} + +fn rendering_intent(intent: ProofIntent) -> RenderingIntent { + match intent { + ProofIntent::Perceptual => RenderingIntent::Perceptual, + ProofIntent::Relative => RenderingIntent::RelativeColorimetric, + } +} + +/// A slider value as a finite fraction in −1..1 (NaN and infinities count as 0). +fn amount(v: f32) -> f32 { + if v.is_finite() { (v / 100.0).clamp(-1.0, 1.0) } else { 0.0 } +} + +/// The print adjustment on one sRGB-encoded value (0..1): brightness bends the mid-tones with a +/// power curve, contrast steepens (or flattens) them with `e − k·sin(2πe)/2π`, which keeps black, +/// mid grey and white where they are and stays monotonic for |k| < 1. +pub fn print_adjust(e: f32, brightness: f32, contrast: f32) -> f32 { + let e = if e.is_finite() { e.clamp(0.0, 1.0) } else { 0.0 }; + let (b, c) = (amount(brightness), amount(contrast)); + let e = if b != 0.0 { e.powf(2f32.powf(-0.5 * b)) } else { e }; + let k = 0.6 * c; + let tau = std::f32::consts::TAU; + (e - k * (tau * e).sin() / tau).clamp(0.0, 1.0) +} + +impl PrintTransform { + /// Build the conversion for a target. Errors say what's wrong with the profile; nothing here + /// panics on a hostile one. + pub fn new(target: &PrintTarget, color: &PrintColor) -> Result { + let icc = match target { + PrintTarget::Managed(n) => Arc::new(crate::icc::write_named(*n)), + PrintTarget::Profile(bytes) => Arc::clone(bytes), + }; + if icc.len() > MAX_PROFILE_BYTES { + return Err("the profile is too large".into()); + } + let dev = guarded(|| ColorProfile::new_from_slice(&icc).ok()).ok_or("not a valid ICC profile")?; + let (layout, channels) = match dev.color_space { + DataColorSpace::Rgb => (Layout::Rgb, 3), + DataColorSpace::Cmyk => (Layout::Rgba, 4), + DataColorSpace::Gray => (Layout::Gray, 1), + _ => return Err("only RGB, CMYK and grey printer profiles can be printed to".into()), + }; + let lin = crate::icc::linear_rec2020_profile(); + let opts = |i| TransformOptions { rendering_intent: i, ..TransformOptions::default() }; + let forward: Executor = guarded(|| lin.create_transform_f32(Layout::Rgb, &dev, layout, opts(rendering_intent(color.intent))).ok()) + .ok_or("can't convert to this profile")?; + + let black = if color.black_point_compensation && color.intent == ProofIntent::Relative { + let back: Executor = guarded(|| dev.create_transform_f32(layout, &lin, Layout::Rgb, opts(RenderingIntent::RelativeColorimetric)).ok()) + .ok_or("can't convert from this profile")?; + device_black(&forward, &back, channels) + } else { + 0.0 + }; + + let decode = Trc::Rec709.lut16(); + let (b, c) = (color.brightness, color.contrast); + let adjust = amount(b) != 0.0 || amount(c) != 0.0; + let pre = decode + .iter() + .map(|&lin| { + let lin = if adjust { + let e = lightcraft_color::transfer::linear_to_srgb(lin.clamp(0.0, 1.0)); + lightcraft_color::transfer::srgb_to_linear(print_adjust(e, b, c)) + } else { + lin.clamp(0.0, 1.0) + }; + lin * (1.0 - black) + black + }) + .collect(); + let description = crate::icc::parse(&icc).and_then(|i| i.description); + Ok(PrintTransform { forward, layout, channels, icc, pre, black, description }) + } + + /// Samples per pixel of the converted page: 3 (RGB), 4 (CMYK) or 1 (grey). + pub fn channels(&self) -> usize { + self.channels + } + + /// The profile the converted samples are in: embed it with them. + pub fn icc(&self) -> &Arc> { + &self.icc + } + + /// Whether the samples are CMYK (PDF readers show CMYK images inverted unless told otherwise, + /// so writers need to know). + pub fn is_cmyk(&self) -> bool { + self.layout == Layout::Rgba + } + + /// The linear black black point compensation lifts the page's black to (0 when off). + pub fn black(&self) -> f32 { + self.black + } + + /// The profile's description, for the job's settings and error messages. + pub fn description(&self) -> Option<&str> { + self.description.as_deref() + } + + /// Convert a page: `rgb` is interleaved 16-bit RGB in the BT.2020 / BT.709 encoding; the result + /// is interleaved 16-bit device samples ([`PrintTransform::channels`] per pixel). + pub fn apply(&self, rgb: &[u16]) -> Result, String> { + if !rgb.len().is_multiple_of(3) { + return Err("the page isn't whole RGB pixels".into()); + } + let pixels = rgb.len() / 3; + let ch = self.channels; + let out_len = pixels.checked_mul(ch).ok_or("the page is too large")?; + let mut out = vec![0u16; out_len]; + let run = |src: &[u16], dst: &mut [u16]| -> Option<()> { + let lin: Vec = src.iter().map(|&v| self.pre.get(usize::from(v)).copied().unwrap_or(0.0)).collect(); + let mut dev = vec![0f32; dst.len()]; + self.forward.transform(&lin, &mut dev).ok()?; + for (d, v) in dst.iter_mut().zip(dev) { + let v = if v.is_finite() { v.clamp(0.0, 1.0) } else { 0.0 }; + *d = (v * 65535.0 + 0.5) as u16; + } + Some(()) + }; + let src = rgb.chunks(BATCH * 3); + let dst = out.chunks_mut(BATCH * ch); + #[cfg(feature = "parallel")] + let ok = { + use rayon::prelude::*; + let pairs: Vec<_> = src.zip(dst).collect(); + pairs.into_par_iter().all(|(s, d)| guarded(|| run(s, d)).is_some()) + }; + #[cfg(not(feature = "parallel"))] + let ok = src.zip(dst).all(|(s, d)| guarded(|| run(s, d)).is_some()); + if ok { Ok(out) } else { Err("the profile's tables can't be applied".into()) } + } +} + +/// The darkest the device holds, as linear Rec.2020 luminance: black there and back, relative +/// colorimetric both ways (for a device that reaches true black, 0). +fn device_black(forward: &Executor, back: &Executor, channels: usize) -> f32 { + let mut device = vec![0f32; channels]; + let mut lin = [0f32; 3]; + let ok = guarded(|| { + forward.transform(&[0.0; 3], &mut device).ok()?; + back.transform(&device, &mut lin).ok() + }); + if ok.is_none() { + return 0.0; + } + // BT.2020 luminance weights + let y = 0.2627 * lin[0] + 0.6780 * lin[1] + 0.0593 * lin[2]; + if y.is_finite() { y.clamp(0.0, MAX_BLACK) } else { 0.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use moxcms::ToneReprCurve; + + /// A page of one 16-bit pixel per linear grey level. + fn greys(levels: &[f32]) -> Vec { + levels.iter().flat_map(|&l| [(Trc::Rec709.from_linear(l) * 65535.0).round() as u16; 3]).collect() + } + + fn convert(target: &PrintTarget, color: PrintColor, levels: &[f32]) -> Vec { + PrintTransform::new(target, &color).unwrap().apply(&greys(levels)).unwrap() + } + + /// An RGB print profile whose ink can't get darker than linear 0.03: device 0 is 3 % grey. + fn raised_black_profile() -> Arc> { + let mut p = ColorProfile::new_from_slice(&crate::icc::write_named(NamedSpace::Srgb)).unwrap(); + let (g, e) = (2.2f32, 0.03f32); + let a = (1.0 - e).powf(1.0 / g); + // ICC parametric type 4: Y = (aX + b)^g + e for X >= d, Y = cX + f below + let trc = ToneReprCurve::Parametric(vec![g, a, 0.0, 0.0, 0.0, e, e]); + p.red_trc = Some(trc.clone()); + p.green_trc = Some(trc.clone()); + p.blue_trc = Some(trc); + Arc::new(p.encode().unwrap()) + } + + #[test] + fn managed_srgb_keeps_black_grey_and_white() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor::default()).unwrap(); + assert_eq!(t.channels(), 3); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::Srgb)); + let out = t.apply(&greys(&[0.0, 0.18, 1.0])).unwrap(); + let mid = (lightcraft_color::transfer::linear_to_srgb(0.18) * 65535.0) as i32; + assert!(out[..3].iter().all(|&v| v < 64), "{out:?}"); + assert!(out[3..6].iter().all(|&v| (i32::from(v) - mid).abs() < 300), "{out:?} vs {mid}"); + assert!(out[6..].iter().all(|&v| v > 65400), "{out:?}"); + } + + #[test] + fn adobe_rgb_target_embeds_its_profile() { + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::AdobeRgb), &PrintColor::default()).unwrap(); + assert_eq!(crate::icc::parse(t.icc()).and_then(|i| i.named), Some(NamedSpace::AdobeRgb)); + // a saturated Rec.2020 green is outside Adobe RGB: it comes out at the space's edge, not wrapped + let px = [0u16, 65535, 0]; + let out = t.apply(&px).unwrap(); + assert!(out[1] > 60000 && out[0] < 8000 && out[2] < 8000, "{out:?}"); + } + + #[test] + fn brightness_lifts_the_mid_tones_and_holds_the_ends() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.0, 0.18, 1.0]; + let plain = convert(&target, PrintColor::default(), &levels); + let bright = convert(&target, PrintColor { brightness: 50.0, ..PrintColor::default() }, &levels); + let dark = convert(&target, PrintColor { brightness: -50.0, ..PrintColor::default() }, &levels); + assert!(bright[3] > plain[3] + 2000 && dark[3] + 2000 < plain[3], "{dark:?} {plain:?} {bright:?}"); + for v in [&plain, &bright, &dark] { + assert!(v[0] < 64 && v[6] > 65400, "{v:?}"); + } + } + + #[test] + fn contrast_spreads_shadows_and_highlights_and_stays_monotonic() { + let target = PrintTarget::Managed(NamedSpace::Srgb); + let levels = [0.03, 0.6]; + let plain = convert(&target, PrintColor::default(), &levels); + let more = convert(&target, PrintColor { contrast: 60.0, ..PrintColor::default() }, &levels); + assert!(more[0] < plain[0] && more[3] > plain[3], "{plain:?} {more:?}"); + for c in [-100.0, 100.0] { + for b in [-100.0, 100.0] { + let ramp: Vec = (0..=64).map(|i| i as f32 / 64.0).map(|e| print_adjust(e, b, c)).collect(); + assert!(ramp.windows(2).all(|w| w[1] >= w[0]), "b {b} c {c}: {ramp:?}"); + assert!(ramp[0] == 0.0 && (ramp[64] - 1.0).abs() < 1e-6); + } + } + } + + #[test] + fn black_point_compensation_keeps_shadow_detail_above_the_inks_black() { + let target = PrintTarget::Profile(raised_black_profile()); + let shadows = [0.0, 0.006, 0.012, 0.018, 0.024, 0.5, 1.0]; + let relative = PrintColor { intent: ProofIntent::Relative, black_point_compensation: false, ..PrintColor::default() }; + let clipped = convert(&target, relative, &shadows); + let kept = convert(&target, PrintColor { black_point_compensation: true, ..relative }, &shadows); + let reds = |v: &[u16]| v.chunks(3).map(|p| p[0]).collect::>(); + let (clipped, kept) = (reds(&clipped), reds(&kept)); + // everything below the ink's black prints as (within rounding) the same device value without + // compensation … + assert!(clipped[..5].iter().all(|&v| v <= clipped[0] + 16), "{clipped:?}"); + // … and as distinct, rising values with it; white still prints as white + assert!(kept[..5].windows(2).all(|w| w[1] > w[0]) && kept[4] > kept[0] + 1000, "{kept:?}"); + assert!(kept[6] > 65400 && clipped[6] > 65400, "{kept:?} {clipped:?}"); + let t = PrintTransform::new(&target, &PrintColor { black_point_compensation: true, ..relative }).unwrap(); + assert!((t.black() - 0.03).abs() < 0.01, "{}", t.black()); + } + + #[test] + fn black_point_compensation_is_relative_colorimetric_only() { + let target = PrintTarget::Profile(raised_black_profile()); + let levels = [0.0, 0.01, 0.2]; + let with = convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: true, ..PrintColor::default() }, &levels); + let without = + convert(&target, PrintColor { intent: ProofIntent::Perceptual, black_point_compensation: false, ..PrintColor::default() }, &levels); + assert_eq!(with, without); + let t = PrintTransform::new(&target, &PrintColor { intent: ProofIntent::Perceptual, ..PrintColor::default() }).unwrap(); + assert_eq!(t.black(), 0.0); + } + + #[test] + fn hostile_input_is_an_error_not_a_panic() { + let color = PrintColor::default(); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; 400])), &color).is_err()); + assert!(PrintTransform::new(&PrintTarget::Profile(Arc::new(vec![0u8; MAX_PROFILE_BYTES + 1])), &color).is_err()); + let good = crate::icc::write_named(NamedSpace::DisplayP3); + for i in (0..good.len()).step_by(7) { + let mut b = good.clone(); + b[i] ^= 0x5a; + if let Ok(t) = PrintTransform::new(&PrintTarget::Profile(Arc::new(b)), &PrintColor { intent: ProofIntent::Relative, ..color }) { + let _ = t.apply(&greys(&[0.0, 0.5, 1.0])); + } + } + let t = PrintTransform::new(&PrintTarget::Managed(NamedSpace::Srgb), &PrintColor { brightness: f32::NAN, contrast: f32::INFINITY, ..color }) + .unwrap(); + assert!(t.apply(&[1, 2]).is_err(), "half a pixel"); + assert_eq!(t.apply(&greys(&[0.18])).unwrap(), convert(&PrintTarget::Managed(NamedSpace::Srgb), color, &[0.18])); + assert!(print_adjust(f32::NAN, 50.0, 50.0).is_finite()); + } +} diff --git a/crates/codecs/src/tiff_codec.rs b/crates/codecs/src/tiff_codec.rs index b077c214a..1bbfa8e6c 100644 --- a/crates/codecs/src/tiff_codec.rs +++ b/crates/codecs/src/tiff_codec.rs @@ -95,6 +95,11 @@ pub(crate) fn decode(bytes: &[u8], opts: &DecodeOptions) -> Result { fn limits(opts: &DecodeOptions) -> Limits { let mut limits = Limits::default(); limits.decoding_buffer_size = opts.max_pixels.saturating_mul(4 * 8).min(usize::MAX as u64) as usize; + // A strip or tile whose declared byte count exceeds this is refused: the default 128 MiB refuses a + // single-strip uncompressed 16-bit export of ~22 MP (#1983). Raised to the decoded image's cap + // (32 bytes per allowed pixel), never lowered: a strip or tile can outgrow the pixels it shows + // (JPEG headers, padding to the tile size). + limits.intermediate_buffer_size = limits.intermediate_buffer_size.max(limits.decoding_buffer_size); limits.ifd_value_size = 32 << 20; limits } diff --git a/crates/codecs/src/video.rs b/crates/codecs/src/video.rs new file mode 100644 index 000000000..3991269d8 --- /dev/null +++ b/crates/codecs/src/video.rs @@ -0,0 +1,1158 @@ +//! Video: AV1 encoding of RGBA frames (`rav1e`, native targets with the `avif` feature) and two +//! small muxers of our own, written from the public specifications only: +//! +//! - **MP4** (ISO/IEC 14496-12 ISO base media file format + the AOM "AV1 Codec ISO Media File +//! Format Binding"): `ftyp`, one `mdat` (64-bit size, patched at the end) holding the samples, +//! then `moov` with one video track whose sample entry is `av01` + `av1C` + `colr` (`nclx`). +//! - **WebM** (Matroska / EBML as restricted by WebM, codec mapping `V_AV1`): EBML header, +//! Segment (size patched at the end) with SeekHead, Info, Tracks, Clusters of SimpleBlocks (the +//! last frame a BlockGroup with its duration) and Cues. +//! +//! Frames may last several frame periods: a picture held on screen is one sample with a long +//! duration (variable frame rate), so a still slide costs one encoded frame however long it +//! stays up. Durations are counted in frame periods of the nominal rate. +//! +//! Samples are AV1 temporal units without temporal delimiter OBUs (both bindings ask for that); +//! the configuration record carries the sequence header OBU. No audio track. + +use std::io::{Seek, SeekFrom, Write}; + +use crate::{Error, Result}; + +/// The container a video is written in. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Container { + Mp4, + WebM, +} + +impl Container { + pub fn id(self) -> &'static str { + match self { + Container::Mp4 => "mp4", + Container::WebM => "webm", + } + } + + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "mp4" => Some(Container::Mp4), + "webm" => Some(Container::WebM), + _ => None, + } + } +} + +/// One encoded picture: an AV1 temporal unit (no temporal delimiter), whether it is a key frame +/// (a sync sample), and how many frame periods it stays on screen (≥ 1). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Sample { + pub data: Vec, + pub key: bool, + pub duration: u64, +} + +/// The longest video the muxers write, in frame periods (keeps every timestamp in range). +pub const MAX_PERIODS: u64 = 60 * 60 * 24 * 120; + +fn io(e: std::io::Error) -> Error { + Error::Encode(format!("video: {e}")) +} + +fn bad(msg: impl Into) -> Error { + Error::Encode(format!("video: {}", msg.into())) +} + +// ---------------------------------------------------------------- AV1 OBUs + +/// OBU types (AV1 specification §6.2.2). +pub const OBU_SEQUENCE_HEADER: u8 = 1; +pub const OBU_TEMPORAL_DELIMITER: u8 = 2; +pub const OBU_PADDING: u8 = 15; + +/// Read a LEB128 value at `pos` (at most 8 bytes); returns (value, bytes read). +fn leb128(data: &[u8], pos: usize) -> Result<(u64, usize)> { + let mut v = 0u64; + for i in 0..8 { + let b = *data.get(pos + i).ok_or_else(|| bad("truncated OBU size"))?; + v |= u64::from(b & 0x7f) << (7 * i); + if b & 0x80 == 0 { + return Ok((v, i + 1)); + } + } + Err(bad("OBU size longer than 8 bytes")) +} + +/// The OBUs of a temporal unit: (type, the whole OBU's byte range). Every OBU but the last must +/// carry its size (as the low-overhead bitstream format requires). +pub fn obus(data: &[u8]) -> Result)>> { + let mut out = Vec::new(); + let mut pos = 0usize; + while let Some(&header) = data.get(pos) { + if header & 0x80 != 0 { + return Err(bad("OBU forbidden bit set")); + } + let kind = (header >> 3) & 0x0f; + let ext = header & 0x04 != 0; + let has_size = header & 0x02 != 0; + let mut p = pos + 1 + usize::from(ext); + let end = if has_size { + let (size, n) = leb128(data, p)?; + p += n; + let size = usize::try_from(size).map_err(|_| bad("OBU too large"))?; + p.checked_add(size).filter(|e| *e <= data.len()).ok_or_else(|| bad("OBU runs past the end of the packet"))? + } else { + data.len() + }; + if p > end { + return Err(bad("truncated OBU header")); + } + out.push((kind, pos..end)); + pos = end; + } + Ok(out) +} + +/// A temporal unit as a sample: temporal delimiters and padding dropped. Also returns its +/// sequence header OBU, if it has one. +pub fn to_sample_data(tu: &[u8]) -> Result<(Vec, Option>)> { + let mut data = Vec::with_capacity(tu.len()); + let mut seq = None; + for (kind, r) in obus(tu)? { + let Some(bytes) = tu.get(r) else { continue }; + match kind { + OBU_TEMPORAL_DELIMITER | OBU_PADDING => {} + _ => { + if kind == OBU_SEQUENCE_HEADER && seq.is_none() { + seq = Some(bytes.to_vec()); + } + data.extend_from_slice(bytes); + } + } + } + Ok((data, seq)) +} + +/// The `av1C` configuration record (AV1-ISOBMFF §2.3) for 8-bit 4:2:0 main profile, followed by +/// `seq_header` (the sequence header OBU, may be empty). Matroska's `CodecPrivate` is the same. +pub fn av1_config(seq_header: &[u8]) -> Vec { + // marker 1, version 1 | profile 0, level 31 (unconstrained) | tier 0, 8-bit, not mono, + // subsampling x and y, chroma position 0 | no initial presentation delay + let mut v = vec![0x81, 31, 0b0000_1100, 0]; + v.extend_from_slice(seq_header); + v +} + +// ---------------------------------------------------------------- MP4 + +fn mp4_box(kind: &[u8; 4], body: &[u8]) -> Result> { + let size = u32::try_from(body.len().checked_add(8).ok_or_else(|| bad("box too large"))?).map_err(|_| bad("box too large"))?; + let mut v = Vec::with_capacity(body.len() + 8); + v.extend_from_slice(&size.to_be_bytes()); + v.extend_from_slice(kind); + v.extend_from_slice(body); + Ok(v) +} + +fn full_box(kind: &[u8; 4], version: u8, flags: u32, body: &[u8]) -> Result> { + let mut b = Vec::with_capacity(body.len() + 4); + b.push(version); + b.extend_from_slice(&flags.to_be_bytes()[1..]); + b.extend_from_slice(body); + mp4_box(kind, &b) +} + +/// The unity transformation matrix of `mvhd` / `tkhd`. +fn matrix() -> Vec { + let mut m = Vec::with_capacity(36); + for v in [0x0001_0000u32, 0, 0, 0, 0x0001_0000, 0, 0, 0, 0x4000_0000] { + m.extend_from_slice(&v.to_be_bytes()); + } + m +} + +/// An MP4 writer: [`Mp4Writer::add`] streams each sample into `mdat`; [`Mp4Writer::finish`] +/// patches its size and appends `moov`. +pub struct Mp4Writer { + w: W, + width: u16, + height: u16, + fps: u32, + /// Where `mdat`'s 64-bit size is, and where its data starts. + mdat_size_at: u64, + data_start: u64, + written: u64, + sizes: Vec, + durations: Vec, + keys: Vec, + config: Option>, +} + +/// Track ticks per frame period (ffmpeg's habit: 512 per frame). +const TICKS: u64 = 512; + +impl Mp4Writer { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + let (width, height) = (u16::try_from(width).map_err(|_| bad("too wide"))?, u16::try_from(height).map_err(|_| bad("too tall"))?); + if width == 0 || height == 0 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut ftyp = Vec::new(); + ftyp.extend_from_slice(b"isom"); + ftyp.extend_from_slice(&0x200u32.to_be_bytes()); + for brand in [b"isom", b"iso6", b"av01", b"mp41"] { + ftyp.extend_from_slice(brand); + } + w.write_all(&mp4_box(b"ftyp", &ftyp)?).map_err(io)?; + let start = w.stream_position().map_err(io)?; + // size 1 = a 64-bit size follows (patched in finish) + w.write_all(&1u32.to_be_bytes()).map_err(io)?; + w.write_all(b"mdat").map_err(io)?; + w.write_all(&0u64.to_be_bytes()).map_err(io)?; + Ok(Mp4Writer { + w, + width, + height, + fps, + mdat_size_at: start + 8, + data_start: start + 16, + written: 0, + sizes: Vec::new(), + durations: Vec::new(), + keys: Vec::new(), + config: None, + }) + } + + /// The `av1C` record ([`av1_config`]); needed before [`Self::finish`]. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + let size = u32::try_from(s.data.len()).map_err(|_| bad("sample too large"))?; + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.sizes.is_empty() && !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.w.write_all(&s.data).map_err(io)?; + self.written = self.written.saturating_add(u64::from(size)); + self.sizes.push(size); + self.durations.push(s.duration); + if s.key { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + self.keys.push(n); + } + Ok(()) + } + + /// Frame periods written so far. + pub fn periods(&self) -> u64 { + self.durations.iter().sum() + } + + pub fn finish(mut self) -> Result { + if self.sizes.is_empty() { + return Err(bad("no frames")); + } + let config = self.config.take().ok_or_else(|| bad("no AV1 configuration"))?; + let periods = self.periods(); + if periods > MAX_PERIODS { + return Err(bad("video too long")); + } + let end = self.w.stream_position().map_err(io)?; + self.w.seek(SeekFrom::Start(self.mdat_size_at)).map_err(io)?; + self.w.write_all(&(end - (self.mdat_size_at - 8)).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + let moov = self.moov(&config, periods)?; + self.w.write_all(&moov).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } + + fn moov(&self, config: &[u8], periods: u64) -> Result> { + let n = u32::try_from(self.sizes.len()).map_err(|_| bad("too many samples"))?; + let timescale = u32::try_from(u64::from(self.fps) * TICKS).map_err(|_| bad("bad frame rate"))?; + let ticks = periods.checked_mul(TICKS).ok_or_else(|| bad("video too long"))?; + // the movie's own timescale: milliseconds + let movie_ms = periods.saturating_mul(1000) / u64::from(self.fps); + let u32_of = |v: u64| u32::try_from(v).map_err(|_| bad("video too long")); + let (track_ticks, movie_ms) = (u32_of(ticks)?, u32_of(movie_ms)?); + + let mut mvhd = Vec::new(); + mvhd.extend_from_slice(&[0; 8]); // creation, modification + mvhd.extend_from_slice(&1000u32.to_be_bytes()); + mvhd.extend_from_slice(&movie_ms.to_be_bytes()); + mvhd.extend_from_slice(&0x0001_0000u32.to_be_bytes()); // rate 1.0 + mvhd.extend_from_slice(&0x0100u16.to_be_bytes()); // volume 1.0 + mvhd.extend_from_slice(&[0; 10]); + mvhd.extend_from_slice(&matrix()); + mvhd.extend_from_slice(&[0; 24]); // pre_defined + mvhd.extend_from_slice(&2u32.to_be_bytes()); // next track id + + let mut tkhd = Vec::new(); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&1u32.to_be_bytes()); // track id + tkhd.extend_from_slice(&[0; 4]); + tkhd.extend_from_slice(&movie_ms.to_be_bytes()); + tkhd.extend_from_slice(&[0; 8]); + tkhd.extend_from_slice(&[0; 2]); // layer + tkhd.extend_from_slice(&[0; 2]); // alternate group + tkhd.extend_from_slice(&[0; 2]); // volume (video: 0) + tkhd.extend_from_slice(&[0; 2]); + tkhd.extend_from_slice(&matrix()); + tkhd.extend_from_slice(&(u32::from(self.width) << 16).to_be_bytes()); + tkhd.extend_from_slice(&(u32::from(self.height) << 16).to_be_bytes()); + + let mut mdhd = Vec::new(); + mdhd.extend_from_slice(&[0; 8]); + mdhd.extend_from_slice(×cale.to_be_bytes()); + mdhd.extend_from_slice(&track_ticks.to_be_bytes()); + mdhd.extend_from_slice(&0x55c4u16.to_be_bytes()); // language "und" + mdhd.extend_from_slice(&[0; 2]); + + let mut hdlr = Vec::new(); + hdlr.extend_from_slice(&[0; 4]); + hdlr.extend_from_slice(b"vide"); + hdlr.extend_from_slice(&[0; 12]); + hdlr.extend_from_slice(b"LightCraft video\0"); + + let mut vmhd = Vec::new(); + vmhd.extend_from_slice(&[0; 8]); // graphics mode, opcolor + let url = full_box(b"url ", 0, 1, &[])?; + let mut dref = 1u32.to_be_bytes().to_vec(); + dref.extend_from_slice(&url); + let dinf = mp4_box(b"dinf", &full_box(b"dref", 0, 0, &dref)?)?; + + // av01 visual sample entry + let mut av01 = Vec::new(); + av01.extend_from_slice(&[0; 6]); + av01.extend_from_slice(&1u16.to_be_bytes()); // data reference index + av01.extend_from_slice(&[0; 16]); // pre_defined, reserved + av01.extend_from_slice(&self.width.to_be_bytes()); + av01.extend_from_slice(&self.height.to_be_bytes()); + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi + av01.extend_from_slice(&0x0048_0000u32.to_be_bytes()); + av01.extend_from_slice(&[0; 4]); + av01.extend_from_slice(&1u16.to_be_bytes()); // frame count + let mut name = [0u8; 32]; + let label = b"AOM Coding"; + name[0] = label.len() as u8; + name[1..=label.len()].copy_from_slice(label); + av01.extend_from_slice(&name); + av01.extend_from_slice(&0x0018u16.to_be_bytes()); // depth + av01.extend_from_slice(&(-1i16).to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"av1C", config)?); + // BT.709 primaries, transfer and matrix, limited range + let mut colr = b"nclx".to_vec(); + colr.extend_from_slice(&[0, 1, 0, 1, 0, 1, 0]); + av01.extend_from_slice(&mp4_box(b"colr", &colr)?); + let mut pasp = 1u32.to_be_bytes().to_vec(); + pasp.extend_from_slice(&1u32.to_be_bytes()); + av01.extend_from_slice(&mp4_box(b"pasp", &pasp)?); + let mut stsd = 1u32.to_be_bytes().to_vec(); + stsd.extend_from_slice(&mp4_box(b"av01", &av01)?); + + // decoding times: runs of equal durations + let mut runs: Vec<(u32, u64)> = Vec::new(); + for d in &self.durations { + match runs.last_mut() { + Some((count, delta)) if *delta == *d => *count += 1, + _ => runs.push((1, *d)), + } + } + let mut stts = u32::try_from(runs.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for (count, delta) in runs { + stts.extend_from_slice(&count.to_be_bytes()); + stts.extend_from_slice(&u32_of(delta.saturating_mul(TICKS))?.to_be_bytes()); + } + let mut stsc = 1u32.to_be_bytes().to_vec(); + for v in [1u32, n, 1] { + stsc.extend_from_slice(&v.to_be_bytes()); + } + let mut stsz = 0u32.to_be_bytes().to_vec(); + stsz.extend_from_slice(&n.to_be_bytes()); + for s in &self.sizes { + stsz.extend_from_slice(&s.to_be_bytes()); + } + // one chunk holding every sample, at the start of mdat's data + let chunk_offset = match u32::try_from(self.data_start) { + Ok(o) => full_box(b"stco", 0, 0, &[1u32.to_be_bytes(), o.to_be_bytes()].concat())?, + Err(_) => { + let mut b = 1u32.to_be_bytes().to_vec(); + b.extend_from_slice(&self.data_start.to_be_bytes()); + full_box(b"co64", 0, 0, &b)? + } + }; + let mut stbl = Vec::new(); + stbl.extend_from_slice(&full_box(b"stsd", 0, 0, &stsd)?); + stbl.extend_from_slice(&full_box(b"stts", 0, 0, &stts)?); + if self.keys.len() < self.sizes.len() { + let mut stss = u32::try_from(self.keys.len()).map_err(|_| bad("too many samples"))?.to_be_bytes().to_vec(); + for k in &self.keys { + stss.extend_from_slice(&k.to_be_bytes()); + } + stbl.extend_from_slice(&full_box(b"stss", 0, 0, &stss)?); + } + stbl.extend_from_slice(&full_box(b"stsc", 0, 0, &stsc)?); + stbl.extend_from_slice(&full_box(b"stsz", 0, 0, &stsz)?); + stbl.extend_from_slice(&chunk_offset); + + let mut minf = full_box(b"vmhd", 0, 1, &vmhd)?; + minf.extend_from_slice(&dinf); + minf.extend_from_slice(&mp4_box(b"stbl", &stbl)?); + let mut mdia = full_box(b"mdhd", 0, 0, &mdhd)?; + mdia.extend_from_slice(&full_box(b"hdlr", 0, 0, &hdlr)?); + mdia.extend_from_slice(&mp4_box(b"minf", &minf)?); + let mut trak = full_box(b"tkhd", 0, 3, &tkhd)?; + trak.extend_from_slice(&mp4_box(b"mdia", &mdia)?); + let mut moov = full_box(b"mvhd", 0, 0, &mvhd)?; + moov.extend_from_slice(&mp4_box(b"trak", &trak)?); + mp4_box(b"moov", &moov) + } +} + +// ---------------------------------------------------------------- WebM + +/// An EBML element id with its length marker, as written (e.g. `0x1A45DFA3`). +fn ebml_id(id: u32) -> Vec { + let b = id.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(3); + b[skip..].to_vec() +} + +/// An EBML size, in the fewest bytes (all-ones values are reserved for "unknown"). +fn ebml_size(n: u64) -> Vec { + for len in 1..=8u32 { + if n < (1u64 << (7 * len)) - 1 { + let mut v = Vec::with_capacity(len as usize); + let marked = n | (1u64 << (7 * len)); + let bytes = marked.to_be_bytes(); + v.extend_from_slice(&bytes[(8 - len as usize)..]); + return v; + } + } + vec![0x01, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xfe] +} + +/// An 8-byte EBML size (patchable in place). +fn ebml_size8(n: u64) -> [u8; 8] { + let mut b = (n & 0x00ff_ffff_ffff_ffff).to_be_bytes(); + b[0] = 0x01; + b +} + +fn el(id: u32, body: &[u8]) -> Vec { + let mut v = ebml_id(id); + v.extend_from_slice(&ebml_size(body.len() as u64)); + v.extend_from_slice(body); + v +} + +fn el_uint(id: u32, n: u64) -> Vec { + let b = n.to_be_bytes(); + let skip = b.iter().take_while(|x| **x == 0).count().min(7); + el(id, &b[skip..]) +} + +fn el_float(id: u32, f: f64) -> Vec { + el(id, &f.to_be_bytes()) +} + +const EBML: u32 = 0x1A45_DFA3; +const SEGMENT: u32 = 0x1853_8067; +const SEEK_HEAD: u32 = 0x114D_9B74; +const SEEK: u32 = 0x4DBB; +const SEEK_ID: u32 = 0x53AB; +const SEEK_POSITION: u32 = 0x53AC; +const VOID: u32 = 0xEC; +const INFO: u32 = 0x1549_A966; +const TIMESTAMP_SCALE: u32 = 0x2A_D7B1; +const DURATION: u32 = 0x4489; +const TRACKS: u32 = 0x1654_AE6B; +const TRACK_ENTRY: u32 = 0xAE; +const CLUSTER: u32 = 0x1F43_B675; +const TIMESTAMP: u32 = 0xE7; +const SIMPLE_BLOCK: u32 = 0xA3; +const BLOCK_GROUP: u32 = 0xA0; +const BLOCK: u32 = 0xA1; +const BLOCK_DURATION: u32 = 0x9B; +const REFERENCE_BLOCK: u32 = 0xFB; +const CUES: u32 = 0x1C53_BB6B; + +/// Room kept after the Segment header for the SeekHead written at the end. +const SEEK_ROOM: usize = 96; + +/// A WebM writer (see the module docs). Timestamps are in milliseconds. +pub struct WebmWriter { + w: W, + width: u32, + height: u32, + fps: u32, + segment_size_at: u64, + segment_start: u64, + duration_at: u64, + info_at: u64, + tracks_at: Option, + config: Option>, + /// Frame periods before the pending sample. + periods: u64, + /// The sample not written yet (the last one becomes a BlockGroup with its duration). + pending: Option<(Sample, u64)>, + cluster: Vec, + cluster_ts: u64, + last_block_ts: u64, + cues: Vec<(u64, u64)>, +} + +impl WebmWriter { + pub fn new(mut w: W, width: u32, height: u32, fps: u32) -> Result { + if width == 0 || height == 0 || width > 65_535 || height > 65_535 || !(1..=240).contains(&fps) { + return Err(bad("bad size or frame rate")); + } + let mut header = Vec::new(); + header.extend_from_slice(&el_uint(0x4286, 1)); // EBMLVersion + header.extend_from_slice(&el_uint(0x42F7, 1)); // EBMLReadVersion + header.extend_from_slice(&el_uint(0x42F2, 4)); // EBMLMaxIDLength + header.extend_from_slice(&el_uint(0x42F3, 8)); // EBMLMaxSizeLength + header.extend_from_slice(&el(0x4282, b"webm")); // DocType + header.extend_from_slice(&el_uint(0x4287, 4)); // DocTypeVersion + header.extend_from_slice(&el_uint(0x4285, 2)); // DocTypeReadVersion + w.write_all(&el(EBML, &header)).map_err(io)?; + w.write_all(&ebml_id(SEGMENT)).map_err(io)?; + let segment_size_at = w.stream_position().map_err(io)?; + w.write_all(&ebml_size8(0)).map_err(io)?; + let segment_start = w.stream_position().map_err(io)?; + // a Void the SeekHead replaces at the end + w.write_all(&el(VOID, &[0; SEEK_ROOM - 2])).map_err(io)?; + let info_at = w.stream_position().map_err(io)? - segment_start; + let mut info = el_uint(TIMESTAMP_SCALE, 1_000_000); + let duration_offset = info.len() + 3; // after Duration's id (2 bytes) and size (1 byte) + info.extend_from_slice(&el_float(DURATION, 0.0)); + info.extend_from_slice(&el(0x4D80, b"LightCraft")); // MuxingApp + info.extend_from_slice(&el(0x5741, b"LightCraft")); // WritingApp + let info_el = el(INFO, &info); + let duration_at = segment_start + info_at + (info_el.len() - info.len()) as u64 + duration_offset as u64; + w.write_all(&info_el).map_err(io)?; + Ok(WebmWriter { + w, + width, + height, + fps, + segment_size_at, + segment_start, + duration_at, + info_at, + tracks_at: None, + config: None, + periods: 0, + pending: None, + cluster: Vec::new(), + cluster_ts: 0, + last_block_ts: 0, + cues: Vec::new(), + }) + } + + /// The `CodecPrivate` (the `av1C` record, [`av1_config`]); needed before the first sample. + pub fn set_config(&mut self, config: Vec) { + self.config = Some(config); + } + + fn ms(&self, periods: u64) -> u64 { + periods.saturating_mul(1000) / u64::from(self.fps) + } + + fn write_tracks(&mut self) -> Result<()> { + let config = self.config.clone().ok_or_else(|| bad("no AV1 configuration"))?; + let mut video = el_uint(0xB0, u64::from(self.width)); // PixelWidth + video.extend_from_slice(&el_uint(0xBA, u64::from(self.height))); // PixelHeight + let mut entry = el_uint(0xD7, 1); // TrackNumber + entry.extend_from_slice(&el_uint(0x73C5, 1)); // TrackUID + entry.extend_from_slice(&el_uint(0x83, 1)); // TrackType video + entry.extend_from_slice(&el_uint(0x9C, 0)); // FlagLacing + entry.extend_from_slice(&el(0x86, b"V_AV1")); // CodecID + entry.extend_from_slice(&el(0x63A2, &config)); // CodecPrivate + entry.extend_from_slice(&el_uint(0x23E383, 1_000_000_000 / u64::from(self.fps))); // DefaultDuration (ns) + entry.extend_from_slice(&el(0xE0, &video)); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(TRACKS, &el(TRACK_ENTRY, &entry))).map_err(io)?; + self.tracks_at = Some(at); + Ok(()) + } + + fn flush_cluster(&mut self) -> Result<()> { + if self.cluster.is_empty() { + return Ok(()); + } + let mut body = el_uint(TIMESTAMP, self.cluster_ts); + body.extend_from_slice(&self.cluster); + let at = self.w.stream_position().map_err(io)? - self.segment_start; + self.w.write_all(&el(CLUSTER, &body)).map_err(io)?; + self.cues.push((self.cluster_ts, at)); + self.cluster.clear(); + Ok(()) + } + + /// The block header: track 1, timestamp relative to the cluster, flags. + fn block(&self, data: &[u8], ts: u64, flags: u8) -> Result> { + let rel = i16::try_from(ts.saturating_sub(self.cluster_ts)).map_err(|_| bad("block timestamp out of range"))?; + let mut b = Vec::with_capacity(data.len() + 4); + b.push(0x81); + b.extend_from_slice(&rel.to_be_bytes()); + b.push(flags); + b.extend_from_slice(data); + Ok(b) + } + + /// Start a new cluster at a key frame or before the relative timestamp could overflow. + fn place(&mut self, key: bool, ts: u64) -> Result<()> { + if !self.cluster.is_empty() && (key || ts.saturating_sub(self.cluster_ts) > 30_000) { + self.flush_cluster()?; + } + if self.cluster.is_empty() { + self.cluster_ts = ts; + } + Ok(()) + } + + fn write_simple(&mut self, s: &Sample, ts: u64) -> Result<()> { + self.place(s.key, ts)?; + let b = self.block(&s.data, ts, if s.key { 0x80 } else { 0 })?; + self.cluster.extend_from_slice(&el(SIMPLE_BLOCK, &b)); + self.last_block_ts = ts; + Ok(()) + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + if s.duration == 0 { + return Err(bad("a sample lasts at least one frame")); + } + if self.tracks_at.is_none() { + if !s.key { + return Err(bad("the first sample must be a key frame")); + } + self.write_tracks()?; + } + if let Some((p, start)) = self.pending.take() { + let ts = self.ms(start); + self.write_simple(&p, ts)?; + } + let start = self.periods; + self.periods = self.periods.saturating_add(s.duration); + if self.periods > MAX_PERIODS { + return Err(bad("video too long")); + } + self.pending = Some((s.clone(), start)); + Ok(()) + } + + pub fn periods(&self) -> u64 { + self.periods + } + + pub fn finish(mut self) -> Result { + let (last, start) = self.pending.take().ok_or_else(|| bad("no frames"))?; + let ts = self.ms(start); + self.place(last.key, ts)?; + let mut group = el(BLOCK, &self.block(&last.data, ts, 0)?); + group.extend_from_slice(&el_uint(BLOCK_DURATION, self.ms(self.periods).saturating_sub(ts).max(1))); + if !last.key { + // the previous block, as a (negative) offset from this one + let back = i64::try_from(ts.saturating_sub(self.last_block_ts)).unwrap_or(i64::MAX).max(1); + el_int_into(&mut group, REFERENCE_BLOCK, -back); + } + self.cluster.extend_from_slice(&el(BLOCK_GROUP, &group)); + self.flush_cluster()?; + // cues: one per cluster (each starts with a key frame or carries on from one) + let cues_at = self.w.stream_position().map_err(io)? - self.segment_start; + let mut cues = Vec::new(); + for (t, pos) in &self.cues { + let mut positions = el_uint(0xF7, 1); // CueTrack + positions.extend_from_slice(&el_uint(0xF1, *pos)); // CueClusterPosition + let mut point = el_uint(0xB3, *t); // CueTime + point.extend_from_slice(&el(0xB7, &positions)); // CueTrackPositions + cues.extend_from_slice(&el(0xBB, &point)); // CuePoint + } + self.w.write_all(&el(CUES, &cues)).map_err(io)?; + let end = self.w.stream_position().map_err(io)?; + // SeekHead in the room kept for it, the rest stays a Void + let tracks_at = self.tracks_at.ok_or_else(|| bad("no tracks"))?; + let mut seeks = Vec::new(); + for (id, at) in [(INFO, self.info_at), (TRACKS, tracks_at), (CUES, cues_at)] { + let mut seek = el(SEEK_ID, &ebml_id(id)); + seek.extend_from_slice(&el(SEEK_POSITION, &at.to_be_bytes())); + seeks.extend_from_slice(&el(SEEK, &seek)); + } + let head = el(SEEK_HEAD, &seeks); + let rest = SEEK_ROOM.checked_sub(head.len()).filter(|r| *r >= 2).ok_or_else(|| bad("seek head too large"))?; + let mut room = head; + room.extend_from_slice(&el(VOID, &vec![0; rest - 2])); + self.w.seek(SeekFrom::Start(self.segment_start)).map_err(io)?; + self.w.write_all(&room).map_err(io)?; + self.w.seek(SeekFrom::Start(self.duration_at)).map_err(io)?; + self.w.write_all(&(self.ms(self.periods) as f64).to_be_bytes()).map_err(io)?; + self.w.seek(SeekFrom::Start(self.segment_size_at)).map_err(io)?; + self.w.write_all(&ebml_size8(end - self.segment_start)).map_err(io)?; + self.w.seek(SeekFrom::Start(end)).map_err(io)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} + +fn el_int_into(out: &mut Vec, id: u32, v: i64) { + let b = v.to_be_bytes(); + // the fewest bytes that keep the sign + let mut skip = 0; + while skip < 7 && ((b[skip] == 0 && b[skip + 1] & 0x80 == 0) || (b[skip] == 0xff && b[skip + 1] & 0x80 != 0)) { + skip += 1; + } + out.extend_from_slice(&el(id, &b[skip..])); +} + +// ---------------------------------------------------------------- either container + +/// An MP4 or WebM writer. +pub enum VideoWriter { + Mp4(Mp4Writer), + WebM(WebmWriter), +} + +impl VideoWriter { + pub fn new(w: W, container: Container, width: u32, height: u32, fps: u32) -> Result { + Ok(match container { + Container::Mp4 => VideoWriter::Mp4(Mp4Writer::new(w, width, height, fps)?), + Container::WebM => VideoWriter::WebM(WebmWriter::new(w, width, height, fps)?), + }) + } + + pub fn set_config(&mut self, config: Vec) { + match self { + VideoWriter::Mp4(m) => m.set_config(config), + VideoWriter::WebM(m) => m.set_config(config), + } + } + + pub fn add(&mut self, s: &Sample) -> Result<()> { + match self { + VideoWriter::Mp4(m) => m.add(s), + VideoWriter::WebM(m) => m.add(s), + } + } + + pub fn periods(&self) -> u64 { + match self { + VideoWriter::Mp4(m) => m.periods(), + VideoWriter::WebM(m) => m.periods(), + } + } + + pub fn finish(self) -> Result { + match self { + VideoWriter::Mp4(m) => m.finish(), + VideoWriter::WebM(m) => m.finish(), + } + } +} + +// ---------------------------------------------------------------- AV1 encoder + +/// How to encode: frame size (even numbers; odd ones are rounded down), nominal frame rate, +/// quality 1–100 and rav1e speed 1 (slow) – 10 (fast). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct VideoConfig { + pub width: u32, + pub height: u32, + pub fps: u32, + pub quality: u8, + pub speed: u8, +} + +/// Is AV1 video encoding in this build (native, `avif` feature)? +pub const AV1_AVAILABLE: bool = cfg!(all(feature = "avif", not(target_arch = "wasm32"))); + +/// BT.709 limited-range 4:2:0 planes of an sRGB RGBA image (alpha ignored), `w × h` (even). +pub fn yuv420(rgba: &[[u8; 4]], w: usize, h: usize) -> Result<(Vec, Vec, Vec)> { + if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) || w.checked_mul(h).is_none_or(|n| rgba.len() < n) { + return Err(bad("frame size")); + } + const KR: f32 = 0.2126; + const KB: f32 = 0.0722; + const KG: f32 = 1.0 - KR - KB; + let mut y = vec![0u8; w * h]; + let (cw, ch) = (w / 2, h / 2); + let mut u = vec![0u8; cw * ch]; + let mut v = vec![0u8; cw * ch]; + let luma = |p: [u8; 4]| KR * f32::from(p[0]) + KG * f32::from(p[1]) + KB * f32::from(p[2]); + for (i, out) in y.iter_mut().enumerate() { + let p = rgba.get(i).copied().unwrap_or([0; 4]); + *out = (16.0 + luma(p) * (219.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + for cy in 0..ch { + for cx in 0..cw { + let mut acc = [0f32; 3]; + for (dx, dy) in [(0, 0), (1, 0), (0, 1), (1, 1)] { + let p = rgba.get((cy * 2 + dy) * w + cx * 2 + dx).copied().unwrap_or([0; 4]); + for (a, c) in acc.iter_mut().zip(p) { + *a += f32::from(c) / 4.0; + } + } + let l = KR * acc[0] + KG * acc[1] + KB * acc[2]; + let cb = (acc[2] - l) / (2.0 * (1.0 - KB)); + let cr = (acc[0] - l) / (2.0 * (1.0 - KR)); + let i = cy * cw + cx; + if let Some(o) = u.get_mut(i) { + *o = (128.0 + cb * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + if let Some(o) = v.get_mut(i) { + *o = (128.0 + cr * (224.0 / 255.0)).round().clamp(0.0, 255.0) as u8; + } + } + } + Ok((y, u, v)) +} + +/// The quantizer for `quality` (ravif's curve, so qualities match the AVIF export). +pub fn quantizer(quality: u8) -> usize { + let q = f32::from(quality.clamp(1, 100)) / 100.0; + let x = if q >= 0.82 { + (1.0 - q) * 2.6 + } else if q > 0.25 { + 1.0 - 0.125 - q * 0.5 + } else { + 1.0 - q + }; + (x * 255.0).round().clamp(0.0, 255.0) as usize +} + +/// An AV1 encoder fed RGBA frames, each with how many frame periods it stays up; it hands back +/// the samples as they come out (in order: no frame reordering). +pub struct Av1Encoder { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + ctx: rav1e::Context, + /// Each frame sent so far: its duration (by input frame number). + #[cfg_attr(not(all(feature = "avif", not(target_arch = "wasm32"))), allow(dead_code))] + durations: Vec, + width: usize, + height: usize, + seq_header: Option>, +} + +impl Av1Encoder { + pub fn new(c: &VideoConfig) -> Result { + let (w, h) = ((c.width & !1) as usize, (c.height & !1) as usize); + if w < 16 || h < 16 || w > 8192 || h > 8192 || !(1..=240).contains(&c.fps) { + return Err(bad("unsupported video size or frame rate")); + } + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + use rav1e::prelude::*; + let mut ec = EncoderConfig::with_speed_preset(c.speed.clamp(1, 10)); + ec.width = w; + ec.height = h; + ec.time_base = Rational::new(1, u64::from(c.fps)); + ec.bit_depth = 8; + ec.chroma_sampling = ChromaSampling::Cs420; + ec.pixel_range = PixelRange::Limited; + ec.color_description = Some(ColorDescription { + color_primaries: ColorPrimaries::BT709, + transfer_characteristics: TransferCharacteristics::BT709, + matrix_coefficients: MatrixCoefficients::BT709, + }); + // in order, one shown frame per packet: samples map 1:1 to the frames sent + ec.low_latency = true; + let q = quantizer(c.quality); + ec.quantizer = q; + ec.min_quantizer = q.min(255) as u8; + ec.min_key_frame_interval = 1; + ec.max_key_frame_interval = 120; + // tiles are what rav1e encodes in parallel (16 measured best for 1080p on 32 threads) + ec.tiles = std::thread::available_parallelism().map_or(4, |n| n.get()).clamp(2, 16); + let ctx: Context = Config::new().with_encoder_config(ec).new_context().map_err(|e| bad(format!("AV1: {e}")))?; + Ok(Av1Encoder { ctx, durations: Vec::new(), width: w, height: h, seq_header: None }) + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (w, h); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// The frame size actually encoded (even numbers). + pub fn size(&self) -> (usize, usize) { + (self.width, self.height) + } + + /// The configuration record for the container ([`av1_config`]), once the first sample (with + /// the sequence header) has come out. + pub fn config_record(&self) -> Option> { + self.seq_header.as_ref().map(|s| av1_config(s)) + } + + /// Encode `rgba` (`width × height` of [`Self::size`]) shown for `periods` frame periods; + /// `key` forces a key frame (a picture that doesn't lean on earlier ones: a held slide is + /// then clean of the slide before it, and seeking lands on it). + pub fn push(&mut self, rgba: &[[u8; 4]], periods: u64, key: bool) -> Result> { + let (y, u, v) = yuv420(rgba, self.width, self.height)?; + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + let mut frame = self.ctx.new_frame(); + for (plane, (data, w)) in frame.planes.iter_mut().zip([(&y, self.width), (&u, self.width / 2), (&v, self.width / 2)]) { + plane.copy_from_raw_u8(data, w, 1); + } + let params = rav1e::prelude::FrameParameters { + frame_type_override: if key { rav1e::prelude::FrameTypeOverride::Key } else { rav1e::prelude::FrameTypeOverride::No }, + ..Default::default() + }; + self.ctx.send_frame((frame, params)).map_err(|e| bad(format!("AV1: {e}")))?; + self.durations.push(periods.max(1)); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + { + let _ = (y, u, v, periods, key); + Err(bad("AV1 video encoding is not available in this build")) + } + } + + /// Flush the encoder: the samples still inside. + pub fn finish(&mut self) -> Result> { + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + { + self.ctx.flush(); + self.drain() + } + #[cfg(not(all(feature = "avif", not(target_arch = "wasm32"))))] + Err(bad("AV1 video encoding is not available in this build")) + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + fn drain(&mut self) -> Result> { + use rav1e::prelude::*; + let mut out = Vec::new(); + loop { + match self.ctx.receive_packet() { + Ok(p) => { + let (data, seq) = to_sample_data(&p.data)?; + if self.seq_header.is_none() { + self.seq_header = seq; + } + let duration = usize::try_from(p.input_frameno).ok().and_then(|i| self.durations.get(i)).copied().unwrap_or(1); + out.push(Sample { data, key: p.frame_type == FrameType::KEY, duration }); + } + Err(EncoderStatus::Encoded) => {} + Err(EncoderStatus::NeedMoreData | EncoderStatus::LimitReached) => break, + Err(e) => return Err(bad(format!("AV1: {e}"))), + } + } + Ok(out) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Cursor; + + /// The boxes of an MP4 byte range: (type, body range). + pub fn boxes(d: &[u8], range: std::ops::Range) -> Vec<([u8; 4], std::ops::Range)> { + let mut out = Vec::new(); + let mut p = range.start; + while p + 8 <= range.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= range.end, "box {} overruns", String::from_utf8_lossy(&kind)); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, range.end, "trailing bytes"); + out + } + + fn find(d: &[u8], r: std::ops::Range, path: &[&[u8; 4]]) -> std::ops::Range { + let mut r = r; + for k in path { + r = boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap_or_else(|| panic!("no {}", String::from_utf8_lossy(*k))).1; + } + r + } + + fn samples() -> Vec { + // fake AV1 data: an OBU_FRAME (type 6) with a size field + let frame = |b: u8| vec![0x32, 2, b, b]; + vec![ + Sample { data: frame(1), key: true, duration: 30 }, + Sample { data: frame(2), key: false, duration: 1 }, + Sample { data: frame(3), key: false, duration: 1 }, + Sample { data: frame(4), key: true, duration: 58 }, + ] + } + + #[test] + fn obus_split_and_temporal_delimiters_go() { + // TD, sequence header (3 bytes), frame (2 bytes) + let tu = [0x12, 0, 0x0a, 3, 1, 2, 3, 0x32, 2, 9, 9]; + let o = obus(&tu).unwrap(); + assert_eq!(o.iter().map(|(k, _)| *k).collect::>(), vec![2, 1, 6]); + let (data, seq) = to_sample_data(&tu).unwrap(); + assert_eq!(data, &tu[2..]); + assert_eq!(seq.unwrap(), &tu[2..7]); + // hostile: sizes past the end, forbidden bit, endless LEB128 + assert!(obus(&[0x32, 9, 1]).is_err()); + assert!(obus(&[0x80]).is_err()); + assert!(obus(&[0x32, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff]).is_err()); + assert!(obus(&[0x36]).is_err(), "extension byte missing"); + assert_eq!(av1_config(&[1, 2])[..], [0x81, 31, 0x0c, 0, 1, 2]); + } + + #[test] + fn mp4_box_tree_samples_and_durations() { + let mut m = Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![1], key: false, duration: 1 }).is_err(), "must start on a key frame"); + for s in samples() { + m.add(&s).unwrap(); + } + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap().finish().is_err(), "no frames"); + m.set_config(av1_config(&[0x0a, 1, 0])); + let d = m.finish().unwrap().into_inner(); + let top = boxes(&d, 0..d.len()); + assert_eq!(top.iter().map(|(k, _)| *k).collect::>(), vec![*b"ftyp", *b"mdat", *b"moov"]); + assert_eq!(&d[top[1].1.clone()], &[0x32, 2, 1, 1, 0x32, 2, 2, 2, 0x32, 2, 3, 3, 0x32, 2, 4, 4]); + let stbl = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let kinds: Vec<_> = boxes(&d, stbl.clone()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(kinds, vec![*b"stsd", *b"stts", *b"stss", *b"stsc", *b"stsz", *b"stco"]); + let be = |r: usize| u32::from_be_bytes(d[r..r + 4].try_into().unwrap()); + let stsz = find(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 4, "sample count"); + let stts = find(&d, stbl.clone(), &[b"stts"]); + assert_eq!(be(stts.start + 4), 3, "runs: 30, 1×2, 58"); + let total: u32 = (0..3).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + assert_eq!(total, 90 * 512); + let stss = find(&d, stbl.clone(), &[b"stss"]); + assert_eq!((be(stss.start + 4), be(stss.start + 8), be(stss.start + 12)), (2, 1, 4)); + let stco = find(&d, stbl.clone(), &[b"stco"]); + assert_eq!(be(stco.start + 8) as usize, top[1].1.start, "chunk offset = mdat data"); + let stsd = find(&d, stbl, &[b"stsd"]); + let av01 = boxes(&d, stsd.start + 8..stsd.end); + assert_eq!(av01[0].0, *b"av01"); + let entry = av01[0].1.clone(); + assert_eq!(u16::from_be_bytes([d[entry.start + 24], d[entry.start + 25]]), 64); + let inner: Vec<_> = boxes(&d, entry.start + 78..entry.end).into_iter().map(|(k, _)| k).collect(); + assert_eq!(inner, vec![*b"av1C", *b"colr", *b"pasp"]); + let mdhd = find(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!((be(mdhd.start + 12), be(mdhd.start + 16)), (30 * 512, 90 * 512)); + let mvhd = find(&d, 0..d.len(), &[b"moov", b"mvhd"]); + assert_eq!(be(mvhd.start + 16), 3000, "3 s in ms"); + } + + /// The EBML elements of a byte range: (id, body range). + fn elements(d: &[u8], range: std::ops::Range) -> Vec<(u32, std::ops::Range)> { + let vint = |p: usize| -> (u64, usize) { + let len = d[p].leading_zeros() as usize + 1; + let mut v = u64::from(d[p]) & (0xff >> len); + for i in 1..len { + v = (v << 8) | u64::from(d[p + i]); + } + (v, len) + }; + let mut out = Vec::new(); + let mut p = range.start; + while p < range.end { + let idlen = d[p].leading_zeros() as usize + 1; + let id = d[p..p + idlen].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + let (size, n) = vint(p + idlen); + let start = p + idlen + n; + let end = start + size as usize; + assert!(end <= range.end, "element {id:x} overruns"); + out.push((id, start..end)); + p = end; + } + out + } + + #[test] + fn webm_element_tree_blocks_and_duration() { + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + assert!(m.add(&Sample { data: vec![0x32, 0], key: true, duration: 1 }).is_err(), "configuration first"); + let mut m = WebmWriter::new(Cursor::new(Vec::new()), 64, 36, 30).unwrap(); + m.set_config(av1_config(&[0x0a, 1, 0])); + for s in samples() { + m.add(&s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + let top = elements(&d, 0..d.len()); + assert_eq!(top.iter().map(|(i, _)| *i).collect::>(), vec![EBML, SEGMENT]); + let seg = elements(&d, top[1].1.clone()); + let ids: Vec<_> = seg.iter().map(|(i, _)| *i).collect(); + assert_eq!(ids, vec![SEEK_HEAD, VOID, INFO, TRACKS, CLUSTER, CLUSTER, CUES]); + let info = elements(&d, seg[2].1.clone()); + let dur = info.iter().find(|(i, _)| *i == DURATION).unwrap().1.clone(); + assert_eq!(f64::from_be_bytes(d[dur].try_into().unwrap()), 3000.0); + let track = elements(&d, elements(&d, seg[3].1.clone())[0].1.clone()); + let codec = track.iter().find(|(i, _)| *i == 0x86).unwrap().1.clone(); + assert_eq!(&d[codec], b"V_AV1"); + let first: Vec<_> = elements(&d, seg[4].1.clone()).into_iter().map(|(i, _)| i).collect(); + assert_eq!(first, vec![TIMESTAMP, SIMPLE_BLOCK, SIMPLE_BLOCK, SIMPLE_BLOCK]); + let second = elements(&d, seg[5].1.clone()); + assert_eq!(d[second[0].1.clone()], [0x04, 0x2a], "the second cluster starts at 1066 ms (32 periods)"); + let group = elements(&d, second[1].1.clone()); + assert_eq!(group.iter().map(|(i, _)| *i).collect::>(), vec![BLOCK, BLOCK_DURATION]); + // the SeekHead points at Info, Tracks and Cues + let seg_start = top[1].1.start; + for seek in elements(&d, seg[0].1.clone()) { + let e = elements(&d, seek.1); + let pos = d[e[1].1.clone()].iter().fold(0usize, |a, b| (a << 8) | usize::from(*b)); + let id = d[e[0].1.clone()].iter().fold(0u32, |a, b| (a << 8) | u32::from(*b)); + assert!(seg.iter().any(|(i, r)| *i == id && r.start > seg_start + pos), "{id:x}"); + } + let mut neg = Vec::new(); + el_int_into(&mut neg, REFERENCE_BLOCK, -33); + assert_eq!(neg, vec![0xfb, 0x81, 0xdf]); + assert_eq!(ebml_size(126), vec![0xfe]); + assert_eq!(ebml_size(127), vec![0x40, 0x7f]); + } + + #[test] + fn hostile_sizes_are_refused() { + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 0, 10, 30).is_err()); + assert!(Mp4Writer::new(Cursor::new(Vec::new()), 70_000, 10, 30).is_err()); + assert!(WebmWriter::new(Cursor::new(Vec::new()), 10, 10, 0).is_err()); + assert!(yuv420(&[[0; 4]; 3], 2, 2).is_err()); + assert!(yuv420(&[[0; 4]; 9], 3, 3).is_err()); + let (y, u, v) = yuv420(&[[255; 4]; 4], 2, 2).unwrap(); + assert_eq!((y, u, v), (vec![235; 4], vec![128], vec![128])); + assert!(quantizer(100) < quantizer(50) && quantizer(50) < quantizer(1)); + assert!(Av1Encoder::new(&VideoConfig { width: 8, height: 8, fps: 30, quality: 50, speed: 10 }).is_err()); + } + + #[cfg(all(feature = "avif", not(target_arch = "wasm32")))] + #[test] + fn encodes_av1_into_a_playable_mp4() { + let c = VideoConfig { width: 64, height: 48, fps: 24, quality: 60, speed: 10 }; + let mut e = Av1Encoder::new(&c).unwrap(); + let mut out = Vec::new(); + for (i, periods) in [(0u8, 24u64), (80, 1), (160, 1), (240, 30)] { + let img = vec![[i, 255 - i, 128, 255]; 64 * 48]; + out.extend(e.push(&img, periods, periods > 1).unwrap()); + } + out.extend(e.finish().unwrap()); + assert_eq!(out.len(), 4, "one sample per frame sent"); + assert!(out[0].key); + assert_eq!(out.iter().map(|s| s.duration).sum::(), 56); + for s in &out { + assert!(obus(&s.data).unwrap().iter().all(|(k, _)| *k != OBU_TEMPORAL_DELIMITER)); + } + let config = e.config_record().unwrap(); + assert_eq!(obus(&config[4..]).unwrap()[0].0, OBU_SEQUENCE_HEADER); + let mut m = VideoWriter::new(Cursor::new(Vec::new()), Container::Mp4, 64, 48, 24).unwrap(); + m.set_config(config); + for s in &out { + m.add(s).unwrap(); + } + let d = m.finish().unwrap().into_inner(); + assert_eq!(boxes(&d, 0..d.len()).len(), 3); + } +} diff --git a/crates/codecs/tests/corpus.rs b/crates/codecs/tests/corpus.rs index d3b2ceba3..9a86bc5e2 100644 --- a/crates/codecs/tests/corpus.rs +++ b/crates/codecs/tests/corpus.rs @@ -31,7 +31,7 @@ fn files(dir: &Path) -> Vec { fn corpus_images_decode() { let dir = corpus_root().join("images"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let (mut ok, mut unsupported) = (0, 0); @@ -57,7 +57,7 @@ fn corpus_images_decode() { fn corpus_pngsuite() { let dir = corpus_root().join("pngsuite"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } let mut n = 0; @@ -83,7 +83,7 @@ fn corpus_pngsuite() { fn corpus_raw_is_routed() { let dir = corpus_root().join("raw"); if !dir.is_dir() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } for p in files(&dir) { @@ -103,7 +103,7 @@ fn system_icc_profiles() { use lightcraft_codecs::icc::{IccColorModel, IccKind, parse}; let dir = Path::new("/System/Library/ColorSync/Profiles"); if !dir.is_dir() { - eprintln!("skip: no system ICC profiles"); + lightcraft_testkit::skip!(System, "no system ICC profiles"); return; } let expect = [ diff --git a/crates/codecs/tests/float_tiff.rs b/crates/codecs/tests/float_tiff.rs new file mode 100644 index 000000000..fe1cd2411 --- /dev/null +++ b/crates/codecs/tests/float_tiff.rs @@ -0,0 +1,128 @@ +//! Non-finite samples in procedural 32-bit float TIFFs (issue #1952, item 6). + +use lightcraft_codecs::{icc, *}; + +fn tiff(samples: &[f32], channels: u8, profile: Option<&[u8]>) -> Vec { + encode_tiff( + &EncodeImage::new((samples.len() / channels as usize) as u32, 1, channels, Samples::F32(samples)), + TiffCompression::None, + &EncodeMeta { icc: profile, ..Default::default() }, + ) + .unwrap() +} + +#[test] +fn float_tiff_nonfinite_rgb_clips_without_losing_finite_hdr() { + let samples = + [[f32::INFINITY; 3], [f32::NAN; 3], [f32::NEG_INFINITY; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [f32::INFINITY, 0.5, f32::NAN]]; + let bytes = tiff(samples.as_flattened(), 3, None); + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert!(decoded.float); + assert_eq!(decoded.bit_depth, 32); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0; 3], [0.0; 3], [-0.25; 3], [0.25, 0.5, 0.75], [2.0, 4.0, 8.0], [1.0, 0.5, 0.0]]); + let display = decoded.to_srgb8(); + assert_eq!(display.data[0], [255; 4]); + for pixel in &display.data[1..4] { + assert_eq!(*pixel, [0, 0, 0, 255]); + } + assert_eq!(display.data[6], [255, 188, 0, 255]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // A box-filtered pair must average clipped white with its ordinary neighbor. + let pair = tiff(&[f32::INFINITY, f32::INFINITY, f32::INFINITY, 0.25, 0.25, 0.25], 3, None); + let small = decode(&pair, DecodeOptions::fit(1, 1)).unwrap(); + assert_eq!(small.image.data, [[0.625; 3]]); +} + +#[test] +fn float_tiff_nonfinite_gray_and_icc_curves_match_clipped_samples() { + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 1.0, 2.0, -0.25]; + let clipped = [1.0, 0.0, 0.0, 0.25, 1.0, 2.0, -0.25]; + let profiles = [ + None, + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Linear)), + Some(icc::write_named(NamedSpace::Srgb)), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Gamma(2.0))), + Some(icc::write_matrix_trc(&lightcraft_color::SRGB, &Trc::Table(vec![0, 16384, 65535]))), + Some(moxcms::ColorProfile::new_gray_with_gamma(2.0).encode().unwrap()), + Some(b"invalid ICC profile".to_vec()), + ]; + for channels in [1, 3] { + let samples: Vec<_> = samples.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + let clipped: Vec<_> = clipped.iter().flat_map(|v| std::iter::repeat_n(*v, channels as usize)).collect(); + for profile in &profiles { + let profile = profile.as_deref(); + let decoded = decode(&tiff(&samples, channels, profile), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, channels, profile), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.grayscale, channels == 1); + assert_eq!(decoded.image, reference.image, "channels={channels}, space={:?}", decoded.space); + assert_eq!(decoded.to_srgb8(), reference.to_srgb8()); + assert!(decoded.image.data[5].iter().all(|v| *v > 1.0), "finite HDR survives the curve"); + assert!(decoded.image.data[6].iter().all(|v| *v < 0.0), "finite negative samples survive the curve"); + } + } +} + +#[test] +fn float_tiff_nonfinite_alpha_is_finite_and_bounded() { + let samples = [ + [f32::INFINITY, f32::INFINITY, f32::INFINITY, f32::INFINITY], + [f32::NAN, f32::NEG_INFINITY, 0.5, f32::NAN], + [0.25, 0.5, 2.0, f32::NEG_INFINITY], + [2.0, 3.0, 4.0, 0.5], + ]; + let decoded = decode(&tiff(samples.as_flattened(), 4, None), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [1.0, 0.0, 0.0, 0.5]); + assert_eq!(decoded.image.data, [[1.0; 3], [0.0, 0.0, 0.5], [0.25, 0.5, 2.0], [2.0, 3.0, 4.0]]); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); + + // Associated alpha takes the same path before unpremultiplication. + let mut cursor = std::io::Cursor::new(Vec::new()); + { + use tiff::encoder::{TiffEncoder, colortype}; + let mut encoder = TiffEncoder::new(&mut cursor).unwrap(); + let mut image = encoder.new_image::(2, 1).unwrap(); + image.encoder().write_tag(tiff::tags::Tag::ExtraSamples, &[1u16][..]).unwrap(); + image.write_data(&[f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.5, 1.0, 2.0, 3.0, 0.5]).unwrap(); + } + let decoded = decode(&cursor.into_inner(), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.alpha.as_ref().unwrap().data, [0.5, 0.5]); + assert_eq!(decoded.image.data, [[2.0, 0.0, 0.0], [2.0, 4.0, 6.0]]); +} + +fn lut_profile() -> Vec { + use moxcms::{ColorProfile, LutMultidimensionalType, LutWarehouse, Matrix3d, ToneReprCurve, Vector3d, Xyzd}; + let mut profile = ColorProfile::new_srgb(); + profile.cicp = None; + profile.red_colorant = Xyzd::default(); + profile.green_colorant = Xyzd::default(); + profile.blue_colorant = Xyzd::default(); + profile.red_trc = None; + profile.green_trc = None; + profile.blue_trc = None; + // A procedural mAB profile without a CLUT: linear RGB to D50 XYZ, then identity curves. + profile.lut_a_to_b_colorimetric = Some(LutWarehouse::Multidimensional(LutMultidimensionalType { + num_input_channels: 3, + num_output_channels: 3, + grid_points: [0; 16], + clut: None, + a_curves: vec![], + m_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + matrix: Matrix3d { v: NamedSpace::Srgb.to_xyz_d50().0.map(|r| r.map(|v| v * 32768.0 / 65535.0)) }, + b_curves: vec![ToneReprCurve::Parametric(vec![1.0]); 3], + bias: Vector3d::default(), + })); + profile.encode().unwrap() +} + +#[test] +fn float_tiff_nonfinite_cms_samples_match_clipped_samples() { + let profile = lut_profile(); + let samples = [f32::INFINITY, f32::NAN, f32::NEG_INFINITY, 0.25, 0.5, 0.75]; + let clipped = [1.0, 0.0, 0.0, 0.25, 0.5, 0.75]; + let decoded = decode(&tiff(&samples, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + let reference = decode(&tiff(&clipped, 3, Some(&profile)), DecodeOptions::default()).unwrap(); + assert_eq!(decoded.space.origin, SpaceOrigin::IccCms); + assert_eq!(decoded.image, reference.image); + assert!(decoded.to_working().data.iter().flatten().all(|v| v.is_finite())); +} diff --git a/crates/codecs/tests/jxl_streaming.rs b/crates/codecs/tests/jxl_streaming.rs new file mode 100644 index 000000000..298034656 --- /dev/null +++ b/crates/codecs/tests/jxl_streaming.rs @@ -0,0 +1,98 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::exif::minimal_exif; +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +fn codestream(bytes: &[u8]) -> Vec { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +fn box_bytes(out: &mut Vec, kind: &[u8; 4], data: &[u8]) { + out.extend_from_slice(&u32::try_from(data.len() + 8).unwrap().to_be_bytes()); + out.extend_from_slice(kind); + out.extend_from_slice(data); +} + +#[test] +fn metadata_in_final_feed_matches_builder() { + let pixels = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let raw = codestream(&encoded); + let exif = minimal_exif(1); + let mut exif_box = vec![0; 4]; // TIFF header offset, relative to the end of this field + exif_box.extend_from_slice(&exif); + // Split the codestream across two feeds, with Exif and XMP following its last frame. + let mut bytes = b"\0\0\0\x0cJXL \x0d\x0a\x87\x0a".to_vec(); + box_bytes(&mut bytes, b"ftyp", b"jxl \0\0\0\0jxl "); + let split = raw.len() / 2; + let mut part = 0u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[..split]); + box_bytes(&mut bytes, b"jxlp", &part); + let padding = vec![0; 4096 - bytes.len() - 8]; + box_bytes(&mut bytes, b"free", &padding); + let mut part = 0x8000_0001u32.to_be_bytes().to_vec(); + part.extend_from_slice(&raw[split..]); + box_bytes(&mut bytes, b"jxlp", &part); + box_bytes(&mut bytes, b"Exif", &exif_box); + box_bytes(&mut bytes, b"xml ", b"

"); + assert!(bytes.len() < 8192, "metadata must fit in the completing input chunk"); + let old = jxl_oxide::JxlImage::builder().read(bytes.as_slice()).unwrap(); + let old_exif = match old.aux_boxes().first_exif().unwrap() { + jxl_oxide::AuxBoxData::Data(ex) => ex.payload().to_vec(), + _ => panic!("builder must preserve Exif in its final feed"), + }; + let old_xml = match old.aux_boxes().first_xml() { + jxl_oxide::AuxBoxData::Data(xml) => xml.to_vec(), + _ => panic!("builder must preserve XMP in its final feed"), + }; + let decoded = decode(&bytes, DecodeOptions::default()).unwrap(); + assert_eq!(decoded.exif.as_deref(), Some(old_exif.as_slice())); + assert_eq!(decoded.xmp.as_deref(), Some(std::str::from_utf8(&old_xml).unwrap())); + assert_eq!(decoded.to_srgb8(), pixels); +} + +#[test] +fn progressive_truncation_matches_builder() { + let pixels = Rgba8::from_fn(96, 64, |x, y| { + let mut seed = (y * 96 + x + 1) as u32; + let mut rgb = [0; 4]; + for channel in &mut rgb[..3] { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + *channel = seed as u8; + } + rgb[3] = 255; + rgb + }); + for lossless in [false, true] { + let encoded = + encode_jxl(&EncodeImage::rgba8(&pixels), &JxlOptions { lossless, progressive: true, ..Default::default() }, &EncodeMeta::default()) + .unwrap(); + let raw = codestream(&encoded); + assert!(raw.len() > 4096, "fixture must exercise post-header feeds"); + for cut in [0, 1, 2, 12, 4095, 4096, 4097, raw.len() / 2, raw.len() - 1, raw.len()] { + let bytes = &raw[..cut]; + // `finalize` permits partial images: rendering determines whether frame 0 is usable. + let old_ok = jxl_oxide::JxlImage::builder().read(bytes).is_ok_and(|image| image.render_frame(0).is_ok()); + assert_eq!(decode(bytes, DecodeOptions::default()).is_ok(), old_ok, "lossless={lossless}, cut={cut}/{}", raw.len()); + } + } +} diff --git a/crates/codecs/tests/jxl_tail_heap.rs b/crates/codecs/tests/jxl_tail_heap.rs new file mode 100644 index 000000000..f2200e862 --- /dev/null +++ b/crates/codecs/tests/jxl_tail_heap.rs @@ -0,0 +1,43 @@ +#![cfg(all(feature = "jxl", not(target_arch = "wasm32")))] + +use lightcraft_codecs::*; +use lightcraft_raster::Rgba8; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +fn codestream(bytes: &[u8]) -> Vec<u8> { + if bytes.starts_with(b"\xff\x0a") { + return bytes.to_vec(); + } + let mut raw = Vec::new(); + let mut at = 12; + while at + 8 <= bytes.len() { + let len = u32::from_be_bytes(bytes[at..at + 4].try_into().unwrap()) as usize; + let end = if len == 0 { bytes.len() } else { at + len }; + match &bytes[at + 4..at + 8] { + b"jxlc" => raw.extend_from_slice(&bytes[at + 8..end]), + b"jxlp" => raw.extend_from_slice(&bytes[at + 12..end]), + _ => {} + } + at = end; + } + assert!(raw.starts_with(b"\xff\x0a")); + raw +} + +#[test] +fn tiny_jxl_tail_does_not_allocate_a_tail_sized_buffer() { + let image = Rgba8::from_fn(8, 8, |x, y| [(x * 20) as u8, (y * 20) as u8, 80, 255]); + let encoded = encode_jxl(&EncodeImage::rgba8(&image), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let mut raw = codestream(&encoded); + let opts = DecodeOptions { max_pixels: 64, ..Default::default() }; + decode(&raw, opts).unwrap(); + raw.resize(raw.len() + (1 << 20), 0); + let _profiler = dhat::Profiler::builder().testing().build(); + let decoded = decode(&raw, opts).unwrap(); + assert_eq!((decoded.width, decoded.height), (8, 8)); + let peak = dhat::HeapStats::get().max_bytes; + eprintln!("peak heap bytes: {peak}"); + assert!(peak < (128 << 10), "tiny image used {peak} heap bytes"); +} diff --git a/crates/codecs/tests/roundtrip.rs b/crates/codecs/tests/roundtrip.rs index af689ae5d..d802366c3 100644 --- a/crates/codecs/tests/roundtrip.rs +++ b/crates/codecs/tests/roundtrip.rs @@ -263,6 +263,90 @@ fn tiff_cmyk_and_orientation() { assert!(red[0] > 0.99 && red[1] < 0.01 && red[2] < 0.01, "{red:?}"); } +/// An uncompressed 16-bit RGB export in a single strip, the shape of the file in #1983 (3923 × 5877, +/// 138 MB). Its one strip is larger than the `tiff` crate's default 128 MiB intermediate buffer, so +/// the thumbnail (and the full decode) failed with "decoder limits exceeded". +#[test] +fn tiff_single_strip_larger_than_128_mib() { + use tiff::encoder::{TiffEncoder, colortype}; + let (w, h) = (3923u32, 5877u32); + let mut cur = std::io::Cursor::new(Vec::new()); + { + let mut enc = TiffEncoder::new(&mut cur).unwrap(); + let mut im = enc.new_image::<colortype::RGB16>(w, h).unwrap(); + im.rows_per_strip(h).unwrap(); + im.write_data(&vec![0x8000u16; (w * h * 3) as usize]).unwrap(); + } + let bytes = cur.into_inner(); + assert!(bytes.len() > 128 << 20); + let t = decode_thumbnail(&bytes, 256).unwrap(); + assert_eq!((t.source_width, t.source_height), (w, h)); + let px = t.image.get(10, 10); + assert!(px[0].abs_diff(128) <= 2 && px[0] == px[1] && px[1] == px[2], "{px:?}"); +} + +/// A little-endian TIFF with one IFD of single-value `(tag, type, value)` entries, followed by `data`. +fn ifd_tiff(entries: &[(u16, u16, u32)], data: &[u8]) -> Vec<u8> { + let mut b = b"II*\0".to_vec(); + b.extend(8u32.to_le_bytes()); + b.extend((entries.len() as u16).to_le_bytes()); + for &(tag, ty, value) in entries { + b.extend(tag.to_le_bytes()); + b.extend(ty.to_le_bytes()); + b.extend(1u32.to_le_bytes()); + b.extend(value.to_le_bytes()); + } + b.extend(0u32.to_le_bytes()); + b.extend_from_slice(data); + b +} + +/// A strip or tile can be larger than the pixels it shows (a JPEG header, padding to the tile size), so +/// a small `max_pixels` must not shrink the intermediate buffer below the `tiff` crate's 128 MiB +/// default. Both files below were refused with "decoder limits exceeded" when the buffer was capped at +/// 32 bytes per allowed pixel. Fixtures from AlexChambers' review of #1989. +#[test] +fn tiff_small_pixel_budget_reads_headers_and_padded_tiles() { + let opts = |max_pixels| DecodeOptions { max_size: None, max_pixels }; + + // 1 × 1 gray, one JPEG-compressed strip (~400 bytes for one pixel), with a one-pixel budget. + let mut jpeg = Vec::new(); + jpeg_encoder::Encoder::new(&mut jpeg, 90).encode(&[128], 1, 1, jpeg_encoder::ColorType::Luma).unwrap(); + let at = 8 + 2 + 10 * 12 + 4; + let entries = [ + (256, 4, 1), + (257, 4, 1), + (258, 3, 8), + (259, 3, 7), + (262, 3, 1), + (273, 4, at), + (277, 3, 1), + (278, 4, 1), + (279, 4, jpeg.len() as u32), + (284, 3, 1), + ]; + let d = decode(&ifd_tiff(&entries, &jpeg), opts(1)).unwrap(); + assert_eq!((d.image.width, d.image.height), (1, 1)); + + // 16 × 16 gray stored in one 256 × 256 tile (64 KiB), with a budget of exactly 256 pixels. + let at = 8 + 2 + 11 * 12 + 4; + let entries = [ + (256, 4, 16), + (257, 4, 16), + (258, 3, 8), + (259, 3, 1), + (262, 3, 1), + (277, 3, 1), + (284, 3, 1), + (322, 4, 256), + (323, 4, 256), + (324, 4, at), + (325, 4, 65536), + ]; + let d = decode(&ifd_tiff(&entries, &[128; 65536]), opts(256)).unwrap(); + assert_eq!((d.image.width, d.image.height), (16, 16)); +} + /// A one-strip, uncompressed, single-sample TIFF (little-endian) whose samples are `bits` wide and /// already packed into `strip`, one padded row after another. fn packed_tiff(width: u16, height: u16, bits: u16, photometric: u16, strip: &[u8]) -> Vec<u8> { @@ -734,6 +818,21 @@ fn jxl_lossless_exact_with_metadata() { assert!(max_diff(&d.image, &want.image) < 1e-5, "16-bit lossless differs by {}", max_diff(&d.image, &want.image)); } +/// #1517: the pixel limit rejects an oversized image; selected truncations never panic. +#[test] +#[cfg(all(feature = "jxl", not(target_arch = "wasm32")))] +fn jxl_size_limit_and_truncation() { + let b = encode_jxl(&EncodeImage::rgba8(&gradient(97, 61)), &JxlOptions { lossless: true, ..Default::default() }, &EncodeMeta::default()).unwrap(); + let r = decode(&b, DecodeOptions { max_pixels: 97 * 61 - 1, ..Default::default() }); + assert!(matches!(r, Err(Error::TooLarge(97, 61))), "{r:?}"); + let d = decode(&b, DecodeOptions { max_pixels: 97 * 61, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (97, 61)); + for cut in [0, 1, 2, 5, 12, 40, b.len() / 2, b.len() - 1] { + let _ = decode(&b[..cut], DecodeOptions::default()); + } + assert!(decode(&b[..12.min(b.len())], DecodeOptions::default()).is_err()); +} + #[test] #[cfg(feature = "jxl")] fn jxl_lossy_quality_and_effort() { diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index 855c3d049..89fe93c9f 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -180,8 +180,8 @@ pub struct Rgb8(pub u8, pub u8, pub u8); impl Rgb8 { pub fn from_hex(s: &str) -> Option<Rgb8> { - let s = s.trim_start_matches('#'); - if s.len() != 6 { + let s = s.strip_prefix('#').unwrap_or(s); + if s.len() != 6 || !s.bytes().all(|b| b.is_ascii_hexdigit()) { return None; } let v = u32::from_str_radix(s, 16).ok()?; @@ -196,6 +196,15 @@ impl Rgb8 { mod tests { use super::*; + #[test] + fn rgb8_from_hex_rejects_signs_and_extra_hashes() { + assert_eq!(Rgb8::from_hex("#0f0f0f"), Some(Rgb8(15, 15, 15))); + assert_eq!(Rgb8::from_hex("ff8000"), Some(Rgb8(255, 128, 0))); + for bad in ["+12345", "#+12345", "#+f+f+f", "-12345", "##123456", "#12345", ""] { + assert_eq!(Rgb8::from_hex(bad), None, "{bad}"); + } + } + #[test] fn srgb_matrix_matches_standard() { let m = SRGB.to_xyz(); diff --git a/crates/denoise-core/src/run.rs b/crates/denoise-core/src/run.rs index 987a859b9..4314ed8c3 100644 --- a/crates/denoise-core/src/run.rs +++ b/crates/denoise-core/src/run.rs @@ -14,7 +14,7 @@ use crate::manifest::Gain; use crate::tiles::{starts, weight}; /// Most pixels one picture may have (guards the allocations that follow from its size). -pub const MAX_PIXELS: usize = 100_000_000; +pub const MAX_PIXELS: usize = 110_000_000; /// A tile whose mean signal is below this (white = 1) is too dark to measure its scale from. const MIN_SIGNAL: f32 = 0.003; @@ -642,4 +642,19 @@ mod tests { assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![0.0; 5]), &p, &Control::default()), Err(Error::Model(_)))); assert!(matches!(denoise_bayer(&[0.5; 16], 4, 4, layout, &Bad(vec![f32::NAN; 3 * 64 * 64]), &p, &Control::default()), Err(Error::Model(_)))); } + + #[test] + fn accepts_102_mp_inputs_and_rejects_inputs_above_the_limit() { + let layout = Layout { dx: 0, dy: 0 }; + let model = Mock { tile: 32, scale: 1.0 }; + let p = params(32, 8, Gain::None); + assert!(matches!( + denoise_bayer(&[], 11_648, 8_736, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the mosaic does not have width × height samples" + )); + assert!(matches!( + denoise_bayer(&[], MAX_PIXELS + 1, 1, layout, &model, &p, &Control::default()), + Err(Error::Input(message)) if message == "the picture has no size, or is too large" + )); + } } diff --git a/crates/develop/src/lib.rs b/crates/develop/src/lib.rs index f376b3c67..4631e5e49 100644 --- a/crates/develop/src/lib.rs +++ b/crates/develop/src/lib.rs @@ -41,7 +41,7 @@ impl DevelopSettings { /// Merge a partial JSON object into these settings (fields not mentioned are kept). A white balance that /// names no [`WbScale`] keeps the meaning it always had (`Legacy`), whatever scale these settings were on: a /// partial from before the scale existed, or one that never said, must not become Kelvin by landing on - /// Kelvin settings. + /// Kelvin settings. Out-of-range values are brought back into range ([`DevelopSettings::keep_in_range`]). pub fn merged(&self, partial: &Value) -> Result<DevelopSettings, serde_json::Error> { let mut v = self.to_json(); presets::deep_merge(&mut v, partial); @@ -52,9 +52,34 @@ impl DevelopSettings { { out.wb.scale = WbScale::Legacy; } + out.keep_in_range(self); Ok(out) } + /// What crs XMP import does to the values it reads, for settings that arrive as JSON (presets, `develop.merge`, + /// `render --settings`; issue #1946): every slider clamped through its control spec, point curves in 0..1, and a + /// crop with no area left as it was in `before`. Unrotated, the crop is clipped to the photo first; a turned crop + /// may reach past 0..1 and is left to the crop tools, which know the photo's size. + fn keep_in_range(&mut self, before: &DevelopSettings) { + for c in controls::CONTROLS { + if let Some(v) = controls::get(self, c.id) { + controls::set(self, c.id, v); + } + } + let c = &mut self.curve; + for p in [&mut c.master, &mut c.red, &mut c.green, &mut c.blue].into_iter().flatten() { + *p = lightcraft_geom::Point::new(p.x.clamp(0.0, 1.0), p.y.clamp(0.0, 1.0)); + } + let g = &mut self.crop.geometry; + if g.angle == 0.0 { + g.rect = g.rect.intersect(&lightcraft_geom::Rect::UNIT); + } + if g.rect.is_empty() { + let old = before.crop.geometry.rect; + g.rect = if old.is_empty() { lightcraft_geom::Rect::UNIT } else { old }; + } + } + /// Stable 64-bit hash of the settings (FNV-1a over canonical JSON), for preview cache keys. pub fn hash64(&self) -> u64 { let s = serde_json::to_string(self).unwrap_or_default(); @@ -255,6 +280,30 @@ mod tests { assert_eq!(m.light.exposure, 0.0); } + #[test] + fn merged_settings_are_kept_in_range() { + // issue #1946: `develop.merge`, `render --settings` and presets stored whatever the JSON said + let mut base = DevelopSettings::default(); + base.crop.geometry.rect = lightcraft_geom::Rect::new(0.1, 0.2, 0.8, 0.9); + let m = base + .merged(&json!({"light": {"exposure": 1e308}, "vignette": {"amount": -1e308}, "geometry": {"scale": 0}, "optics": {"distortion": 1e308}})) + .unwrap(); + assert_eq!((m.light.exposure, m.vignette.amount, m.geometry.scale, m.optics.distortion), (5.0, -100.0, 50.0, 100.0)); + // point curves stay in 0..1, as an imported XMP curve does + let m = base.merged(&json!({"curve": {"master": [{"x": -3, "y": 0}, {"x": 1, "y": 1e308}]}})).unwrap(); + assert_eq!(m.curve.master, vec![lightcraft_geom::Point::new(0.0, 0.0), lightcraft_geom::Point::new(1.0, 1.0)]); + // a crop with no area is left out, as XMP import leaves out one whose edges are inverted + for rect in + [json!({"x0": 0.9, "x1": 0.1, "y0": 0, "y1": 1}), json!({"x0": 1e308, "x1": -1e308, "y0": 0, "y1": 1}), json!({"x0": 0.5, "x1": 0.5})] + { + let m = base.merged(&json!({"crop": {"geometry": {"rect": rect}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, base.crop.geometry.rect, "{rect}"); + } + // unrotated, the crop stays inside the photo + let m = base.merged(&json!({"crop": {"geometry": {"rect": {"x0": -1e308, "y0": 0.25, "x1": 1e308, "y1": 0.75}}}})).unwrap(); + assert_eq!(m.crop.geometry.rect, lightcraft_geom::Rect::new(0.0, 0.25, 1.0, 0.75)); + } + #[test] fn unedited_and_reset() { let mut s = DevelopSettings::default(); diff --git a/crates/develop/src/presets.rs b/crates/develop/src/presets.rs index acaa303fc..b3edc801c 100644 --- a/crates/develop/src/presets.rs +++ b/crates/develop/src/presets.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; use serde_json::{Map, Value}; +use std::borrow::Cow; use crate::DevelopSettings; @@ -183,18 +184,11 @@ fn scale_patch(base: &Value, patch: &Value, t: f64) -> Value { } /// Apply a partial settings object with an amount (1.0 = 100 %). Out-of-range values are clamped -/// through the control specs. +/// through the control specs, as every merge does ([`DevelopSettings::merged`]). pub fn apply_partial(s: &DevelopSettings, partial: &Value, amount: f64) -> DevelopSettings { - let base = s.to_json(); - let patch = if (amount - 1.0).abs() < 1e-9 { partial.clone() } else { scale_patch(&base, partial, amount) }; + let patch = if (amount - 1.0).abs() < 1e-9 { Cow::Borrowed(partial) } else { Cow::Owned(scale_patch(&s.to_json(), partial, amount)) }; // (a white balance without a scale keeps its old meaning: `DevelopSettings::merged`) - let mut out = s.merged(&patch).unwrap_or_else(|_| s.clone()); - for c in crate::controls::CONTROLS { - if let Some(v) = crate::controls::get(&out, c.id) { - crate::controls::set(&mut out, c.id, v); - } - } - out + s.merged(&patch).unwrap_or_else(|_| s.clone()) } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] diff --git a/crates/develop/src/settings.rs b/crates/develop/src/settings.rs index 1895776b3..a40f094c2 100644 --- a/crates/develop/src/settings.rs +++ b/crates/develop/src/settings.rs @@ -900,11 +900,24 @@ pub struct BrushStroke { pub density: f64, pub erase: bool, pub auto_mask: bool, + /// Pen pressure (0..1) at each of `points`, recorded when the brush's pressure switch is on: it scales each dab's + /// radius and flow. Empty (the default, a mouse or the switch off) is full pressure everywhere; a point past the + /// end of a shorter list takes the last value. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub pressure: Vec<f32>, +} + +impl BrushStroke { + /// The pressure at point `i` (0..1; 1 without pressure data, a hostile value clamped, NaN as 1). + pub fn pressure_at(&self, i: usize) -> f32 { + let p = self.pressure.get(i).or(self.pressure.last()).copied().unwrap_or(1.0); + if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) } + } } impl Default for BrushStroke { fn default() -> Self { - Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false } + Self { points: Vec::new(), size: 0.03, feather: 50.0, flow: 100.0, density: 100.0, erase: false, auto_mask: false, pressure: Vec::new() } } } diff --git a/crates/engine/Cargo.toml b/crates/engine/Cargo.toml index 2229c42a9..091efe066 100644 --- a/crates/engine/Cargo.toml +++ b/crates/engine/Cargo.toml @@ -9,6 +9,8 @@ description = "LightCraft engine: session, command registry, history, render job workspace = true [dependencies] +# Paths stored in a Lightroom catalog are followed only to local files or the catalog's share. +craft-docpath = { workspace = true } lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } @@ -29,6 +31,7 @@ lightcraft-faces = { workspace = true } miniz_oxide = { workspace = true } lightcraft-merge = { workspace = true } lightcraft-geocode = { workspace = true } +lightcraft-slideshow = { workspace = true } rayon = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } @@ -51,6 +54,8 @@ denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime", "lightcraft-g rawnind-model = ["denoise", "lightcraft-denoise/rawnind-model", "lightcraft-denoise-core/rawnind-model"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } # HDR JPEG XL tests read the file's colour encoding jxl-oxide = { workspace = true } # Lossless synthetic HEIC files (the import test; decoded when the codecs' `heif` feature is on). diff --git a/crates/engine/build.rs b/crates/engine/build.rs index e097889ff..056c7adc8 100644 --- a/crates/engine/build.rs +++ b/crates/engine/build.rs @@ -3,7 +3,7 @@ //! Recipe from craft-fonts' docs/integration.md; craft-fonts is never a Cargo dependency, and this //! script only reads a local directory (no network). //! -//! wasm32 (the web build) embeds only the UI font, BIZ UDPGothic Regular (~4.5 MB), to keep the +//! wasm32 embeds BIZ UDPGothic Regular (~4.5 MB) and Noto Sans Thai (~214 KiB), to keep the //! `.wasm` within the web size budget (Cloudflare serves at most 25 MiB per file); the export //! watermark falls back to it there. Native builds embed every font. use std::fmt::Write as _; @@ -69,6 +69,7 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { let mut out = String::new(); let mut has_japanese_ui = false; let mut has_chinese_ui = false; + let mut has_thai_ui = false; for line in text.lines().map(str::trim).filter(|l| !l.is_empty() && !l.starts_with('#')) { let f: Vec<&str> = line.split(" | ").map(str::trim).collect(); let [family, style, file, scripts, ..] = f.as_slice() else { @@ -76,7 +77,8 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { }; has_japanese_ui |= (*family, *style) == ("BIZ UDPGothic", "Regular"); has_chinese_ui |= (*family, *style) == ("Noto Sans CJK SC", "Regular") && scripts.split(',').any(|script| script.trim() == "Hans"); - if wasm && (*family, *style) != ("BIZ UDPGothic", "Regular") { + has_thai_ui |= (*family, *style) == ("Noto Sans Thai", "Regular") && scripts.split(',').any(|script| script.trim() == "Thai"); + if wasm && !matches!((*family, *style), ("BIZ UDPGothic" | "Noto Sans Thai", "Regular")) { continue; } let path = dir.join(file).canonicalize().map_err(|e| format!("{file}: {e}"))?; @@ -89,6 +91,9 @@ fn craft_fonts(dir: &Path, required: bool) -> Result<String, String> { path.display().to_string(), ); } + if required && !has_thai_ui { + return Err("required Thai UI face Noto Sans Thai Regular (Thai) is missing from fonts/manifest.txt".into()); + } if required && !has_japanese_ui { return Err("required Japanese UI face BIZ UDPGothic Regular is missing from fonts/manifest.txt".into()); } diff --git a/crates/engine/examples/raw_frame.rs b/crates/engine/examples/raw_frame.rs new file mode 100644 index 000000000..ce6c63e85 --- /dev/null +++ b/crates/engine/examples/raw_frame.rs @@ -0,0 +1,499 @@ +//! Measure where a camera's own JPEG sits inside its raw sensor data, and which edge bands of that data hold no +//! picture: `cargo xtask raw-frame [--json] FILE…` (or `cargo run --release -p lightcraft-engine --example raw_frame -- +//! [--json] FILE…`). It checks the crop any decoder applies against the camera's own framing, and measures the entries +//! of Nikon `MEASURED_BODIES` (`crates/raw/src/vendor/nef.rs`) for bodies that record +//! no crop. It works for any colour-filter raw that embeds a full-size camera JPEG (checked on Nikon, Sony, Canon CR2 / +//! CR3, Pentax and Samsung files, where it finds the decoder's crop); bodies with a smaller embedded preview (most +//! Fujifilm, Panasonic and Olympus files) get the edge bands only. +//! +//! **Frame.** For each raw it aligns the largest embedded JPEG with the sensor data: both are reduced to half-size +//! luminance (the raw by summing each 2×2 colour-filter block), and every offset that keeps the JPEG inside the data is +//! scored by the correlation of their gradient magnitudes. The best offset is refined to sub-pixel precision; a peak +//! that is weak, ambiguous or between two pixels is flagged. Files are grouped by model and data size (bit depths +//! pooled: both read out the same area); when 3 clean files agree, the group prints its table entries (siblings of the +//! same readout that agree on the same frame count towards the 3; see `MIN_FILES`). Use several +//! files with detail across the frame: a blank sky, fog or a lens cap has nothing to align on (low score). The JPEG +//! must be the camera's full-size one, unrotated and without in-camera geometry corrections (distortion control off); +//! a smaller preview is not aligned. +//! +//! **Edge bands.** From each edge inwards (at most 128 lines) it counts lines that hold no picture: *empty or dark* +//! (mean below 1% of the range over black) or *masked* (flat, patternless and above black: even and odd samples +//! alike within 4 standard errors or within 0.4% of white, since some filler holds a small fixed offset between the +//! two, as on the D90 and D5000; spread no more than the line's own neighbour noise and under 0.4% of white). A +//! band that the decoder's crop still includes is developed as picture: the coloured bars of issue #976. This is a +//! survey aid: a dark band can also be deep shadow, so read it with the frame. +use std::collections::BTreeMap; + +use lightcraft_codecs::DecodeOptions; +use lightcraft_raw::RawData; + +/// Smallest correlation peak worth trusting, and how far it must stand above the best offset outside its 3×3 +/// neighbourhood (measured: 0.75–0.88 on detailed D5100 / D7000 frames, runners-up ≥ 0.05 lower). +const MIN_SCORE: f64 = 0.5; +const MIN_MARGIN: f64 = 0.02; +/// Largest sub-pixel distance from a whole pixel accepted as a clean alignment. +const MAX_FRACTION: f64 = 0.35; +/// Search at most this many half-size offsets in each direction (384 px of margin around the JPEG: Canon bodies keep +/// up to ~280 px of masked border). +const MAX_SEARCH: usize = 192; +/// Windows of up to this many offsets are searched exhaustively (every Nikon body); larger ones coarse to fine. +const FULL_SEARCH: usize = 2048; +/// Edge bands are counted up to this many lines from each edge. +const MAX_BAND: usize = 128; +/// Clean, agreeing files an entry needs: of one model and data size (bit depths pooled: both read out the same area), +/// or, with at least one of its own, together with sibling models of the same readout (data and JPEG size) whose own +/// files agree on the same frame. +const MIN_FILES: usize = 3; + +struct Frame { + jpeg: (usize, usize), + /// Sub-pixel offset of the JPEG's top left corner in sensor pixels. + at: (f64, f64), + score: f64, + margin: f64, +} + +impl Frame { + fn whole(&self) -> (usize, usize) { + (self.at.0.round().max(0.0) as usize, self.at.1.round().max(0.0) as usize) + } + fn clean(&self) -> bool { + let frac = |v: f64| (v - v.round()).abs(); + self.score >= MIN_SCORE && self.margin >= MIN_MARGIN && frac(self.at.0) <= MAX_FRACTION && frac(self.at.1) <= MAX_FRACTION + } +} + +/// Lines without picture at one edge: empty or dark ones, then masked ones (or the other way round), from the edge. +#[derive(Clone, Copy, Default)] +struct Band { + dark: usize, + masked: usize, +} + +impl Band { + fn total(self) -> usize { + self.dark + self.masked + } +} + +struct Measured { + model: String, + size: (usize, usize), + bits: u32, + black: f64, + white: f64, + /// The crop the decoder applies, in sensor-data coordinates: x, y, width, height. + crop: (usize, usize, usize, usize), + /// Left, right, top, bottom. + bands: [Band; 4], + frame: Result<Frame, String>, + /// With a clean frame: what the crop holds beyond it. + beyond: Option<[Beyond; 4]>, +} + +impl Measured { + /// Lines of each edge band (left, right, top, bottom) inside the decoder's crop: developed as picture. + fn in_crop(&self) -> [usize; 4] { + let (w, h) = self.size; + let (x, y, cw, ch) = self.crop; + let [l, r, t, b] = self.bands.map(Band::total); + [l.saturating_sub(x), (x + cw).saturating_sub(w - r.min(w)), t.saturating_sub(y), (y + ch).saturating_sub(h - b.min(h))] + } +} + +/// What the decoder's crop holds outside the camera's JPEG frame, per side (left, right, top, bottom): lines that are +/// masked (flat above black: a coloured bar), dark (empty or optical black: a dark edge) or picture (trimmed by the +/// camera, harmless). +#[derive(Clone, Copy, Default)] +struct Beyond { + masked: usize, + dark: usize, + picture: usize, +} + +fn main() { + let args: Vec<String> = std::env::args().skip(1).collect(); + let json = args.iter().any(|a| a == "--json"); + let files: Vec<&String> = args.iter().filter(|a| *a != "--json").collect(); + if files.is_empty() { + eprintln!("usage: raw_frame [--json] FILE… (raw files whose largest embedded JPEG is the camera's full-size one)"); + std::process::exit(2); + } + // model, data size, JPEG size -> (bit depth, frame) of each file + let mut groups: BTreeMap<(String, (usize, usize), (usize, usize)), Vec<(u32, Frame)>> = BTreeMap::new(); + for f in files { + let m = match measure(f) { + Ok(m) => m, + Err(e) => { + if json { + println!("{}", serde_json::json!({ "file": f, "error": e })); + } else { + println!("{f}: skipped: {e}"); + } + continue; + } + }; + let in_crop = m.in_crop(); + if json { + let band = |b: Band| serde_json::json!({ "dark": b.dark, "masked": b.masked }); + let frame = match &m.frame { + Ok(fr) => serde_json::json!({ + "jpeg": [fr.jpeg.0, fr.jpeg.1], "at": [fr.at.0, fr.at.1], "score": fr.score, "margin": fr.margin, "clean": fr.clean() + }), + Err(e) => serde_json::json!({ "error": e }), + }; + println!( + "{}", + serde_json::json!({ + "file": f, "model": m.model, "size": [m.size.0, m.size.1], "bits": m.bits, "black": m.black, "white": m.white, + "crop": [m.crop.0, m.crop.1, m.crop.2, m.crop.3], + "bands": { "left": band(m.bands[0]), "right": band(m.bands[1]), "top": band(m.bands[2]), "bottom": band(m.bands[3]) }, + "in_crop": { "left": in_crop[0], "right": in_crop[1], "top": in_crop[2], "bottom": in_crop[3] }, + "beyond_frame": m.beyond.map(|b| { + let side = |b: Beyond| serde_json::json!({ "masked": b.masked, "dark": b.dark, "picture": b.picture }); + serde_json::json!({ "left": side(b[0]), "right": side(b[1]), "top": side(b[2]), "bottom": side(b[3]) }) + }), + "frame": frame, + }) + ); + } else { + let frame = match &m.frame { + Ok(fr) => format!( + "JPEG {}×{} at ({:.2}, {:.2}), score {:.3}, margin {:.3}{}", + fr.jpeg.0, + fr.jpeg.1, + fr.at.0, + fr.at.1, + fr.score, + fr.margin, + if fr.clean() { "" } else { " (not clean: weak, ambiguous or between pixels)" } + ), + Err(e) => format!("no frame: {e}"), + }; + println!("{f}: {} {}×{} {}-bit, {frame}", m.model, m.size.0, m.size.1, m.bits); + let side = |name: &str, b: Band| match (b.dark, b.masked) { + (0, 0) => None, + (d, 0) => Some(format!("{name} {d} dark")), + (0, k) => Some(format!("{name} {k} masked")), + (d, k) => Some(format!("{name} {d} dark + {k} masked")), + }; + let bands: Vec<String> = ["left", "right", "top", "bottom"].iter().zip(m.bands).filter_map(|(n, b)| side(n, b)).collect(); + let shown: Vec<String> = + ["left", "right", "top", "bottom"].iter().zip(in_crop).filter(|(_, n)| *n > 0).map(|(s, n)| format!("{s} {n}")).collect(); + println!( + " edge bands: {}; decoder crop ({}, {}, {}, {}){}", + if bands.is_empty() { "none".to_string() } else { bands.join(", ") }, + m.crop.0, + m.crop.1, + m.crop.2, + m.crop.3, + match m.beyond { + Some(b) => { + let parts: Vec<String> = ["left", "right", "top", "bottom"] + .iter() + .zip(b) + .filter(|(_, b)| b.masked + b.dark + b.picture > 0) + .map(|(s, b)| format!("{s} {} masked / {} dark / {} picture", b.masked, b.dark, b.picture)) + .collect(); + if parts.is_empty() { + "; nothing beyond the camera frame".to_string() + } else { + format!("; beyond the camera frame: {}", parts.join(", ")) + } + } + None if shown.is_empty() => String::new(), + None => format!(" still includes edge bands: {} lines", shown.join(", ")), + } + ); + } + if let Ok(fr) = m.frame { + groups.entry((m.model.clone(), m.size, fr.jpeg)).or_default().push((m.bits, fr)); + } + } + if json { + return; + } + println!(); + // each group's agreed frame: (model, data size, JPEG size) -> (offset, clean files), when its clean files agree + let agreed: BTreeMap<&(String, (usize, usize), (usize, usize)), ((usize, usize), usize)> = groups + .iter() + .filter_map(|(key, frames)| { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + let first = *offsets.first()?; + offsets.iter().all(|o| *o == first).then_some((key, (first, offsets.len()))) + }) + .collect(); + for (key, frames) in &groups { + let (model, size, jpeg) = key; + let clean = frames.iter().filter(|(_, f)| f.clean()).count(); + let mut depths: Vec<u32> = frames.iter().map(|(b, _)| *b).collect(); + depths.sort_unstable(); + depths.dedup(); + let label = format!("{model} {}×{}", size.0, size.1); + let Some(&((x, y), _)) = agreed.get(key) else { + if clean == 0 { + println!("{label}: no clean alignment in {} files; no entry", frames.len()); + } else { + let offsets: Vec<(usize, usize)> = frames.iter().filter(|(_, f)| f.clean()).map(|(_, f)| f.whole()).collect(); + println!("{label}: files disagree: {offsets:?}; no entry"); + } + continue; + }; + let note = if clean < frames.len() { format!(", {} not clean", frames.len() - clean) } else { String::new() }; + println!("{label}: {clean} of {} files agree on ({x}, {y}){note}", frames.len()); + // siblings: other models whose sensor readout matches (same data and JPEG size) and whose own files agree on + // the same frame + let siblings: Vec<(&str, usize)> = agreed + .iter() + .filter(|((m, s, j), (o, _))| m != model && s == size && j == jpeg && *o == (x, y)) + .map(|((m, _, _), (_, n))| (m.as_str(), *n)) + .collect(); + let pooled = clean + siblings.iter().map(|(_, n)| n).sum::<usize>(); + if clean < MIN_FILES { + if pooled < MIN_FILES { + println!(" pending: {} more clean file(s) needed for an entry", MIN_FILES - pooled); + continue; + } + let names: Vec<String> = siblings.iter().map(|(m, n)| format!("{m} ({n})")).collect(); + println!(" with siblings of the same readout and frame: {}", names.join(", ")); + } + for bits in depths { + println!( + " MeasuredBody {{ model: {model:?}, size: ({}, {}), bits: {bits}, crop: ({x}, {y}, {}, {}), white: None }},", + size.0, size.1, jpeg.0, jpeg.1 + ); + } + } +} + +fn measure(path: &str) -> Result<Measured, String> { + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let raw = lightcraft_raw::decode(&bytes).map_err(|e| e.to_string())?; + let RawData::U16(d) = &raw.data else { return Err("not 16-bit integer sensor data".into()) }; + if raw.cpp != 1 || raw.cfa.is_none() { + return Err("not colour-filter sensor data".into()); + } + let (w, h) = (raw.width, raw.height); + if w < 4 || h < 4 || d.len() < w.saturating_mul(h) { + return Err("sensor data shorter than its size".into()); + } + let black = f64::from(raw.black.values.iter().sum::<f32>() / raw.black.values.len().max(1) as f32).max(0.0); + let white = f64::from(raw.white.first().copied().unwrap_or(((1u32 << raw.bits.min(16)) - 1) as f32)).max(black + 1.0); + let at = |x: usize, y: usize| f64::from(d.get(y * w + x).copied().unwrap_or(0)); + let bands = [ + band((0..w.min(MAX_BAND)).map(|x| line(h, |i| at(x, i))), black, white), + band((0..w.min(MAX_BAND)).map(|k| line(h, |i| at(w - 1 - k, i))), black, white), + band((0..h.min(MAX_BAND)).map(|y| line(w, |i| at(i, y))), black, white), + band((0..h.min(MAX_BAND)).map(|k| line(w, |i| at(i, h - 1 - k))), black, white), + ]; + // the decoder's crop is relative to the active area: in sensor-data coordinates here + let a = raw.active_area; + let c = lightcraft_raw::Rect::new(a.x + raw.crop.x, a.y + raw.crop.y, raw.crop.width, raw.crop.height); + let frame = frame(&bytes, d, w, h); + let beyond = frame.as_ref().ok().filter(|f| f.clean()).map(|f| { + let ((fx, fy), (fw, fh)) = (f.whole(), f.jpeg); + let kinds = |lines: Vec<Line>| { + lines.iter().fold(Beyond::default(), |mut b, l| { + match kind(l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => b.picture += 1, + } + b + }) + }; + let col = |x: usize| line(h, |i| at(x, i)); + let row = |y: usize| line(w, |i| at(i, y)); + [ + kinds((c.x..fx.min(c.x + c.width)).map(col).collect()), + kinds(((fx + fw).max(c.x)..c.x + c.width).map(col).collect()), + kinds((c.y..fy.min(c.y + c.height)).map(row).collect()), + kinds(((fy + fh).max(c.y)..c.y + c.height).map(row).collect()), + ] + }); + Ok(Measured { + model: raw.metadata.model.clone().unwrap_or_default().trim().to_string(), + size: (w, h), + bits: raw.bits, + black, + white, + crop: (c.x, c.y, c.width, c.height), + bands, + frame, + beyond, + }) +} + +/// Statistics of one line of sensor samples: mean, |even − odd| mean, spread, neighbour noise (same colour, 2 apart) +/// and the number of sample pairs. +struct Line { + mean: f64, + pattern: f64, + spread: f64, + noise: f64, + pairs: f64, +} + +fn line(n: usize, at: impl Fn(usize) -> f64) -> Line { + let stride = ((n / 512).max(1)).div_ceil(2) * 2; + let (mut k, mut even, mut odd, mut var, mut noise) = (0f64, 0f64, 0f64, 0f64, 0f64); + let idx: Vec<usize> = (0..n.saturating_sub(2)).step_by(stride).collect(); + for &i in &idx { + even += at(i); + odd += at(i + 1); + k += 1.0; + } + let (even, odd) = (even / k.max(1.0), odd / k.max(1.0)); + for &i in &idx { + var += (at(i) - even).powi(2); + noise += (at(i + 2) - at(i)).powi(2) / 2.0; + } + Line { mean: (even + odd) / 2.0, pattern: (even - odd).abs(), spread: (var / k.max(1.0)).sqrt(), noise: (noise / k.max(1.0)).sqrt(), pairs: k } +} + +enum Kind { + Dark, + Masked, + Picture, +} + +fn kind(l: &Line, black: f64, white: f64) -> Kind { + let range = white - black; + let slack = white / 4096.0; + if l.mean < black + range * 0.01 { + Kind::Dark + } else if l.mean > black + range * 0.02 + && l.mean < white * 0.25 + // patternless within the noise, or with a small fixed offset between the CFA phases (D90 / D5000 filler) + && (l.pattern < 4.0 * l.noise * (2.0 / l.pairs.max(1.0)).sqrt() + slack || l.pattern < white * 0.004) + && l.spread < 1.5 * l.noise + slack + && l.spread < white * 0.004 + { + Kind::Masked + } else { + Kind::Picture + } +} + +fn band(lines: impl Iterator<Item = Line>, black: f64, white: f64) -> Band { + let mut b = Band::default(); + for l in lines { + match kind(&l, black, white) { + Kind::Dark => b.dark += 1, + Kind::Masked => b.masked += 1, + Kind::Picture => break, + } + } + b +} + +fn frame(bytes: &[u8], d: &[u16], w: usize, h: usize) -> Result<Frame, String> { + let jpeg = lightcraft_raw::embedded_preview(bytes).ok_or("no embedded preview")?; + let jpeg = lightcraft_codecs::decode(&jpeg, DecodeOptions::default()).map_err(|e| e.to_string())?; + let (jw, jh) = (jpeg.image.width, jpeg.image.height); + if jw > w || jh > h || jw * 10 < w * 9 || jh * 10 < h * 9 { + return Err(format!("the largest preview ({jw}×{jh}) is not a full-size JPEG of the {w}×{h} data")); + } + // half-size luminance, square-rooted so the linear raw and the JPEG weigh their tones alike + let (rw, rh) = (w / 2, h / 2); + let raw_y: Vec<f64> = (0..rh * rw) + .map(|i| { + let (x, y) = (2 * (i % rw), 2 * (i / rw)); + let s: f64 = + [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)].iter().map(|&(x, y)| f64::from(d.get(y * w + x).copied().unwrap_or(0))).sum(); + s.sqrt() + }) + .collect(); + let (pw, ph) = (jw / 2, jh / 2); + let px = &jpeg.image.data; + let jpg_y: Vec<f64> = (0..ph * pw) + .map(|i| { + let (x, y) = (2 * (i % pw), 2 * (i / pw)); + let s: f64 = [(x, y), (x + 1, y), (x, y + 1), (x + 1, y + 1)] + .iter() + .map(|&(x, y)| px.get(y * jw + x).map_or(0.0, |p| f64::from(0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2]).max(0.0))) + .sum(); + s.sqrt() + }) + .collect(); + let (sx, sy) = ((rw - pw).min(MAX_SEARCH), (rh - ph).min(MAX_SEARCH)); + let grad = |img: &[f64], stride: usize, x: usize, y: usize| { + let at = |x: usize, y: usize| img.get(y * stride + x).copied().unwrap_or(0.0); + (at(x + 1, y) - at(x, y)).hypot(at(x, y + 1) - at(x, y)) + }; + let normalise = |v: Vec<f64>| { + let n = v.len().max(1) as f64; + let mean = v.iter().sum::<f64>() / n; + let sd = (v.iter().map(|a| (a - mean).powi(2)).sum::<f64>() / n).sqrt().max(1e-12); + v.into_iter().map(|a| (a - mean) / sd).collect::<Vec<f64>>() + }; + // gradient magnitudes on every `step`-th half-size pixel, normalised, and the correlation at one offset + let sample = |step: usize| { + let points: Vec<(usize, usize)> = + (0..ph.saturating_sub(1)).step_by(step).flat_map(|y| (0..pw.saturating_sub(1)).step_by(step).map(move |x| (x, y))).collect(); + let gj = normalise(points.iter().map(|&(x, y)| grad(&jpg_y, pw, x, y)).collect()); + (points, gj) + }; + let score = |(points, gj): &(Vec<(usize, usize)>, Vec<f64>), dx: usize, dy: usize| { + let gr = normalise(points.iter().map(|&(x, y)| grad(&raw_y, rw, x + dx, y + dy)).collect()); + gr.iter().zip(gj).map(|(a, b)| a * b).sum::<f64>() / gj.len().max(1) as f64 + }; + let fine = sample(2); + // every offset when the window is small (every Nikon body); else every 4th on sparser points, then each offset + // around the best of those + let mut scores = vec![vec![f64::NEG_INFINITY; sx + 1]; sy + 1]; + let mut coarse_best = f64::NEG_INFINITY; + let (mut cx, mut cy) = (0, 0); + let window = if (sx + 1) * (sy + 1) <= FULL_SEARCH { + (0..=sx, 0..=sy) + } else { + let coarse = sample(8); + for dy in (0..=sy).step_by(4) { + for dx in (0..=sx).step_by(4) { + let v = score(&coarse, dx, dy); + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = v; + } + if v > coarse_best { + (coarse_best, cx, cy) = (v, dx, dy); + } + } + } + (cx.saturating_sub(5)..=(cx + 5).min(sx), cy.saturating_sub(5)..=(cy + 5).min(sy)) + }; + for dy in window.1.clone() { + for dx in window.0.clone() { + if let Some(s) = scores.get_mut(dy).and_then(|r| r.get_mut(dx)) { + *s = score(&fine, dx, dy); + } + } + } + let at = |dx: usize, dy: usize| scores.get(dy).and_then(|r| r.get(dx)).copied().unwrap_or(f64::NEG_INFINITY); + let (mut bx, mut by) = (0, 0); + for dy in 0..=sy { + for dx in 0..=sx { + if at(dx, dy) > at(bx, by) { + (bx, by) = (dx, dy); + } + } + } + let best = at(bx, by); + let runner_up = (0..=sy) + .flat_map(|dy| (0..=sx).map(move |dx| (dx, dy))) + .filter(|&(dx, dy)| dx.abs_diff(bx) > 1 || dy.abs_diff(by) > 1) + .map(|(dx, dy)| at(dx, dy)) + .fold(f64::NEG_INFINITY, f64::max); + // parabola through the peak and its neighbours (none at the window's edge) + let refine = |a: f64, b: f64, c: f64| { + let k = a - 2.0 * b + c; + if a.is_finite() && c.is_finite() && k < 0.0 { (0.5 * (a - c) / k).clamp(-0.5, 0.5) } else { 0.0 } + }; + let fx = if bx > 0 { refine(at(bx - 1, by), best, at(bx + 1, by)) } else { 0.0 }; + let fy = if by > 0 { refine(at(bx, by - 1), best, at(bx, by + 1)) } else { 0.0 }; + Ok(Frame { + jpeg: (jw, jh), + at: (2.0 * (bx as f64 + fx), 2.0 * (by as f64 + fy)), + score: best, + margin: if runner_up.is_finite() { best - runner_up } else { best }, + }) +} diff --git a/crates/engine/examples/sync_scan_bench.rs b/crates/engine/examples/sync_scan_bench.rs new file mode 100644 index 000000000..3b9bc51c1 --- /dev/null +++ b/crates/engine/examples/sync_scan_bench.rs @@ -0,0 +1,47 @@ +//! What a Synchronize Folder scan costs on a folder whose photos are all in the library and +//! unchanged: the walk, and the check of every photo and its sidecar. Reads the folder; writes +//! nothing there, and probes no file (the library is made from the folder's own listing). +//! +//! `cargo run --release -p lightcraft-engine --example sync_scan_bench -- <folder>` +use std::time::Instant; + +use lightcraft_catalog::{Op, Photo, Source}; +use lightcraft_engine::Session; +use lightcraft_engine::sync::{SyncInput, SyncProgress, scan_with}; + +fn main() { + let Some(folder) = std::env::args().nth(1) else { + eprintln!("usage: sync_scan_bench <folder>"); + return; + }; + let mut s = Session::new(); + let t = Instant::now(); + let files = lightcraft_engine::import::expand(std::slice::from_ref(&folder), None); + println!("{} files listed in {:.1} s (the library is made of them)", files.len(), t.elapsed().as_secs_f64()); + for f in &files { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(f).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + let p = Photo::new(id, Source::File { path: f.clone() }, &name, "JPEG", 6, 4, "2026-01-01T00:00:00"); + if s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).is_err() { + eprintln!("could not add {name}"); + } + } + for run in 0..2 { + let input = match SyncInput::new(&mut s, &folder, true) { + Ok(input) => input, + Err(e) => { + eprintln!("{e}"); + return; + } + }; + let t = Instant::now(); + let changes = scan_with(input, &SyncProgress::default()); + println!( + "run {run}: scan {:.1} s ({} new, {} missing, {} with sidecar news)", + t.elapsed().as_secs_f64(), + changes.new.len(), + changes.missing.len(), + changes.metadata.len() + ); + } +} diff --git a/crates/engine/src/activity.rs b/crates/engine/src/activity.rs index 6fc618039..147d20f3a 100644 --- a/crates/engine/src/activity.rs +++ b/crates/engine/src/activity.rs @@ -73,7 +73,7 @@ impl Unit { pub struct TaskInfo { pub id: u64, /// A stable id: `import`, `export`, `scan`, `previews`, `smartPreviews`, `lightroom`, `merge`, `download`, - /// `faces`, `denoise`, `findMissing`, `applePhotos`. + /// `faces`, `denoise`, `findMissing`, `applePhotos`, `cache`, `focus`. pub kind: &'static str, /// English; the frontend translates it. pub label: String, diff --git a/crates/engine/src/camera_preview.rs b/crates/engine/src/camera_preview.rs index c4824997c..614c58c60 100644 --- a/crates/engine/src/camera_preview.rs +++ b/crates/engine/src/camera_preview.rs @@ -30,7 +30,11 @@ use lightcraft_raw::{ /// states, for every make (#778). /// 4: a tone curve from black-and-white camera JPEGs (#577). /// 5: dark JPEG luminance pairs for the camera toe (#1307). -pub const LOOK_VERSION: u32 = 5; +/// 6: the ILCA-99M2 profile. +/// 7: the sensor proxy follows the file's lens warp when the camera JPEG is lens-corrected. +/// 8: the bundled NIKON Z 6_2 profile. +/// 9: the bundled NIKON Z 8 and NIKON D5300 profiles. +pub const LOOK_VERSION: u32 = 9; #[derive(Clone, Debug)] pub(crate) struct CameraLook { @@ -63,6 +67,7 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { | RawFormat::Pef | RawFormat::Srw | RawFormat::Orf + | RawFormat::Erf ) } @@ -74,8 +79,42 @@ pub(crate) fn file_local_look(format: RawFormat) -> bool { /// white (a monochrome picture style) has no colour to fit, but still gives its tone curve: with the camera profile's /// colour in [`fit_preview`], else on top of the spectral matrices or the fallback ([`monochrome_look`]). Returns the /// as-shot white, the camera transform at it and the fitted look. -pub(crate) fn starting_colour(raw: &mut RawImage, bytes: &[u8]) -> (Xy, CameraTransform, Option<CameraLook>) { - starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t), |raw, t| monochrome_look(raw, bytes, t)) +/// `lens` is the file's own lens-correction warp ([`lens_geometry`], read before the caller removes it from +/// `OpcodeList3`): the camera applied it to its JPEG, so the sensor proxy is warped the same way before pairing. +/// `embedded` is the file's embedded lens vignetting, for the DNG tone fit. +pub(crate) fn starting_colour( + raw: &mut RawImage, + bytes: &[u8], + lens: Option<&LensGeometry>, + embedded: Option<&lightcraft_develop::EmbeddedLens>, +) -> (Xy, CameraTransform, Option<CameraLook>) { + let (xy, t, look) = starting_colour_with(raw, |raw, t| fit_preview(raw, bytes, t, lens), |raw, t| monochrome_look(raw, bytes, t, lens)); + if look.is_none() && dng_tone_eligible(raw, &t) { + let look = fit_dng_tone(raw, bytes, &t, embedded); + return (xy, t, look); + } + (xy, t, look) +} + +/// Whether the profile's tone curve is absent or the identity (within 0.005 over the range). +fn identity_tone_curve(curve: Option<&lightcraft_raw::profile::ToneCurve>) -> bool { + curve.is_none_or(|c| { + (0..=32).all(|i| { + let x = i as f32 / 32.0; + (c.eval(x) - x).abs() < 0.005 + }) + }) +} + +/// A DNG with its own colour model whose tone is not (yet) defined by the file: a matrix, no +/// non-identity `ProfileToneCurve`, no `LookTable` (a look table is the file's own rendering). +pub(crate) fn dng_tone_eligible(raw: &RawImage, t: &CameraTransform) -> bool { + let p = &raw.color.profile; + raw.format == RawFormat::Dng + && !t.matrix_is_fallback + && identity_tone_curve(p.tone_curve.as_ref()) + && p.look_table.is_none() + && p.gain_table_map.is_none() } /// [`starting_colour`] with the JPEG fit (the look, and whether the JPEG is black and white) and the black-and-white @@ -119,8 +158,29 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C if lightcraft_raw::color::has_matrix(&raw.color) { return None; } - let (_, t, look) = starting_colour(raw, bytes); - dng_colour(&raw.color, &t, look.as_ref()) + // the white the source shows, decided before `starting_colour` may fill in measured matrices + let scale = crate::files::WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); + let white = scale.dng_white(&raw.color, lightcraft_raw::color::as_shot_white_xy(raw)); + // (a merge frame arrives without its lens warp: `lightcraft_merge` removes it before calling) + let (_, t, look) = starting_colour(raw, bytes, None, None); + dng_colour(&raw.color, &white, &t, look.as_ref()) +} + +/// Give a raw about to be written as a DNG of its own (Export as DNG, Convert / Copy as DNG) the colour it opens +/// with, when the file has no colour matrices (issue #1745: a Z 9 NEF's DNG had no `ColorMatrix1`, and other raw +/// converters refused it). The lens corrections stay in the DNG; only the fit sees the raw without them, as when +/// the photo opens. +pub(crate) fn with_starting_colour(raw: &mut RawImage, bytes: &[u8]) { + if lightcraft_raw::color::has_matrix(&raw.color) { + return; + } + let list3 = raw.opcodes.list3.clone(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let tags = starting_colour_tags(raw, bytes); + raw.opcodes.list3 = list3; + if let Some(tags) = tags { + raw.color = tags; + } } /// DNG tags that render white-balanced camera RGB at its as-shot white as `t` with `look` on top does: one forward @@ -129,25 +189,38 @@ pub(crate) fn starting_colour_tags(raw: &mut RawImage, bytes: &[u8]) -> Option<C /// doesn't keep each pixel's luminance as the fitted one does ([`HueSat`]), and the chroma curve has no tag; on two /// CC0 CR2s (EOS 550D, 40D) an HDR merge of three copies renders within mean CIE76 ΔE 1.07 and 0.47 of the photo /// (1.47 and 0.66 without the table; 26 and 23 with no colour tags at all). +/// The colour matrices declare `declared`, the white balance the source shows ([`crate::files::WbScaleOf::dng_white`]), +/// so the DNG opens at the source's As Shot temperature and tint and a white-balance edit starts from it. Matrices +/// derived from `forward` put the declared white wherever the look and the generic camera model took it: ARW merges +/// opened at about 7000 K / +115 where the ARWs show 4800 K / +8, and any white-balance edit turned them green. +/// `forward` is used only with the as-shot neutral, so the render at As Shot doesn't depend on them. /// `None` when the white balance or the matrix can't give tags. -fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> { +pub(crate) fn dng_colour(tags: &ColorData, declared: &crate::files::DngWhite, t: &CameraTransform, look: Option<&CameraLook>) -> Option<ColorData> { let m = look.map_or(t.matrix, |l| l.matrix.mul(&t.matrix)); // the DNG model renders the camera's white at unit brightness: what a look does to white's brightness goes // into the baseline exposure let white = m.apply([1.0; 3]); let k = (white[0] + white[1] + white[2]) / 3.0; - if !(k.is_finite() && k > 1e-6) || !t.wb.iter().all(|w| w.is_finite() && *w > 0.0) { + if !(k.is_finite() && k > 1e-6 && t.wb.iter().all(|w| w.is_finite() && *w > 0.0)) { return None; } let m = Mat3(m.0.map(|r| r.map(|v| v / k))); let forward = bradford(D65, D50).mul(&REC2020.to_xyz()).mul(&m); let wb = t.wb.map(f64::from); let neutral = wb.map(|w| wb.iter().cloned().fold(f64::MAX, f64::min) / w); - let camera_to_xyz = bradford(D50, t.white_xy).mul(&forward).mul(&Mat3::diag(1.0 / neutral[0], 1.0 / neutral[1], 1.0 / neutral[2])); + let (illuminant, color_matrix) = match declared { + crate::files::DngWhite::Matrices(c) if lightcraft_raw::color::has_matrix(c) => (c.illuminant, c.color_matrix), + crate::files::DngWhite::Matrices(_) => ([21, 0], [Some(declaring(t.white_xy, neutral)?), None]), + crate::files::DngWhite::At(xy) => ([21, 0], [Some(declaring(*xy, neutral)?), None]), + }; + if !color_matrix.iter().flatten().all(|m| m.0.iter().flatten().all(|v| v.is_finite())) { + return None; + } Some(ColorData { - illuminant: [21, 0], - color_matrix: [Some(camera_to_xyz.inverse()?), None], - forward_matrix: [Some(forward), None], + illuminant, + color_matrix, + // a second colour matrix comes with a second forward matrix (DNG spec): the same one, fitted at one white + forward_matrix: [Some(forward), color_matrix[1].map(|_| forward)], camera_calibration: [None, None], analog_balance: None, as_shot_neutral: Some(neutral), @@ -163,6 +236,17 @@ fn dng_colour(tags: &ColorData, t: &CameraTransform, look: Option<&CameraLook>) }) } +/// An XYZ → camera colour matrix under which camera `neutral` is white `xy`: the generic camera model, scaled per +/// channel. `None` when the model gives no positive neutral there. +fn declaring(xy: Xy, neutral: [f64; 3]) -> Option<Mat3> { + let g = lightcraft_raw::color::xyz_to_camera(&ColorData::default(), xy); + let n = g.apply(xy.to_xyz()); + if !n.iter().all(|v| v.is_finite() && *v > 1e-9) { + return None; + } + Some(Mat3::diag(neutral[0] / n[0], neutral[1] / n[1], neutral[2] / n[2]).mul(&g)) +} + /// A camera tone curve as a DNG `ProfileToneCurve` (linear in, linear out, 1 = white): sampled densely over the /// 12 stops below white, the range the curve is read back over (`files::dng_tone_curve`). fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> { @@ -176,11 +260,11 @@ fn dng_tone_curve(tone: &CameraTone) -> Option<ToneCurve> { } /// The look fitted to the file's camera JPEG, and whether that JPEG is black and white ([`monochrome`]). -pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> (Option<CameraLook>, bool) { +pub(crate) fn fit_preview(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> (Option<CameraLook>, bool) { if !transform.matrix_is_fallback || !file_local_look(raw.format) { return (None, false); } - let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY) else { return (None, false) }; + let Some((sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, 0.0, lens) else { return (None, false) }; let black_and_white = monochrome(&reference); // A camera profile pooled from many photos knows colours this photo shows too little of; // try its colour first and fit tone/chroma per photo (picture styles vary). @@ -337,18 +421,183 @@ fn at_most(a: &CameraTone, b: &CameraTone) -> bool { /// decodes at full size, so a 66.5 MP full-frame ILCE-7RM6 JPEG transiently needs ~200 MB (3 B/px); /// 128 MP also admits 100 MP medium-format previews. const FIT_PREVIEW_MAX_PIXELS: u64 = 128_000_000; + +/// A DNG's tone and chroma curves fitted to its own camera JPEG ([`dng_tone_eligible`]); the file's colour model +/// (matrix, profile tables, white balance) stays as it is: the proxy goes through the file's tables and the given +/// colour of the search is the identity. Same held-out acceptance gates as [`fit_look`]; the preview must be near +/// the raw's size, show the same picture ([`structure_agrees`]), and the curve must not clip more highlights than +/// the plain render ([`clips_more`]). +fn fit_dng_tone(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&lightcraft_develop::EmbeddedLens>) -> Option<CameraLook> { + // Logged under LIGHTCRAFT_PROFILE so a corpus run can count the outcome per gate. + let refuse = |why: &str| { + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE refused {why}"); + } + None + }; + let Some((mut sensor, reference, clipped)) = proxies(raw, bytes, transform, PROXY, MIN_PREVIEW_FRACTION, None) else { return refuse("preview") }; + // As the loader renders it: the profile's tables around the baseline exposure. + let xy = lightcraft_raw::color::as_shot_white_xy(raw); + if let Some(tables) = lightcraft_raw::profile::ProfileTables::new(&raw.color.profile, lightcraft_raw::color::illuminant_weight(&raw.color, xy)) { + let gain = 2f32.powf(transform.baseline_exposure as f32); + sensor.map_in_place(|p| tables.apply(p.map(|v| v / gain), gain)); + } + correct_vignetting(&mut sensor, lens, raw.orientation); + if (sensor.width, sensor.height) != (reference.width, reference.height) { + return refuse("preview"); + } + if !structure_agrees(&sensor, &reference) { + return refuse("structure"); + } + let unclipped = without_clipped(&sensor, &clipped); + let fit_on = unclipped.as_ref().unwrap_or(&sensor); + let colour = Some((Mat3::IDENTITY, None)); + let look = TONE_FITS.iter().find_map(|&tone| { + [false, true] + .into_iter() + .find_map(|away_from_edges| fit_attempt(fit_on, &reference, &colour, Attempt { tone, profile: true, away_from_edges, dark: true })) + }); + let Some(look) = look else { return refuse("fit") }; + let plain = match raw.color.profile.tone_curve.as_ref().and_then(crate::files::dng_tone_curve) { + Some(curve) => ToneMap::camera(&curve, 0.0, 0.0, 0.0), + None => ToneMap::new(0.0, 0.0, 0.0), + }; + let fitted = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); + if clips_more(&sensor, &reference, &fitted, &plain) { + return refuse("clip"); + } + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("DNGTONE accepted"); + } + Some(look) +} + +/// Whether the fitted tone map puts clearly more of the photo's proxy pixels at or near white than both the plain +/// render and the camera's own preview do ([`CLIP_TOLERANCE`]): a fit that clips what the camera clips too is the +/// camera's look, one that clips what neither does loses highlight detail. +fn clips_more(sensor: &Rgb32f, reference: &Rgb32f, fitted: &ToneMap, plain: &ToneMap) -> bool { + let near_white = |tone: &ToneMap| { + let n = sensor.data.iter().filter(|p| p.iter().all(|v| v.is_finite()) && tone.apply(luminance_2020(p.map(|v| v.max(0.0)))) >= 0.98).count(); + n as f64 / sensor.data.len().max(1) as f64 + }; + let preview = reference.data.iter().filter(|p| luminance_2020(**p) >= 0.98).count() as f64 / reference.data.len().max(1) as f64; + near_white(fitted) > near_white(plain).max(preview) + CLIP_TOLERANCE +} + +/// Multiply the sensor proxy (sensor orientation, default crop) by the embedded vignetting correction, the gain the +/// pipeline applies to the render (`optics::embedded_vignette_gain`; `lens` is in the oriented, cropped frame): the +/// camera JPEG has it applied, so a tone fitted without it brightens the render's corners twice. +fn correct_vignetting(sensor: &mut Rgb32f, lens: Option<&lightcraft_develop::EmbeddedLens>, orientation: lightcraft_geom::Orientation) { + let Some(vignette) = lens.and_then(|l| l.vignette) else { return }; + let (w, h) = (sensor.width as f64, sensor.height as f64); + let (ow, oh) = if orientation.swaps_axes() { (h, w) } else { (w, h) }; + let width = sensor.width.max(1); + for (i, p) in sensor.data.iter_mut().enumerate() { + let (x, y) = orientation.map((i % width) as f64 + 0.5, (i / width) as f64 + 0.5, w, h); + let gain = lightcraft_pipeline::optics::embedded_vignette_gain(&vignette, lightcraft_geom::Point::new(x, y), ow, oh) as f32; + *p = p.map(|v| v * gain); + } +} + +/// The embedded preview's long edge must be at least this share of the raw's (and 384 px): a DNG whose preview is a +/// small thumbnail is not fitted. +const MIN_PREVIEW_FRACTION: f64 = 0.9; + +/// Lowest preview luminance and channel value a tone-only fit trains on (the other fits use 0.015 and 0.004): a dark +/// night scene has most of its pixels below 0.015, and a curve fitted without them overshoots its shadows. +const DARK_FLOOR: (f32, f32) = (0.004, 0.001); + +/// Share of the proxy by which the fitted curve may clip more highlights than the plain render and the camera's +/// preview (chosen on the CC0 and user DNGs from the clipped share of their 1440 px renders: it refuses the fits +/// that clip about 1% more than the camera does and passes the ones that clip like the camera). +const CLIP_TOLERANCE: f64 = 0.001; + +/// Log-luminance correlation (5 × 5 box blurred) the unshifted proxies must reach. +const MIN_STRUCTURE: f64 = 0.85; + +/// Whether the sensor proxy and the camera JPEG proxy show the same picture in the same place: their log luminance +/// correlates by at least [`MIN_STRUCTURE`] unshifted, and no shift of more than one proxy pixel (within ±3) +/// correlates better. +fn structure_agrees(sensor: &Rgb32f, reference: &Rgb32f) -> bool { + let (w, h) = (sensor.width, sensor.height); + if (w, h) != (reference.width, reference.height) || w < 16 || h < 16 || sensor.data.len() != w * h || reference.data.len() != w * h { + return false; + } + let log = |img: &Rgb32f| -> Vec<f64> { + let l: Vec<f64> = + img.data.iter().map(|p| f64::from(luminance_2020(p.map(|v| if v.is_finite() { v.max(0.0) } else { 0.0 }))).max(1e-4).ln()).collect(); + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let (mut sum, mut n) = (0.0, 0.0); + for yy in y.saturating_sub(2)..(y + 3).min(h) { + for xx in x.saturating_sub(2)..(x + 3).min(w) { + if let Some(v) = l.get(yy * w + xx) { + sum += v; + n += 1.0; + } + } + } + sum / f64::max(n, 1.0) + }) + .collect() + }; + let (a, b) = (log(sensor), log(reference)); + const M: usize = 3; + let corr = |dx: isize, dy: isize| -> f64 { + let (mut n, mut sa, mut sb, mut saa, mut sbb, mut sab) = (0.0, 0.0, 0.0, 0.0, 0.0, 0.0); + for y in M..h - M { + for x in M..w - M { + let (xx, yy) = (x.saturating_add_signed(dx), y.saturating_add_signed(dy)); + let (Some(&u), Some(&v)) = (a.get(y * w + x), b.get(yy * w + xx)) else { continue }; + n += 1.0; + sa += u; + sb += v; + saa += u * u; + sbb += v * v; + sab += u * v; + } + } + let var = (saa - sa * sa / n) * (sbb - sb * sb / n); + if n < 2.0 || var <= 1e-12 { 0.0 } else { (sab - sa * sb / n) / var.sqrt() } + }; + let at_zero = corr(0, 0); + let (mut best, mut shift) = (at_zero, (0isize, 0isize)); + for dy in -(M as isize)..=M as isize { + for dx in -(M as isize)..=M as isize { + let c = corr(dx, dy); + if c > best + 1e-9 { + (best, shift) = (c, (dx, dy)); + } + } + } + at_zero >= MIN_STRUCTURE && shift.0.abs() <= 1 && shift.1.abs() <= 1 +} + /// Same-size proxies of the sensor (white-balanced, baseline exposure, through `transform`'s /// matrix: the generic camera ≈ sRGB model) and of the file's embedded camera JPEG (linear Rec.2020), /// and per proxy pixel whether any sensor sample it covers is clipped. -fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usize) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { +fn proxies( + raw: &RawImage, + bytes: &[u8], + transform: &CameraTransform, + size: usize, + min_fraction: f64, + lens: Option<&LensGeometry>, +) -> Option<(Rgb32f, Rgb32f, Vec<bool>)> { let edge = (2 * size).max(384) as u32; let decoded = crate::files::decode_raw_preview( bytes, lightcraft_codecs::DecodeOptions { max_size: Some((edge, edge)), max_pixels: FIT_PREVIEW_MAX_PIXELS }, None, )?; - let mut reference = decoded.to_working(); let (a, crop) = (raw.active_area, raw.crop.clipped(raw.active_area.width, raw.active_area.height)); + // a preview below `min_fraction` of the crop's long edge (or under 384 px) is refused; 0 = any + if min_fraction > 0.0 && (decoded.source_width.max(decoded.source_height) as f64) < (min_fraction * crop.width.max(crop.height) as f64).max(384.0) + { + return None; + } + let mut reference = decoded.to_working(); if crop.width == 0 || crop.height == 0 || reference.width == 0 || reference.height == 0 { return None; } @@ -370,6 +619,12 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz let k = (crop.width.max(crop.height).div_ceil(edge as usize).max(2)).div_ceil(2) * 2; let mut sensor = sensor_proxy(raw, k, edge as usize)?; let mut clipped = clip_mask(&sensor); + // The camera may have corrected its JPEG with the lens warp the file carries (Nikon Z lenses always, at up to 9 % + // zoom; other cameras when their correction setting is on): the sensor moved into that geometry is a candidate, + // kept below when it lines up with the JPEG better than the uncorrected one. + let warped = lens + .filter(|_| raw.format != RawFormat::Cr3) + .and_then(|l| Some((into_lens_geometry(&sensor, raw, k, l)?, into_lens_geometry(&clipped, raw, k, l)?))); let gain = 2f32.powf(transform.baseline_exposure as f32); let to_working = |p: [f32; 3]| transform.matrix.apply_f32(std::array::from_fn(|i| p[i] * transform.wb[i] * gain)); if raw.format == RawFormat::Cr3 { @@ -383,7 +638,15 @@ fn proxies(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, size: usiz (sensor, clipped) = align_cr3_framing(sensor, clipped, &reference); } let mut sensor = fit(&sensor, size, size, Filter::Box); - let reference = reference_at(&reference, &sensor); + let mut reference_small = reference_at(&reference, &sensor); + if let Some((warped, warped_clipped)) = warped { + let candidate = fit(&warped, size, size, Filter::Box); + let candidate_reference = reference_at(&reference, &candidate); + if alignment(&candidate, &candidate_reference) > alignment(&sensor, &reference_small) { + (sensor, reference_small, clipped) = (candidate, candidate_reference, warped_clipped); + } + } + let reference = reference_small; // A proxy pixel is clipped when any sample it averages is (outside the CR3 framing the mask is // NaN: those pixels are left out of the pairs anyway). Resized to exactly the proxy's size, like // the reference, so the mask pairs pixel for pixel. @@ -545,16 +808,16 @@ fn align_cr3_framing(sensor: Rgb32f, clipped: Rgb32f, reference: &Rgb32f) -> (Rg /// Colour training pairs of one raw for a camera profile: white-balanced camera RGB (with the /// baseline exposure) → its camera JPEG (linear Rec.2020). `None` for formats with their own /// colour matrices, other files or unusable previews. -pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8]) -> Option<Vec<([f64; 3], [f64; 3])>> { +pub(crate) fn profile_pairs(raw: &RawImage, bytes: &[u8], lens: Option<&LensGeometry>) -> Option<Vec<([f64; 3], [f64; 3])>> { if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { return None; } let transform = lightcraft_raw::color::camera_transform(raw, lightcraft_raw::color::as_shot_white_xy(raw)); - let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, &transform, PROFILE_PROXY, 0.0, lens)?; let to_camera = transform.matrix.inverse()?; // clipped sensor pixels don't record the scene's colour (see `fit_look`) let sensor = without_clipped(&sensor, &clipped).unwrap_or(sensor); - let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None)?; + let (pairs, _) = collect_pairs(&sensor, &reference, 0.05, None, false)?; Some(pairs.into_iter().map(|(x, y)| (to_camera.apply(x), y)).collect()) } @@ -565,6 +828,102 @@ pub(crate) fn fit_profile(pairs: &[([f64; 3], [f64; 3])]) -> Option<(Mat3, Optio Some((matrix, fit_hue_sat(pairs, &matrix))) } +/// A raw's lens-correction warp, as its camera applied it to the embedded JPEG: the first `WarpRectilinear` of +/// `OpcodeList3` (its green plane; the in-file corrections LightCraft reads have no tangential terms). `None` without +/// one, or for a DNG 1.6 `WarpRectilinear2` chain (phone DNGs, which carry colour matrices and are not fitted). +#[derive(Clone, Copy, Debug, PartialEq)] +pub(crate) struct LensGeometry { + /// `kr0..kr3`: source radius = r * (kr0 + kr1 r^2 + kr2 r^4 + kr3 r^6), r over the farthest corner distance. + k: [f64; 4], + /// The optical centre, relative to the active area (as the opcode has it). + center: [f64; 2], +} + +pub(crate) fn lens_geometry(list3: &[lightcraft_raw::Opcode]) -> Option<LensGeometry> { + use lightcraft_raw::Opcode; + let ops: Vec<&Opcode> = lightcraft_raw::opcodes::applied_ops(list3).collect(); + if ops.iter().any(|op| matches!(op, Opcode::WarpRectilinear2 { .. })) { + return None; + } + ops.into_iter().find_map(|op| match op { + Opcode::WarpRectilinear { planes, center } => { + let p = planes.get(1).or(planes.first())?; + let k = [p[0], p[1], p[2], p[3]]; + (k.iter().chain(center).all(|v| v.is_finite()) && (0.0..=1.0).contains(&center[0]) && (0.0..=1.0).contains(&center[1])) + .then_some(LensGeometry { k, center: *center }) + } + _ => None, + }) +} + +/// How well two same-size proxies line up: the correlation of their high-passed log luminance over the pixels both +/// have (a tone curve or colour change leaves it alone, a geometric offset lowers it). `-1` without enough pixels. +fn alignment(a: &Rgb32f, b: &Rgb32f) -> f64 { + let (w, h) = (a.width, a.height); + if (b.width, b.height) != (w, h) || w < 3 || h < 3 { + return -1.0; + } + let lum = |img: &Rgb32f| img.data.iter().map(|p| (luminance_2020(*p).max(1e-4) as f64).ln()).collect::<Vec<f64>>(); + let high = |l: &[f64]| -> Vec<f64> { + (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + if x == 0 || y == 0 || x + 1 == w || y + 1 == h { + return f64::NAN; + } + let around = [i - w - 1, i - w, i - w + 1, i - 1, i + 1, i + w - 1, i + w, i + w + 1]; + l[i] - around.iter().map(|&j| l[j]).sum::<f64>() / 8.0 + }) + .collect() + }; + let (ha, hb) = (high(&lum(a)), high(&lum(b))); + let pairs: Vec<(f64, f64)> = ha.into_iter().zip(hb).filter(|(x, y)| x.is_finite() && y.is_finite()).collect(); + if pairs.len() < 64 { + return -1.0; + } + let n = pairs.len() as f64; + let (ma, mb) = (pairs.iter().map(|p| p.0).sum::<f64>() / n, pairs.iter().map(|p| p.1).sum::<f64>() / n); + let cov: f64 = pairs.iter().map(|p| (p.0 - ma) * (p.1 - mb)).sum(); + let (va, vb): (f64, f64) = (pairs.iter().map(|p| (p.0 - ma).powi(2)).sum(), pairs.iter().map(|p| (p.1 - mb).powi(2)).sum()); + let r = cov / (va * vb).sqrt().max(1e-12); + if r.is_finite() { r } else { -1.0 } +} + +/// A `k`-binned sensor proxy of `raw` ([`sensor_proxy`]: the default crop, or the active area when the crop is +/// smaller than a block, at `1/k`) moved into the camera JPEG's geometry: each pixel samples the proxy where `lens` +/// maps it, and is NaN where that falls outside the proxy (the pairs leave it out). `None` for a proxy of another +/// size (the demosaiced fallback of linear raws). +fn into_lens_geometry(img: &Rgb32f, raw: &RawImage, k: usize, lens: &LensGeometry) -> Option<Rgb32f> { + let a = raw.active_area; + let c = raw.crop.clipped(a.width, a.height); + let c = if c.width < k || c.height < k { lightcraft_raw::Rect::new(0, 0, a.width, a.height) } else { c }; + let (w, h) = (img.width, img.height); + if k == 0 || w != c.width / k || h != c.height / k || w == 0 || h == 0 || a.width < 2 || a.height < 2 { + return None; + } + let (aw, ah) = (a.width as f64 - 1.0, a.height as f64 - 1.0); + let (cx, cy) = (lens.center[0] * aw, lens.center[1] * ah); + let m = [(0.0, 0.0), (aw, 0.0), (0.0, ah), (aw, ah)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9); + let [k0, k1, k2, k3] = lens.k; + let kf = k as f64; + let (x0, y0) = (c.x as f64, c.y as f64); + let mut out = Rgb32f::new(w, h); + for (i, px) in out.data.iter_mut().enumerate() { + // the proxy pixel's centre in active-area pixel-index coordinates, mapped to the sensor + let (ox, oy) = (x0 + ((i % w) as f64 + 0.5) * kf - 0.5, y0 + ((i / w) as f64 + 0.5) * kf - 0.5); + let (dx, dy) = ((ox - cx) / m, (oy - cy) / m); + let r2 = dx * dx + dy * dy; + let f = k0 + r2 * (k1 + r2 * (k2 + r2 * k3)); + let (sx, sy) = ((cx + m * dx * f + 0.5 - x0) / kf, (cy + m * dy * f + 0.5 - y0) / kf); + *px = if sx >= 0.5 && sy >= 0.5 && sx <= w as f64 - 0.5 && sy <= h as f64 - 0.5 { + img.sample_bilinear(sx as f32, sy as f32) + } else { + [f32::NAN; 3] + }; + } + Some(out) +} + fn sensor_proxy(raw: &RawImage, k: usize, edge: usize) -> Option<Rgb32f> { Some(match raw.develop_binned(k, SENSOR_CLIP).ok()? { Some(sensor) => sensor, @@ -626,7 +985,7 @@ fn local_contrast(image: &Rgb32f, i: usize) -> f32 { /// are left out: there a small geometric mismatch between the raw and its JPEG pairs unrelated /// colours (see [`EDGE_CONTRAST`]). type Pairs = Vec<([f64; 3], [f64; 3])>; -fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>) -> Option<(Pairs, Pairs)> { +fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limit: Option<f32>, dark: bool) -> Option<(Pairs, Pairs)> { if (sensor.width, sensor.height) != (reference.width, reference.height) || sensor.data.len() != reference.data.len() { return None; } @@ -645,7 +1004,8 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi if (0.015..=1.0).contains(&y) { bright.push((input.map(f64::from), output.map(f64::from))); } - if !colour_pair(*input, *output) { + let floors = if dark { DARK_FLOOR } else { (0.015, 0.004) }; + if !colour_pair_above(*input, *output, floors) { continue; } let min = output.iter().copied().fold(f32::INFINITY, f32::min); @@ -658,9 +1018,15 @@ fn collect_pairs(sensor: &Rgb32f, reference: &Rgb32f, min_chroma: f32, edge_limi /// The original colour-pair eligibility, also used to preserve its held-out split in tone fitting. fn colour_pair(input: [f32; 3], output: [f32; 3]) -> bool { + colour_pair_above(input, output, (0.015, 0.004)) +} + +/// [`colour_pair`] with its lowest preview luminance and channel value given (`floors`: the usual 0.015 and 0.004, or +/// [`DARK_FLOOR`] for the tone-only fit of a DNG). +fn colour_pair_above(input: [f32; 3], output: [f32; 3], (ylo, clo): (f32, f32)) -> bool { input.iter().all(|v| v.is_finite() && *v > 0.001 && *v < 1.5) - && output.iter().all(|v| *v > 0.004 && *v < 0.98) - && (0.015..0.85).contains(&luminance_2020(output)) + && output.iter().all(|v| *v > clo && *v < 0.98) + && (ylo..0.85).contains(&luminance_2020(output)) } /// Fit tone independently of colour eligibility when enough dark JPEG signal is available. @@ -797,6 +1163,8 @@ struct Attempt { tone: ToneFit, profile: bool, away_from_edges: bool, + /// Train on the dark end of the preview too ([`DARK_FLOOR`]): the tone-only fit of a DNG. + dark: bool, } /// The photo's look and the attempt that produced it: its own matrix (with and without a @@ -813,7 +1181,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt order.iter().find_map(|&tone| { profiles.iter().find_map(|&profile| { [false, true].into_iter().find_map(|away_from_edges| { - let attempt = Attempt { tone, profile, away_from_edges }; + let attempt = Attempt { tone, profile, away_from_edges, dark: false }; fit_attempt(sensor, reference, &colour, attempt).map(|look| (look, attempt)) }) }) @@ -822,7 +1190,7 @@ fn search_ordered(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opt fn fit_attempt(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, attempt: Attempt) -> Option<CameraLook> { let colour = if attempt.profile { colour.clone() } else { None }; - fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone) + fit_pairs_on(sensor, reference, colour, attempt.away_from_edges.then_some(EDGE_CONTRAST), attempt.tone, attempt.dark) } fn fit_pairs_on( @@ -831,10 +1199,11 @@ fn fit_pairs_on( colour: Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>, tone_fit: ToneFit, + dark: bool, ) -> Option<CameraLook> { // A known colour model needs enough signal for tone fitting, not a scene rich enough to // learn a new colour matrix. Still reject monochrome references. - let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, if colour.is_some() { 0.005 } else { 0.05 }, edge_limit, dark)?; let candidates = match colour { Some(given) => vec![given], None => { @@ -978,7 +1347,7 @@ fn fit_partial(sensor: &Rgb32f, reference: &Rgb32f) -> Option<CameraLook> { fn fit_partial_on(sensor: &Rgb32f, reference: &Rgb32f, edge_limit: Option<f32>) -> Option<CameraLook> { // as for a known colour model: enough signal for the tone, but no monochrome reference - let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit)?; + let (pairs, bright) = collect_pairs(sensor, reference, 0.005, edge_limit, false)?; let related = rank_correlation(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1052,11 +1421,11 @@ fn monochrome(reference: &Rgb32f) -> bool { /// `transform` is the colour the photo gets otherwise (the spectral matrices or the fallback's); the look keeps it and /// adds the tone curve alone ([`fit_tone_only`]), as [`fit_look`] tries the photo's own colour after a profile's. The /// chroma curve stays neutral: the JPEG has no colourfulness to learn. -fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform) -> Option<CameraLook> { +fn monochrome_look(raw: &RawImage, bytes: &[u8], transform: &CameraTransform, lens: Option<&LensGeometry>) -> Option<CameraLook> { if !file_local_look(raw.format) { return None; } - let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY)?; + let (sensor, reference, clipped) = proxies(raw, bytes, transform, PROXY, 0.0, lens)?; if !monochrome(&reference) { return None; } @@ -1087,7 +1456,7 @@ fn fit_tone_only(sensor: &Rgb32f, reference: &Rgb32f, colour: Option<(Mat3, Opti fn fit_tone_only_on(sensor: &Rgb32f, reference: &Rgb32f, colour: &Option<(Mat3, Option<HsvTable>)>, edge_limit: Option<f32>) -> Option<CameraLook> { // every pair counts: the JPEG has no colour to require - let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit)?; + let (pairs, _) = collect_pairs(sensor, reference, f32::NEG_INFINITY, edge_limit, false)?; let related = rank_correlation_ties(&pairs); if related < PARTIAL_MIN_RANK_CORRELATION { if lightcraft_pipeline::profiling() { @@ -1181,6 +1550,42 @@ impl<'a> HueSat<'a> { } std::array::from_fn(|i| rgb[i] + (out[i] * before / after - rgb[i]) * w) } + + /// [`Self::apply`] to `N` pixels at once (`rgb[channel][lane]`), bit for bit, through + /// [`HsvTable::apply_lanes`], with the guards as selects. + #[inline] + pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let mul = |m: &[[f32; 3]; 3], v: &[[f32; N]; 3]| { + let mut out = [[0.0f32; N]; 3]; + for (row, out) in m.iter().zip(&mut out) { + for l in 0..N { + out[l] = row[0] * v[0][l] + row[1] * v[1][l] + row[2] * v[2][l]; + } + } + out + }; + let out = mul(&self.from, &self.table.apply_lanes(mul(&self.to, &rgb))); + let mut restored = rgb; + for l in 0..N { + let (input, out) = ([rgb[0][l], rgb[1][l], rgb[2][l]], [out[0][l], out[1][l], out[2][l]]); + let (before, after) = (luminance_2020(input), luminance_2020(out)); + let untouched = (before <= 0.0) | (after <= 0.0) | !(out[0].is_finite() & out[1].is_finite() & out[2].is_finite()); + let w = restore_weight(after / before); + for (c, restored) in restored.iter_mut().enumerate() { + let full = out[c] * before / after; + let blend = input[c] + (full - input[c]) * w; + // (`w >= 1` and `w <= 0` exclude each other) + restored[l] = if untouched | (w <= 0.0) { + input[c] + } else if w >= 1.0 { + full + } else { + blend + }; + } + } + restored + } } /// HSV hue (degrees) and saturation; `None` for black or non-finite colours. @@ -1490,7 +1895,7 @@ mod tests { fn colour_holdouts_stay_out_of_the_wider_tone_training() { let (sensor, reference) = steep_toe_scene(); let fit = |reference: &Rgb32f| { - let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None).unwrap(); + let (pairs, _) = collect_pairs(&sensor, reference, 0.005, None, false).unwrap(); fit_tone_with_shadows(ToneFit::Quantile, &pairs, &sensor, reference, None, |x| x).unwrap() }; let expected = fit(&reference); @@ -1547,7 +1952,7 @@ mod tests { *dst = [y * slope; 3]; assert_eq!(colour_pair(*src, *dst), i >= 600); } - let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None).unwrap(); + let (pairs, _) = collect_pairs(&sensor, &reference, f32::NEG_INFINITY, None, false).unwrap(); let original = fit_tone(pairs.iter().enumerate().filter(|(i, _)| i % 3 != 0).map(|(_, (x, y))| (luma(*x), luma(*y))).collect()).unwrap(); // Both sample sets start at a multiple of three, so this split matches // the helper's separate colour and extra-sample indices. @@ -1657,8 +2062,10 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata::default(), + lens_metadata: Default::default(), }; let proxy = sensor_proxy(&raw, 2, 384).unwrap(); assert_eq!((proxy.width, proxy.height), (32, 32)); @@ -1848,7 +2255,7 @@ mod tests { let mut pool = Vec::new(); for seed in 0..4 { let (sensor, reference) = photo(seed, 2.0 + seed as f32 * 0.5); - pool.extend(collect_pairs(&sensor, &reference, 0.05, None).unwrap().0); + pool.extend(collect_pairs(&sensor, &reference, 0.05, None, false).unwrap().0); } let (matrix, _) = fit_profile(&pool).unwrap(); // the camera's colours (luminance-normalised: the tone curve sets brightness) @@ -1952,6 +2359,51 @@ mod tests { assert!(worst < 20.0, "a near-black pixel came out {worst}× its largest channel"); } + /// `apply_lanes` gives `apply`'s bits for any colour: random, near-black noise (the restore's blend), grey, + /// negative, tiny, above 1, huge, NaN and infinite components; on a fitted look's shape and a saturating table. + #[test] + fn hue_sat_lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x2545_f491_4f6c_dd1du64; + let mut next = move || { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + seed + }; + let unit = |r: u64, i: usize| ((r >> (16 * i)) & 0xffff) as f32 / 65535.0; + let fitted = (0..72 * 5 * 5) + .map(|_| { + let r = next(); + [unit(r, 0) * 60.0 - 30.0, 0.5 + unit(r, 1), 1.0] + }) + .collect(); + let fitted = HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data: fitted, srgb_value: true }; + let saturating = HsvTable { hue_divisions: 4, sat_divisions: 2, val_divisions: 1, data: vec![[10.0, 1.5, 1.0]; 8], srgb_value: false }; + let special = [0.0, -0.0, 1e-30, -1e-6, 0.002, 0.5, 1.0, 3.0, 1e30, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]; + let mut colours = Vec::new(); + for a in special { + for b in special { + colours.extend(special.map(|c| [a, b, c])); + } + } + for _ in 0..60_000 { + let r = next(); + let (offset, scale) = [(0.15, 1.3), (0.15e-3, 1.3e-3), (0.6, 5.0), (-0.002, 0.008)][(r >> 62) as usize]; + colours.push(std::array::from_fn(|i| unit(r, i) * scale - offset)); + } + for table in [fitted, saturating] { + let hue_sat = HueSat::new(&table).unwrap(); + for chunk in colours.chunks(16) { + let rgb: [[f32; 16]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.0, |p| p[c]))); + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], hue_sat.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + } + #[test] fn hue_sat_restore_weight_blends_smoothly_between_the_thresholds() { assert_eq!(restore_weight(0.0), 0.0); @@ -2000,11 +2452,100 @@ mod tests { color, wb_multipliers: Some([2.0, 1.0, 1.5]), linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: lightcraft_raw::Metadata { make: Some(make.to_owned()), model: Some(model.to_owned()), ..Default::default() }, + lens_metadata: Default::default(), } } + /// The lens warp the colour fit follows is the first `WarpRectilinear`'s green plane; no warp, a DNG 1.6 chain + /// (phone DNGs, which carry colour matrices), a centre outside the area or a non-finite term give none. + #[test] + fn lens_geometry_reads_the_first_rectilinear_warp() { + use lightcraft_raw::Opcode; + let warp = |k0: f64, c: [f64; 2]| Opcode::WarpRectilinear { + planes: vec![[9.0, 0.0, 0.0, 0.0, 0.0, 0.0], [k0, 0.1, -0.02, 0.003, 0.0, 0.0], [9.0, 0.0, 0.0, 0.0, 0.0, 0.0]], + center: c, + }; + assert_eq!(lens_geometry(&[warp(0.97, [0.5, 0.4])]), Some(LensGeometry { k: [0.97, 0.1, -0.02, 0.003], center: [0.5, 0.4] })); + let single = Opcode::WarpRectilinear { planes: vec![[1.02, 0.0, 0.0, 0.0, 0.0, 0.0]], center: [0.5, 0.5] }; + assert_eq!(lens_geometry(std::slice::from_ref(&single)).map(|g| g.k[0]), Some(1.02)); + assert!(lens_geometry(&[]).is_none()); + assert!(lens_geometry(&[warp(1.0, [1.5, 0.5])]).is_none()); + assert!(lens_geometry(&[warp(f64::NAN, [0.5, 0.5])]).is_none()); + let mut p = [0.0; lightcraft_raw::opcodes::WR2_LEN]; + p[0] = 1.0; + p[18] = 1e6; + let chain = Opcode::WarpRectilinear2 { planes: vec![p], center: [0.5, 0.5], reciprocal: false, optional: false }; + assert!(lens_geometry(&[chain, single]).is_none()); + } + + /// A binned sensor proxy moved into the camera JPEG's geometry: the identity warp leaves it as it is, a warp + /// whose source radius is 0.8 of the output's samples nearer the centre, output pixels whose source falls outside + /// the proxy are NaN (left out of the pairs), and a proxy of another size is left alone. + #[test] + fn the_sensor_proxy_follows_the_cameras_lens_warp() { + let mut raw = raw_of(RawFormat::Nef, "NIKON CORPORATION", "NIKON Z 6_2", Default::default()); + raw.active_area = lightcraft_raw::Rect::new(0, 0, 64, 48); + raw.crop = raw.active_area; + // a 1/4 proxy whose pixels hold their own coordinates + let mut img = Rgb32f::new(16, 12); + for (i, p) in img.data.iter_mut().enumerate() { + *p = [(i % 16) as f32, (i / 16) as f32, 1.0]; + } + let geometry = |k0: f64| LensGeometry { k: [k0, 0.0, 0.0, 0.0], center: [0.5, 0.5] }; + let same = into_lens_geometry(&img, &raw, 4, &geometry(1.0)).unwrap(); + assert!(same.data.iter().zip(&img.data).all(|(a, b)| (0..3).all(|c| (a[c] - b[c]).abs() < 1e-4))); + // the corner pixel's centre (1.5, 1.5) maps to (31.5 - 0.8 * 30, 23.5 - 0.8 * 22) = (7.5, 5.9) on the sensor + let zoomed = into_lens_geometry(&img, &raw, 4, &geometry(0.8)).unwrap(); + assert!((zoomed.data[0][0] - 1.5).abs() < 1e-4 && (zoomed.data[0][1] - 1.1).abs() < 1e-4, "{:?}", zoomed.data[0]); + let outside = into_lens_geometry(&img, &raw, 4, &geometry(1.3)).unwrap(); + assert!(outside.data[0][0].is_nan() && outside.data[6 * 16 + 8][0].is_finite()); + assert!(into_lens_geometry(&Rgb32f::new(15, 12), &raw, 4, &geometry(1.0)).is_none()); + } + + /// A raw with an in-file lens correction (Fujifilm, Panasonic, the A7R IVA, Nikon) may have a lens-corrected + /// camera JPEG: the proxies the fit pairs line up with it at least as well as the uncorrected sensor, on every + /// corpus file the colour fit uses that carries such a warp, and better on those whose JPEG is corrected. + #[test] + fn corpus_lens_corrected_jpegs_pair_with_the_warped_sensor() { + let dir = std::env::var_os("LIGHTCRAFT_CORPUS") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) + .join("raw"); + let Ok(entries) = std::fs::read_dir(&dir) else { + eprintln!("skip: no corpus at {}", dir.display()); + return; + }; + let mut paths: Vec<_> = entries.flatten().map(|e| e.path()).collect(); + paths.sort(); + let (mut checked, mut gain) = (0, 0.0); + for path in paths { + let Ok(bytes) = std::fs::read(&path) else { continue }; + let Ok(mut raw) = lightcraft_raw::decode(&bytes) else { continue }; + let Some(lens) = lens_geometry(&raw.opcodes.list3) else { continue }; + if !file_local_look(raw.format) || lightcraft_raw::color::has_matrix(&raw.color) { + continue; + } + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + let (Some((plain, reference, _)), Some((chosen, chosen_reference, _))) = + (proxies(&raw, &bytes, &t, PROXY, 0.0, None), proxies(&raw, &bytes, &t, PROXY, 0.0, Some(&lens))) + else { + continue; + }; + let (before, after) = (alignment(&plain, &reference), alignment(&chosen, &chosen_reference)); + eprintln!("{}: alignment {before:.3} -> {after:.3}", path.display()); + // (chosen on camera RGB, measured after the colour transform: a near tie may flip by a little) + assert!(after >= before - 0.01, "{}: {before:.3} -> {after:.3}", path.display()); + gain += after - before; + checked += 1; + } + eprintln!("{checked} corpus raws with an in-file lens warp, mean gain {:.3}", gain / checked.max(1) as f64); + assert!(checked == 0 || gain > 0.0); + } + /// Step 1: a file's own colour matrices win over everything, including a spectral row for its model; no JPEG fit /// is tried. #[test] @@ -2052,7 +2593,7 @@ mod tests { assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M4").unwrap(); let mut raw = raw_of(RawFormat::Arw, "SONY", "ILCE-7M4", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); assert!(look.is_none()); assert_eq!(raw.color.color_matrix, camera.color_matrix.map(Some)); assert!(!t.matrix_is_fallback); @@ -2111,13 +2652,13 @@ mod tests { #[test] fn a_camera_outside_the_table_keeps_the_fallback() { let mut raw = raw_of(RawFormat::Cr2, "Canon", "Canon EOS 7D", Default::default()); - let (_, t, look) = starting_colour(&mut raw, &[]); + let (_, t, look) = starting_colour(&mut raw, &[], None, None); assert!(look.is_none() && t.matrix_is_fallback); assert_eq!(raw.color, lightcraft_raw::ColorData::default()); // without a model there is no table row let mut raw = raw_of(RawFormat::Cr2, "Canon", "", Default::default()); raw.metadata.model = None; - assert!(starting_colour(&mut raw, &[]).1.matrix_is_fallback); + assert!(starting_colour(&mut raw, &[], None, None).1.matrix_is_fallback); } /// The colour precedence on public raws (each skipped without its corpus file): a photo whose JPEG fit is @@ -2132,17 +2673,18 @@ mod tests { .join("raw"); for name in ["arw-sony-a7m4-14bit.arw", "cr2-canon-5d3.cr2", "nef-nikon-d5100-lossless.nef", "cr2-canon-7d.cr2", "dng-canon-5d3-16bit.dng"] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let mut raw = lightcraft_raw::decode(&bytes).unwrap(); + let lens = lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let own = lightcraft_raw::color::has_matrix(&raw.color); let (make, model) = (raw.metadata.make.as_deref(), raw.metadata.model.as_deref().unwrap_or_default()); let measured = lightcraft_raw::spectral::find(make, model).is_some(); let covered = measured || lightcraft_raw::kelvin_presets::covers(make, model); let before = raw.color.clone(); - let (_, t, fitted) = starting_colour(&mut raw, &bytes); + let (_, t, fitted) = starting_colour(&mut raw, &bytes, lens.as_ref(), None); let spectral = !own && fitted.is_none() && measured; eprintln!("{name}: own matrices {own}, JPEG fit {}, spectral {spectral}", fitted.is_some()); assert_eq!(raw.color != before, spectral, "{name}"); @@ -2191,6 +2733,7 @@ mod tests { RawFormat::Pef, RawFormat::Srw, RawFormat::Orf, + RawFormat::Erf, ]; assert!(local.into_iter().all(file_local_look)); // DNG carries its own colour model @@ -2235,13 +2778,13 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/cr3-canon-r100-raw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = match lightcraft_raw::decode(&bytes) { Ok(raw) => raw, Err(lightcraft_raw::RawError::Unsupported(why)) => { - eprintln!("skip CR3 sensor decoder: {why}"); + lightcraft_testkit::skip!(Feature, "CR3 sensor decoder: {why}"); return; } Err(error) => panic!("CR3 corpus failed to decode: {error}"), @@ -2249,7 +2792,7 @@ mod tests { assert_eq!(raw.format, RawFormat::Cr3); assert_eq!(raw.info().developed_size(), (6000, 4000)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0; + let look = fit_preview(&raw, &bytes, &transform, None).0; eprintln!("R100 guarded colour fit accepted: {}", look.is_some()); assert!(look.is_some(), "this R100 corpus photo has enough matching colour after camera-framing alignment"); let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2258,31 +2801,31 @@ mod tests { assert_eq!(info.camera_tone.is_some(), look.is_some()); } - /// A public ILCE-7CR sample (skipped without the corpus) takes its colour from the bundled ILCE-7CR profile and - /// passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the photo's - /// own fit (the fallback when a profile is rejected) would not. - #[test] - fn corpus_a7cr_uses_the_bundled_profile() { + /// A public sample of `model` (skipped without the corpus) takes its colour from the bundled profile for `model` + /// and passes the acceptance gates with it: the fitted look keeps the profile's matrix and table, which the + /// photo's own fit (the fallback when a profile is rejected) would not. + fn corpus_uses_the_bundled_profile(file: &str, model: &str) { let path = std::env::var_os("LIGHTCRAFT_CORPUS") .map(std::path::PathBuf::from) .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) - .join("raw/arw-sony-a7cr-lossless-l.arw"); + .join("raw") + .join(file); let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; } Err(e) => panic!("{}: {e}", path.display()), }; - let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == "ILCE-7CR").expect("ILCE-7CR is built in"); + let (_, json) = crate::camera_profiles::BUNDLED.iter().find(|(m, _)| *m == model).expect("the model is built in"); let bundled: crate::camera_profiles::CameraProfile = serde_json::from_str(json).unwrap(); - let used = crate::camera_profiles::get("ILCE-7CR").expect("ILCE-7CR profile"); - assert_eq!(*used, bundled, "a local ILCE-7CR profile overrides the built-in one; remove it to run this test"); + let used = crate::camera_profiles::get(model).expect("a profile for the model"); + assert_eq!(*used, bundled, "a local {model} profile overrides the built-in one; remove it to run this test"); let raw = lightcraft_raw::decode(&bytes).unwrap(); - assert_eq!(raw.metadata.model.as_deref(), Some("ILCE-7CR")); + assert_eq!(raw.metadata.model.as_deref(), Some(model)); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("fit accepted"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("fit accepted"); let expected = bundled.matrix().mul(&transform.matrix.inverse().unwrap()); for (row, want) in look.matrix.0.iter().zip(expected.0) { for (got, want) in row.iter().zip(want) { @@ -2292,6 +2835,28 @@ mod tests { assert_eq!(look.hue_sat, bundled.hue_sat, "the look's table is not the profile's"); } + #[test] + fn corpus_a7cr_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a7cr-lossless-l.arw", "ILCE-7CR"); + } + + #[test] + fn corpus_a99m2_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("arw-sony-a99m2.arw", "ILCA-99M2"); + } + + /// The public Z 6II NEF wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z6ii_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z6ii-lossless.nef", "NIKON Z 6_2"); + } + + /// The public Z 8 NEF (Z 14-24 f/2.8 S) wasn't one of the photos its bundled profile was fitted to. + #[test] + fn corpus_z8_uses_the_bundled_profile() { + corpus_uses_the_bundled_profile("nef-nikon-z8-lossless.nef", "NIKON Z 8"); + } + /// A public D7500 NEF (skipped without the corpus): its look is fitted to its own JPEG and white /// balance is relative to the as-shot look. #[test] @@ -2301,7 +2866,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d7500-lossless14.nef"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2321,7 +2886,7 @@ mod tests { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -2342,7 +2907,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/rw2-panasonic-fz1000m2-4x3.rw2"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (img, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2361,7 +2926,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/arw-sony-a7rm2-12bit-uncompressed.arw"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); @@ -2380,7 +2945,7 @@ mod tests { let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; let render = |m: &Mat3, wb: [f32; 3], ev: f64, c: [f64; 3]| m.apply(std::array::from_fn(|i| c[i] * wb[i] as f64)).map(|v| v * ev.exp2()); for look in [Some(&look), None] { - let dng = dng_colour(&ColorData::default(), &t, look).unwrap(); + let dng = dng_colour(&ColorData::default(), &crate::files::DngWhite::At(t.white_xy), &t, look).unwrap(); let back = camera_transform_of(&dng, as_shot_white_of(&dng, None)); for i in 0..3 { assert!((back.wb[i] - t.wb[i]).abs() < 1e-4 * t.wb[i], "{:?} vs {:?}", back.wb, t.wb); @@ -2405,6 +2970,82 @@ mod tests { } } + /// The merged DNG declares the white balance its source shows, also when the look's matrix doesn't keep white + /// neutral, as fitted looks often don't, and renders at As Shot as the source does. Real ARW merges declared their + /// colour matrix from the look: As Shot about 7000 K / +115 where the ARWs show 4800 K / +8. + #[test] + fn dng_colour_declares_the_white_balance_the_source_shows() { + use crate::files::DngWhite; + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of, wb_multipliers}; + let tags = ColorData::default(); + let as_shot = [2.5039f32, 1.0, 1.5078]; + let t = camera_transform_of(&tags, as_shot_white_of(&tags, Some(as_shot))); + let mut look = dro_test_look(&std::array::from_fn(|i| 0.005 * 1.2f32.powi(i as i32)), &|x| 0.95 * x / (x + 0.1)); + look.matrix = Mat3([[1.75, -0.4, -0.2], [-0.2, 1.35, -0.2], [0.05, -0.3, 1.3]]); + let tint = look.matrix.apply([1.0; 3]); + assert!((tint[0] - tint[1]).abs() > 0.1, "a look that tints white: {tint:?}"); + let renders_as_shot = |dng: &ColorData| { + let r = camera_transform_of(dng, as_shot_white_of(dng, None)); + assert!(r.wb.iter().zip(t.wb).all(|(a, b)| (a - b).abs() < 1e-4 * b), "{:?} vs {:?}", r.wb, t.wb); + let m = look.matrix.mul(&t.matrix); + for c in [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6]] { + let want = m.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()); + let got = r.matrix.apply(std::array::from_fn(|i| c[i] * r.wb[i] as f64)).map(|v| v * dng.baseline_exposure.exp2()); + assert!(want.iter().zip(got).all(|(a, b)| (a - b).abs() < 1e-4), "{c:?}: {want:?} vs {got:?}"); + } + }; + // a white the source's scale shows (Sony's presets, the relative scale's 6500 K / 0) + for xy in [lightcraft_color::cct::temp_tint_to_xy(5600.0, 3.0), lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)] { + let dng = dng_colour(&tags, &DngWhite::At(xy), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - xy.x).abs() < 1e-6 && (back.y - xy.y).abs() < 1e-6, "as shot {back:?}, shown {xy:?}"); + renders_as_shot(&dng); + } + // a camera with measured matrices (the ILCE-7M3): every white read through them, as for the source + let camera = lightcraft_raw::spectral::find(Some("SONY"), "ILCE-7M3").expect("a covered camera"); + let mut model = tags.clone(); + assert!(camera.fill(&mut model)); + let shown = lightcraft_raw::spectral::as_shot_white_through(camera, &tags, Some(as_shot)).unwrap(); + let dng = dng_colour(&tags, &DngWhite::Matrices(Box::new(model.clone())), &t, Some(&look)).unwrap(); + let back = as_shot_white_of(&dng, None); + assert!((back.x - shown.x).abs() < 1e-5 && (back.y - shown.y).abs() < 1e-5, "as shot {back:?}, shown {shown:?}"); + for w in [Xy::new(0.3127, 0.329), Xy::new(0.4476, 0.4074), Xy::new(0.3457, 0.3585)] { + let (a, b) = (wb_multipliers(&model, w), wb_multipliers(&dng, w)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-6 * a), "{w:?}: {a:?} vs {b:?}"); + } + renders_as_shot(&dng); + } + + /// Photo Merge gives a panorama the white balance of its frames' vendor multipliers (issue #1144). A raw without + /// colour matrices (CR2, NEF…) is merged with the tags above, whose neutral is that white balance already: taking + /// it from the multipliers again leaves the merged DNG rendering as the tags do. + #[test] + fn a_merges_white_balance_from_the_multipliers_is_the_one_the_starting_colour_tags_have() { + use lightcraft_raw::color::{as_shot_white_of, camera_transform_of}; + for (model, multipliers) in [("Canon EOS 6D Mark II", [2.0, 1.0, 1.5]), ("Canon EOS 600D", [1.9, 1.0, 1.7])] { + let mut raw = raw_of(RawFormat::Cr2, "Canon", model, Default::default()); + raw.wb_multipliers = Some(multipliers); + let tags = starting_colour_tags(&mut raw, &[]).expect("tags for a raw without colour matrices"); + let neutral = tags.as_shot_neutral.expect("the tags carry the as-shot white balance"); + // the reciprocal of the multipliers, up to the scale a neutral is given in + let scale = neutral[1] * multipliers[1] as f64; + for i in 0..3 { + assert!((neutral[i] * multipliers[i] as f64 - scale).abs() < 1e-3, "{model}: {neutral:?} vs 1 / {multipliers:?}"); + } + let mut merged = lightcraft_merge::FrameColor::Camera(Box::new(tags.clone())); + merged.set_as_shot_multipliers(multipliers); + let lightcraft_merge::FrameColor::Camera(merged) = merged else { panic!("camera colour") }; + let (a, b) = (camera_transform_of(&tags, as_shot_white_of(&tags, None)), camera_transform_of(&merged, as_shot_white_of(&merged, None))); + for i in 0..3 { + assert!((a.wb[i] - b.wb[i]).abs() < 1e-4 * a.wb[i], "{model}: {:?} vs {:?}", a.wb, b.wb); + } + for (ra, rb) in a.matrix.0.iter().zip(b.matrix.0) { + assert!(ra.iter().zip(rb).all(|(x, y)| (x - y).abs() < 1e-4), "{model}: {:?} vs {:?}", a.matrix, b.matrix); + } + assert_eq!(a.baseline_exposure, b.baseline_exposure); + } + } + /// A look with tone curve `f` at knots `xs` and a colour model and chroma curve to keep. fn dro_test_look(xs: &[f32; 32], f: &dyn Fn(f32) -> f32) -> CameraLook { CameraLook { @@ -2556,7 +3197,7 @@ mod tests { let bytes = match std::fs::read(&path) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } Err(e) => panic!("{}: {e}", path.display()), @@ -2565,9 +3206,9 @@ mod tests { let mut raw = lightcraft_raw::decode(&bytes).unwrap(); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let transform = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = fit_preview(&raw, &bytes, &transform).0.expect("a camera look"); + let look = fit_preview(&raw, &bytes, &transform, None).0.expect("a camera look"); // the look fitted to the JPEG, as before this change - let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY).unwrap(); + let (sensor, reference, clipped) = proxies(&raw, &bytes, &transform, PROXY, 0.0, None).unwrap(); let profile = raw.metadata.model.as_deref().and_then(crate::camera_profiles::get); let colour = profile.as_ref().and_then(|p| Some((p.matrix().mul(&transform.matrix.inverse()?), p.hue_sat.clone()))); let fitted = fit_look(&sensor, &reference, &clipped, colour).unwrap(); @@ -2863,7 +3504,7 @@ mod tests { assert_eq!((none.matrix.0, none.tone), (all.matrix.0, all.tone)); // without clipped pixels the refit keeps the accepted attempt (here: all pixels, own colour, quantile tone) let (_, attempt) = search_ordered(&sensor, &reference, None, TONE_FITS).unwrap(); - assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false }); + assert_eq!(attempt, Attempt { tone: ToneFit::Quantile, profile: false, away_from_edges: false, dark: false }); assert!(without_clipped(&sensor, &[true; 3]).is_none(), "a mask of another size is ignored"); } @@ -3126,8 +3767,10 @@ mod tests { color: ColorData::default(), wb_multipliers: Some([1.0; 3]), linearized: true, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: lightcraft_meta::Metadata { make: Some("SONY".into()), model: Some("ILCE-7M4".into()), ..Default::default() }, + lens_metadata: Default::default(), }; assert!(crate::camera_profiles::get("ILCE-7M4").is_some()); // the camera's rendering: a bright curve on the scene's luminance, as a grey (or tinted) 8-bit sRGB JPEG @@ -3155,12 +3798,12 @@ mod tests { tiff }; let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); - let look = monochrome_look(&raw, &file([1.0; 3]), &t).expect("a tone curve from the black-and-white JPEG"); + let look = monochrome_look(&raw, &file([1.0; 3]), &t, None).expect("a tone curve from the black-and-white JPEG"); assert!(look.matrix == Mat3::IDENTITY && look.hue_sat.is_none(), "on the transform's colour"); let curve = ToneMap::camera(&look.tone, 0.0, 0.0, 0.0); let mid = curve.apply(0.25); assert!((mid - (1.0 - (-0.75f32).exp())).abs() < 0.05, "the camera's curve at 0.25: {mid}"); - assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t).is_none(), "a tinted JPEG is not black and white"); + assert!(monochrome_look(&raw, &file([1.0, 0.9, 0.8]), &t, None).is_none(), "a tinted JPEG is not black and white"); } /// The black-and-white fit has the partial look's checks: a JPEG of another picture, or one the default curve @@ -3199,4 +3842,145 @@ mod tests { let (_, info) = crate::files::load_bytes(&bytes, 400).unwrap(); assert!(info.camera_tone.is_some(), "no camera look fitted"); } + + /// A smooth synthetic scene (sensor proxy) and its camera rendering, `dx` pixels to the right. + fn scene_pair(w: usize, h: usize, dx: usize) -> (Rgb32f, Rgb32f) { + let scene = |x: f32, y: f32| -> [f32; 3] { + let l = 0.03 + 0.4 * (0.5 + 0.5 * (x * 0.21).sin() * (y * 0.17).cos()) + 0.2 * ((x - 30.0).hypot(y - 20.0) < 12.0) as u8 as f32; + [l * (0.8 + 0.3 * (y / h as f32)), l, l * (1.1 - 0.4 * (x / w as f32))] + }; + let camera = |p: [f32; 3]| { + let y = luminance_2020(p); + p.map(|v| v * (1.0 - (-2.5 * y).exp()) / y) + }; + let mut sensor = Rgb32f::new(w, h); + let mut reference = sensor.clone(); + for y in 0..h { + for x in 0..w { + sensor.data[y * w + x] = scene(x as f32, y as f32); + reference.data[y * w + x] = camera(scene(x as f32 - dx as f32, y as f32)); + } + } + (sensor, reference) + } + + /// The structural gate accepts the same picture whatever its tone, and refuses a shifted, a broken (stripes), a + /// flat and a differently sized preview. + #[test] + fn dng_structure_gate_refuses_shifted_broken_and_unrelated_previews() { + let (sensor, reference) = scene_pair(96, 64, 0); + assert!(structure_agrees(&sensor, &reference)); + assert!(structure_agrees(&sensor, &scene_pair(96, 64, 1).1), "one proxy pixel (lens correction) is tolerated"); + assert!(!structure_agrees(&sensor, &scene_pair(96, 64, 3).1), "differently framed"); + let mut stripes = reference.clone(); + for (i, p) in stripes.data.iter_mut().enumerate() { + *p = if (i / 96) % 4 < 2 { [0.6, 0.1, 0.5] } else { [0.05, 0.4, 0.1] }; + } + assert!(!structure_agrees(&sensor, &stripes), "a broken preview"); + let mut flat = reference.clone(); + flat.data.fill([0.2; 3]); + assert!(!structure_agrees(&sensor, &flat)); + assert!(!structure_agrees(&sensor, &scene_pair(95, 64, 0).1)); + } + + /// With the file's colour given as the identity, only the tone is fitted: the colour stays and a camera that + /// renders mid-grey brighter than the default tone map is followed. + #[test] + fn dng_tone_fit_keeps_the_files_colour_and_follows_the_camera_tone() { + let (mut sensor, mut reference) = scene_pair(96, 64, 0); + for image in [&mut sensor, &mut reference] { + for p in image.data.iter_mut().step_by(5) { + *p = [p[0] * 1.6, p[1], p[2] * 0.5]; + } + } + let colour = Some((Mat3::IDENTITY, None)); + let look = fit_attempt(&sensor, &reference, &colour, Attempt { tone: ToneFit::Quantile, profile: true, away_from_edges: false, dark: true }) + .expect("tone fitted"); + assert_eq!(look.matrix, Mat3::IDENTITY); + assert!(look.hue_sat.is_none()); + let (fitted, default) = (ToneMap::camera(&look.tone, 0.0, 0.0, 0.0), ToneMap::new(0.0, 0.0, 0.0)); + let target = 1.0 - (-2.5f32 * 0.18).exp(); + assert!((fitted.apply(0.18) - target).abs() < 0.03, "{} vs {target}", fitted.apply(0.18)); + assert!(fitted.apply(0.18) > default.apply(0.18) + 0.05, "{} vs default {}", fitted.apply(0.18), default.apply(0.18)); + assert!(!clips_more(&sensor, &reference, &fitted, &fitted), "a tone never clips more than itself"); + } + + /// The highlight gate compares the share of near-white pixels of the fitted tone with the plain render's and the + /// camera preview's, in one direction only: clipping what the camera clips is not refused. + #[test] + fn dng_highlight_gate_compares_near_white_shares() { + let mut sensor = Rgb32f::new(32, 32); + for (i, p) in sensor.data.iter_mut().enumerate() { + *p = [0.02 + 1.5 * i as f32 / 1024.0; 3]; + } + let (bright, dark) = (ToneMap::new(0.0, 1.0, 0.0), ToneMap::new(0.0, -1.0, 0.0)); + let mut white = Rgb32f::new(32, 32); + white.data.iter_mut().for_each(|p| *p = [1.0; 3]); + let mut black = Rgb32f::new(32, 32); + black.data.iter_mut().for_each(|p| *p = [0.1; 3]); + assert!(clips_more(&sensor, &black, &bright, &dark), "clips more than the plain render and a preview without white"); + assert!(!clips_more(&sensor, &black, &dark, &bright)); + assert!(!clips_more(&sensor, &black, &bright, &bright)); + assert!(!clips_more(&sensor, &white, &bright, &dark), "the camera preview clips as much"); + } + + /// A dark scene's shadows below the usual training floor still reach the tone-only fit. + #[test] + fn dng_tone_fit_trains_on_dark_pixels() { + let mut sensor = Rgb32f::new(64, 64); + let mut reference = Rgb32f::new(64, 64); + for (i, (s, r)) in sensor.data.iter_mut().zip(reference.data.iter_mut()).enumerate() { + let v = 0.0005 + 0.004 * (i % 64) as f32 / 64.0 + if i % 7 == 0 { 0.2 } else { 0.0 }; + *s = [v, v * 1.1, v * 0.9]; + *r = [(v * 3.0).min(0.9), (v * 3.3).min(0.9), (v * 2.7).min(0.9)]; + } + let usual = collect_pairs(&sensor, &reference, 0.0, None, false).map_or(0, |p| p.0.len()); + let dark = collect_pairs(&sensor, &reference, 0.0, None, true).map_or(0, |p| p.0.len()); + assert!(dark > usual, "{dark} vs {usual}"); + } + + /// The embedded vignetting correction reaches the sensor proxy as the pipeline applies it to the render. + #[test] + fn dng_sensor_proxy_gets_the_embedded_vignetting_correction() { + use lightcraft_develop::{EmbeddedLens, EmbeddedVignette}; + let lens = EmbeddedLens { + vignette: Some(EmbeddedVignette { k: [1.0, 0.0, 0.0, 0.0, 0.0], center: lightcraft_geom::Point::new(0.5, 0.5), radius: 0.6 }), + ..Default::default() + }; + let mut proxy = Rgb32f::new(96, 64); + proxy.data.fill([0.2; 3]); + correct_vignetting(&mut proxy, Some(&lens), lightcraft_geom::Orientation::Normal); + let (centre, corner) = (proxy.data[32 * 96 + 48][1] / 0.2, proxy.data[0][1] / 0.2); + assert!((centre - 1.0).abs() < 0.01 && (1.9..2.0).contains(&corner), "centre {centre}, corner {corner}"); + let mut plain = Rgb32f::new(96, 64); + plain.data.fill([0.2; 3]); + correct_vignetting(&mut plain, None, lightcraft_geom::Orientation::Normal); + assert!(plain.data.iter().all(|p| *p == [0.2; 3])); + } + + /// Which DNGs get a tone fitted to their camera JPEG: a colour matrix, a tone curve that is absent or the + /// identity, no look table; and no other raw format. + #[test] + fn dng_tone_gates_follow_the_files_own_tags() { + use lightcraft_raw::profile::{HsvTable, ToneCurve}; + let own = Mat3([[0.6, -0.1, -0.05], [-0.4, 1.3, 0.1], [-0.1, 0.2, 0.7]]); + let color = lightcraft_raw::ColorData { illuminant: [21, 0], color_matrix: [Some(own), None], ..Default::default() }; + let eligible = |format: RawFormat, color: lightcraft_raw::ColorData| { + let raw = raw_of(format, "Make", "Model", color); + let t = lightcraft_raw::color::camera_transform(&raw, lightcraft_raw::color::as_shot_white_xy(&raw)); + dng_tone_eligible(&raw, &t) + }; + assert!(eligible(RawFormat::Dng, color.clone())); + assert!(!eligible(RawFormat::Cr2, color.clone()), "other formats keep their own path"); + assert!(!eligible(RawFormat::Dng, Default::default()), "no matrix, no colour to keep"); + let mut identity = color.clone(); + identity.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.5, 0.5, 1.0, 1.0]); + assert!(identity.profile.tone_curve.is_some() && eligible(RawFormat::Dng, identity), "an identity ProfileToneCurve is no tone"); + let mut curved = color.clone(); + curved.profile.tone_curve = ToneCurve::from_tag(&[0.0, 0.0, 0.18, 0.4, 1.0, 1.0]); + assert!(curved.profile.tone_curve.is_some() && !eligible(RawFormat::Dng, curved), "the file's own tone curve is kept"); + let mut looked = color; + looked.profile.look_table = HsvTable::from_tags(&[1, 2, 1], &[0.0, 1.0, 1.0, 0.0, 1.0, 1.0], 0); + assert!(looked.profile.look_table.is_some() && !eligible(RawFormat::Dng, looked), "a look table is the file's own rendering"); + } } diff --git a/crates/engine/src/camera_profiles.rs b/crates/engine/src/camera_profiles.rs index 6c35c2a45..d13023b6e 100644 --- a/crates/engine/src/camera_profiles.rs +++ b/crates/engine/src/camera_profiles.rs @@ -25,8 +25,12 @@ const MAX_FILE: u64 = 4 << 20; /// Profiles built into LightCraft (`assets/camera-profiles/`, see `assets/ATTRIBUTION.md`): /// `(model, JSON)`. pub const BUNDLED: &[(&str, &str)] = &[ + ("ILCA-99M2", include_str!("../../../assets/camera-profiles/ILCA-99M2.json")), ("ILCE-7CR", include_str!("../../../assets/camera-profiles/ILCE-7CR.json")), ("ILCE-7M4", include_str!("../../../assets/camera-profiles/ILCE-7M4.json")), + ("NIKON D5300", include_str!("../../../assets/camera-profiles/NIKON_D5300.json")), + ("NIKON Z 6_2", include_str!("../../../assets/camera-profiles/NIKON_Z_6_2.json")), + ("NIKON Z 8", include_str!("../../../assets/camera-profiles/NIKON_Z_8.json")), ("X-H2S", include_str!("../../../assets/camera-profiles/X-H2S.json")), ("X-T4", include_str!("../../../assets/camera-profiles/X-T4.json")), ]; @@ -194,10 +198,12 @@ impl Pool { /// NEF or RAF without colour matrices, no usable camera JPEG, too little colour). pub fn add(&mut self, bytes: &[u8]) -> Result<Option<String>, String> { let mut raw = lightcraft_raw::decode(bytes).map_err(|e| e.to_string())?; - // colour only: geometric lens corrections would stop the sensor proxy from binning + // colour only: geometric lens corrections would stop the sensor proxy from binning (the pairs still follow + // the warp the camera applied to its JPEG) + let lens = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); let Some(model) = raw.metadata.model.as_deref().map(str::trim).filter(|m| !m.is_empty()) else { return Ok(None) }; - let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes) else { return Ok(None) }; + let Some(pairs) = crate::camera_preview::profile_pairs(&raw, bytes, lens.as_ref()) else { return Ok(None) }; let step = pairs.len().div_ceil(PAIRS_PER_FILE).max(1); let entry = self.models.entry(model.to_owned()).or_default(); entry.0 += 1; diff --git a/crates/engine/src/cmd/browse.rs b/crates/engine/src/cmd/browse.rs index a681ec14b..655965688 100644 --- a/crates/engine/src/cmd/browse.rs +++ b/crates/engine/src/cmd/browse.rs @@ -6,11 +6,14 @@ //! Each browse records when the folders it listed were browsed. Untouched records of folders //! not browsed for [`Session::forget_local_days`] are forgotten (when the library opens, or with //! `library.forgetLocal`); see [`lightcraft_catalog::local`] for what "untouched" means. Files -//! and sidecars on disk are never touched, and browsing the folder again brings them back. +//! and sidecars on disk are never touched, and browsing the folder again brings them back. A +//! browse also forgets at once the untouched records of the folder's files that are gone from +//! the disk (deleted or moved by another app), so the folder shows what is on disk. use std::path::Path; -use lightcraft_catalog::{Op, PhotoId}; +use lightcraft_catalog::query::in_folder; +use lightcraft_catalog::{Op, PhotoId, Source}; use serde_json::{Value, json}; use super::{CommandSpec, always, bad, bool_or, cmd, str_param}; @@ -42,11 +45,73 @@ fn browse(s: &mut Session, p: &Value) -> Result<Value> { // looking at a folder never brings its deleted photos back (importing the files does) let opts = ImportOptions { mode: ImportMode::Add, local: true, on_deleted: crate::import::OnDeleted::Skip, ..Default::default() }; let report = import_with(s, &files, &opts)?; + let forgotten = forget_gone(s, &dir_s, subfolders, &files); stamp_browsed(s, &dir_s, &files); s.browse = Some(Browse { path: dir_s.clone(), subfolders }); s.source = LibrarySource::Folder; + s.keep_active_in_view(); let shown = s.visible().len(); - Ok(json!({"path": dir_s, "subfolders": subfolders, "photos": shown, "new": report.imported.len(), "failed": report.failed.len()})) + Ok(json!({ + "path": dir_s, + "subfolders": subfolders, + "photos": shown, + "new": report.imported.len(), + "failed": report.failed.len(), + "forgotten": forgotten, + })) +} + +impl Session { + /// Forget the untouched Local records of files gone from the folder `dir` (with its subfolders when + /// `subfolders`) that a browse just listed as `files`: what `library.browse` does after reading a folder, for a + /// UI that reads it on a worker thread instead. Returns how many went. + pub fn forget_gone_from(&mut self, dir: &str, subfolders: bool, files: &[String]) -> usize { + forget_gone(self, dir, subfolders, files) + } +} + +/// Local records in the browsed folder whose files are no longer on disk (deleted or moved by +/// another app) and that nothing was changed on are forgotten, as `library.forgetLocal` forgets +/// them: the folder shows what is on disk, not photos that fail to load (and log an error) on +/// every visit (issue #1392). Records the user changed stay, shown as missing, and so do those +/// the session holds (selected, edited, in the undo history) or with a smart preview. Returns how +/// many went. +fn forget_gone(s: &mut Session, dir: &str, subfolders: bool, files: &[String]) -> usize { + let listed: std::collections::HashSet<&str> = files.iter().map(String::as_str).collect(); + let held = held(s); + let smart = s.media.smart_dir.clone(); + let gone: Vec<PhotoId> = s + .catalog + .photos() + .filter(|p| p.local && !p.deleted && !held.contains(&p.id)) + // a smart preview may be all that is left of the picture + .filter(|p| !smart.as_ref().is_some_and(|d| crate::smart::file_path(d, p).exists())) + .filter(|p| match &p.source { + // a file left out of the listing is checked on disk: another spelling of its path, or + // a listing that missed it, keeps it + Source::File { path } => in_folder(path, dir, subfolders) && !listed.contains(path.as_str()) && !Path::new(path).exists(), + Source::Demo { .. } => false, + }) + .map(|p| p.id) + .collect(); + if gone.is_empty() { + return 0; + } + let mut forgotten = 0; + for chunk in s.catalog.forgettable_local(&gone).chunks(2000) { + let op = Op::Batch { ops: chunk.iter().map(|id| Op::RemovePhoto { id: *id }).collect() }; + match s.catalog.apply(op.clone()) { + Ok(_) => { + s.pending_log.push(op); + forgotten += chunk.len(); + } + Err(e) => log::warn!("forget Local records of files gone from {dir}: {e}"), + } + } + if forgotten > 0 { + log::info!("forgot {forgotten} untouched Local record(s) of files gone from {dir}"); + } + forgotten } /// Folders whose browse time is refreshed at most this often (keeps repeated browsing from @@ -82,7 +147,15 @@ fn stamp_browsed(s: &mut Session, root: &str, files: &[String]) { /// Photos the session still needs, whatever their state: shown, selected, being edited, or /// referred to by an undo / redo step. fn in_use(s: &mut Session) -> std::collections::HashSet<PhotoId> { - let mut keep: std::collections::HashSet<PhotoId> = s.visible().iter().copied().collect(); + let mut keep = held(s); + keep.extend(s.visible().iter().copied()); + keep +} + +/// Photos the session holds whatever is shown: selected, being edited, or referred to by an undo +/// / redo step. +fn held(s: &Session) -> std::collections::HashSet<PhotoId> { + let mut keep: std::collections::HashSet<PhotoId> = std::collections::HashSet::new(); keep.extend(s.selection.ids.iter().copied()); keep.extend(s.selection.active); keep.extend(s.previous_active); @@ -151,7 +224,7 @@ fn forget_local(s: &mut Session, p: &Value) -> Result<Value> { /// The browsed photos among `ids` (default: the selection) become library photos. fn add_to_library(s: &mut Session, p: &Value) -> Result<Value> { - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some_and(|ph| ph.local)).collect(); if ids.is_empty() { return Ok(json!({"added": 0})); } @@ -337,7 +410,7 @@ fn move_to_folder(s: &mut Session, p: &Value) -> Result<Value> { if !dir.is_dir() { return Err(bad(C, format!("{folder} is not a folder (create it first with folder.create)"))); } - let ids = s.targets(p); + let ids = s.targets(p)?; if ids.is_empty() { return Err(bad(C, "no photos to move: select some or give ids")); } diff --git a/crates/engine/src/cmd/color.rs b/crates/engine/src/cmd/color.rs index 55ea8b5ab..111159e7a 100644 --- a/crates/engine/src/cmd/color.rs +++ b/crates/engine/src/cmd/color.rs @@ -156,7 +156,7 @@ fn targeted(s: &mut Session, p: &Value) -> Result<Value> { } fn index(p: &Value, c: &str) -> Result<usize> { - Ok(f64_req(p, "index", c)? as usize) + super::index_req(p, "index", c) } pub fn specs() -> Vec<CommandSpec> { diff --git a/crates/engine/src/cmd/convert.rs b/crates/engine/src/cmd/convert.rs index 4d7f13aeb..eae955cce 100644 --- a/crates/engine/src/cmd/convert.rs +++ b/crates/engine/src/cmd/convert.rs @@ -35,7 +35,8 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path Some(r) => r(path)?, None => std::fs::read(path).map_err(|e| format!("{path}: {e}"))?, }; - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{path}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: (!packet.is_empty()).then_some(packet), ..Default::default() }) .map_err(|e| e.to_string())?; @@ -58,7 +59,7 @@ pub(crate) fn write_dng_with(file_bytes: Option<&crate::merge::ByteReader>, path fn convert(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.convertToDng"; - let ids = s.targets(p); + let ids = s.targets(p)?; let mut ops = Vec::new(); let mut converted = Vec::new(); let mut skipped = Vec::new(); @@ -124,7 +125,7 @@ pub fn specs() -> Vec<CommandSpec> { /// the library and stack it on top of the original. The host opens the file in the editor. fn edit_external(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.editExternal"; - let mut ids = s.targets(p); + let mut ids = s.targets(p)?; ids.dedup(); if ids.is_empty() { return Err(bad(C, "no active photo")); @@ -201,12 +202,16 @@ fn edit_copy(s: &mut Session, p: &Value, id: lightcraft_catalog::PhotoId, opts: /// The op that brings a photo up to date with what its file is now (`info`, a fresh probe), or /// `None` when nothing changed. Covers a raw that became decodable (or stopped being: a different -/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`]. +/// file relinked), i.e. a change of [`lightcraft_catalog::Photo::preview_only`], and a +/// preview-only photo this version hasn't checked yet: its stamp is refreshed so it isn't probed +/// again (see [`lightcraft_catalog::Photo::preview_checked`]). pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::Photo, info: crate::media::ProbeInfo) -> Option<Op> { + let preview_checked = lightcraft_catalog::preview_stamp(&info.preview_only); let same = info.content_hash == ph.content_hash && info.file_size == ph.file_size && (info.width, info.height) == (ph.width, ph.height) - && info.preview_only == ph.preview_only; + && info.preview_only == ph.preview_only + && preview_checked == ph.preview_checked; (!same).then_some(Op::SetContent { id, width: info.width, @@ -214,6 +219,7 @@ pub(crate) fn content_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalo file_size: info.file_size, content_hash: info.content_hash, preview_only: info.preview_only, + preview_checked, }) } @@ -234,6 +240,10 @@ fn fill_missing_meta(id: lightcraft_catalog::PhotoId, ph: &lightcraft_catalog::P m.aperture = m.aperture.or(src.aperture); m.iso = m.iso.or(src.iso); m.gps = m.gps.or(src.gps); + // the camera's focus point: photos imported before it was read get it here + if m.focus.is_none() { + m.focus = src.focus.clone(); + } let mut ops = Vec::new(); if m != before { ops.push(Op::SetMeta { id, meta: Box::new(m) }); @@ -271,10 +281,41 @@ pub(crate) fn as_shot_wb_op(id: lightcraft_catalog::PhotoId, ph: &lightcraft_cat (ph.develops_raw() && !ph.relative_wb() && ph.as_shot_wb != Some(read)).then_some(Op::SetAsShotWb { id, wb: Some(read) }) } +/// The ops that bring photo `id` and its virtual copies up to date with what their file is now +/// (`info`, a fresh probe), appended to `ops`; the photos they change are appended to `changed`. +/// Reload and the preview-only recheck (`library.recheckPreviewOnly`) share it. +pub(crate) fn reload_ops( + cat: &lightcraft_catalog::Catalog, + id: lightcraft_catalog::PhotoId, + info: &crate::media::ProbeInfo, + ops: &mut Vec<Op>, + changed: &mut Vec<lightcraft_catalog::PhotoId>, +) { + let Some(ph) = cat.photo(id) else { return }; + // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; + // nothing already set is overwritten + let meta_ops = fill_missing_meta(id, ph, info); + let mut mine: Vec<Op> = + content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, info)).chain(as_shot_wb_op(id, ph, info)).collect(); + if !mine.is_empty() { + ops.append(&mut mine); + changed.push(id); + } + // virtual copies share the file + for c in cat.photos().filter(|c| c.copy_of == Some(id)) { + let theirs: Vec<Op> = + content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, info)).chain(as_shot_wb_op(c.id, c, info)).collect(); + if !theirs.is_empty() { + ops.extend(theirs); + changed.push(c.id); + } + } +} + /// Re-read photos whose files changed on disk (an external editor saved them): new size, /// dimensions and content hash, cached sources dropped. → {reloaded: [ids]} fn reload(s: &mut Session, p: &Value) -> Result<Value> { - let ids = s.targets(p); + let ids = s.targets(p)?; let paths: Vec<(lightcraft_catalog::PhotoId, String)> = ids .iter() .filter_map(|id| match s.catalog.photo(*id).map(|ph| ph.source.clone()) { @@ -286,25 +327,8 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> { let mut ops = Vec::new(); let mut reloaded = Vec::new(); for ((id, _), info) in paths.into_iter().zip(probed) { - let (Ok(info), Some(ph)) = (info, s.catalog.photo(id)) else { continue }; - // camera fields the catalog lacks (e.g. a raw imported before its format was read) are filled in; - // nothing already set is overwritten - let meta_ops = fill_missing_meta(id, ph, &info); - let mut mine: Vec<Op> = - content_op(id, ph, info.clone()).into_iter().chain(meta_ops).chain(lens_ops(id, ph, &info)).chain(as_shot_wb_op(id, ph, &info)).collect(); - if !mine.is_empty() { - ops.append(&mut mine); - reloaded.push(id); - } - // virtual copies share the file - for c in s.catalog.photos().filter(|c| c.copy_of == Some(id)) { - let theirs: Vec<Op> = - content_op(c.id, c, info.clone()).into_iter().chain(lens_ops(c.id, c, &info)).chain(as_shot_wb_op(c.id, c, &info)).collect(); - if !theirs.is_empty() { - ops.extend(theirs); - reloaded.push(c.id); - } - } + let Ok(info) = info else { continue }; + reload_ops(&s.catalog, id, &info, &mut ops, &mut reloaded); } for id in &reloaded { s.media.forget(*id); @@ -320,7 +344,7 @@ fn reload(s: &mut Session, p: &Value) -> Result<Value> { fn duplicate(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "photo.duplicate"; let mut made = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id).map(|p| (**p).clone()) else { continue }; let Source::File { path } = &ph.source else { return Err(bad(C, "a generated demo photo has no file to duplicate")) }; let src = Path::new(path); @@ -421,6 +445,27 @@ mod tests { assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty()); } + /// Reload from Disk gives a photo imported before focus points were read its camera focus point, + /// and never replaces one it has. + #[test] + fn reload_fills_a_missing_focus_point() { + let fp = |x: f64| lightcraft_meta::FocusPoint { + rect: lightcraft_meta::Rect { x0: x, y0: 0.4, x1: x + 0.02, y1: 0.42 }, + mode: Some("AF-C".into()), + area: None, + tracking: None, + }; + let mut ph = Photo::new(PhotoId(1), Source::File { path: "/x.arw".into() }, "x.arw", "ARW", 8640, 5760, "2026-10-06T00:00:00"); + ph.captured = Some("2026-10-04T08:16:11".into()); + let mut info = crate::media::ProbeInfo { captured: ph.captured.clone(), ..Default::default() }; + info.meta.focus = Some(fp(0.3)); + let ops = fill_missing_meta(PhotoId(1), &ph, &info); + let Some(Op::SetMeta { meta, .. }) = ops.first() else { panic!("{ops:?}") }; + assert_eq!(meta.focus, Some(fp(0.3))); + ph.meta.focus = Some(fp(0.1)); + assert!(fill_missing_meta(PhotoId(1), &ph, &info).is_empty(), "a known focus point is kept"); + } + fn lens() -> lightcraft_develop::EmbeddedLens { lightcraft_develop::EmbeddedLens { warp: Some(lightcraft_develop::EmbeddedWarp { planes: [[1.0, -0.1, 0.03, 0.0, 0.0, 0.0]; 3], ..Default::default() }), @@ -494,7 +539,7 @@ mod tests { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); let f = root.join("raw/rw2-panasonic-gx80.rw2"); if !f.exists() { - eprintln!("skip: {} absent", f.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", f.display()); return; } let mut s = Session::new().with_fs(); diff --git a/crates/engine/src/cmd/cull.rs b/crates/engine/src/cmd/cull.rs index 51049797e..030cb6e93 100644 --- a/crates/engine/src/cmd/cull.rs +++ b/crates/engine/src/cmd/cull.rs @@ -16,11 +16,11 @@ const SAME: f32 = 0.93; fn secs(iso: &str) -> Option<i64> { let d = lightcraft_meta::DateTime::parse_iso(iso)?; - Some(((d.year as i64 * 372 + d.month as i64 * 31 + d.day as i64) * 24 + d.hour as i64) * 3600 + d.minute as i64 * 60 + d.second as i64) + Some(d.unix_seconds()) } fn analyze(s: &mut Session, p: &Value) -> Result<Value> { - let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let ids: Vec<PhotoId> = if p.get("ids").is_some() || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let reject_below = p.get("rejectBelow").and_then(Value::as_f64).map(|v| v as f32); let pick_best = p.get("pickBest").and_then(Value::as_bool).unwrap_or(false); // measure (thumbnail-level sources: fast, and enough for focus at 512 px) diff --git a/crates/engine/src/cmd/curves.rs b/crates/engine/src/cmd/curves.rs index 77b061b78..7388f8e57 100644 --- a/crates/engine/src/cmd/curves.rs +++ b/crates/engine/src/cmd/curves.rs @@ -349,9 +349,8 @@ fn export_presets(s: &mut Session, p: &Value) -> Result<Value> { if chosen.is_empty() { return Err(bad(C, "no curve presets to export (save one first, or pass names)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_file(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad(C, e))?; + crate::export::write_file_durable(&target, to_file(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } diff --git a/crates/engine/src/cmd/develop.rs b/crates/engine/src/cmd/develop.rs index 6f064b260..e3d59c567 100644 --- a/crates/engine/src/cmd/develop.rs +++ b/crates/engine/src/cmd/develop.rs @@ -5,7 +5,7 @@ use lightcraft_develop::{DevelopSettings, Preset, ProcessVersion, Section, Setti use lightcraft_geom::{CropGeometry, CropHandle, Point, Rect, crop_fit_angle, drag_crop}; use serde_json::{Value, json}; -use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, ok, str_param}; +use super::{CommandSpec, always, bad, bool_or, cmd, f64_or, f64_req, has_active, has_clipboard, has_selection, index_req, ok, str_param}; use crate::{Result, Session, media::SourceLevel}; fn active(s: &Session, c: &str) -> Result<PhotoId> { @@ -144,7 +144,7 @@ fn user_preset<'a>(s: &'a mut Session, p: &Value, c: &str) -> Result<&'a mut Pre /// Change version `p.index` of the active photo (`f` gets it and the current settings). fn edit_version(s: &mut Session, p: &Value, c: &str, label: &str, f: impl FnOnce(&mut Version, std::sync::Arc<DevelopSettings>)) -> Result<Value> { let id = active(s, c)?; - let i = f64_req(p, "index", c)? as usize; + let i = index_req(p, "index", c)?; let ph = s.catalog.photo(id).ok_or_else(|| bad(c, "no photo"))?; let mut versions = ph.versions.clone(); let v = versions.get_mut(i).ok_or_else(|| bad(c, "no such version"))?; @@ -254,7 +254,7 @@ pub fn specs() -> Vec<CommandSpec> { }) }), cmd!("develop.reset", "Reset Edits", ["Photo"], Some("Cmd+Shift+R"), "{ids?}", has_selection, |s, p| { - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -304,7 +304,7 @@ pub fn specs() -> Vec<CommandSpec> { let targets = match named_photos(s, p, C)? { Some(ids) if !ids.iter().any(|id| outdated(s, *id)) => return Err(crate::EngineError::Disabled(C.into(), already_current())), Some(ids) => ids, - None => s.targets(p), + None => s.targets(p)?, }; let ops: Vec<Op> = targets .into_iter() @@ -588,14 +588,14 @@ pub fn specs() -> Vec<CommandSpec> { "Straighten", [], None, - "{angle: degrees -45..45} — keeps the largest crop of the current aspect", + "{angle: degrees -45..45} — keeps the crop if it still fits, else shrinks it about its centre (the largest crop of its aspect stays the largest)", has_active, |s, p| { let id = active(s, "crop.straighten")?; let (w, h) = crop_dims(s, id); let angle = f64_req(p, "angle", "crop.straighten")?.clamp(-45.0, 45.0); edit(s, "crop.straighten", "Straighten", |d| { - // keep the current crop if it still fits, else the largest of its aspect that does + // keep the current crop if it still fits, else shrink it about its centre until it does d.crop.geometry = d.crop.geometry.with_angle(angle, w, h); Ok(()) }) @@ -678,10 +678,11 @@ pub fn specs() -> Vec<CommandSpec> { let (w, h) = crop_dims(s, id); edit(s, "crop.rotateAspect", "Rotate Crop", |d| { let r = d.crop.geometry.rect_px(w, h); - let a = r.height() / r.width(); + // a degenerate crop has no aspect to rotate: fall back to the image's own + let a = Some(r.height() / r.width()).filter(|a| a.is_finite() && *a > 0.0); d.crop.aspect = d.crop.aspect.map(|(x, y)| (y, x)); let angle = d.crop.geometry.angle; - d.crop.geometry = crop_fit_angle(w, h, angle, Some(a)); + d.crop.geometry = crop_fit_angle(w, h, angle, a); Ok(()) }) }), @@ -810,7 +811,7 @@ pub fn specs() -> Vec<CommandSpec> { s.previous_active.filter(|id| s.catalog.photo(*id).is_some_and(|ph| !ph.deleted)).ok_or_else(|| bad(c, "no previous photo"))?; let groups = groups_param(p, c)?.unwrap_or_else(|| s.copy_groups.clone()); let partial = lightcraft_develop::extract_groups(&s.develop_of(prev).unwrap_or_default(), &groups); - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|id| *id != prev).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|id| *id != prev).collect(); let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -837,7 +838,7 @@ pub fn specs() -> Vec<CommandSpec> { let full = lightcraft_develop::apply_partial(&DevelopSettings::default(), &clip, 1.0); clip = lightcraft_develop::extract_groups(&full, &only); } - let ids = s.targets(p); + let ids = s.targets(p)?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -889,7 +890,7 @@ pub fn specs() -> Vec<CommandSpec> { let delta = f64_req(p, "delta", c)?; let label = format!("Quick Develop: {}", spec.label); let ops: Vec<Op> = s - .targets(p) + .targets(p)? .into_iter() .filter_map(|id| s.develop_of(id).map(|d| (id, d))) .filter_map(|(id, d)| { @@ -942,7 +943,7 @@ pub fn specs() -> Vec<CommandSpec> { }; let r_ev = gathered(s, reference).ok_or_else(|| bad(c, "the active photo has no shutter / aperture / ISO"))?; let r_slider = s.develop_of(reference).map(|d| d.light.exposure).unwrap_or(0.0); - let ids: Vec<PhotoId> = s.targets(p).into_iter().filter(|x| *x != reference).collect(); + let ids: Vec<PhotoId> = s.targets(p)?.into_iter().filter(|x| *x != reference).collect(); let (mut ops, mut skipped) = (Vec::new(), Vec::new()); for id in ids { let (Some(ev), Some(d)) = (gathered(s, id), s.develop_of(id)) else { @@ -977,7 +978,8 @@ pub fn specs() -> Vec<CommandSpec> { let pid = str_param(p, "id").ok_or_else(|| bad("preset.apply", "missing id"))?; let preset = s.presets.iter().find(|x| x.id == pid).cloned().ok_or_else(|| bad("preset.apply", format!("unknown preset `{pid}`")))?; let amount = f64_or(p, "amount", 100.0).clamp(0.0, 200.0) / 100.0; - let ids = s.targets(p); + // `id` names the preset here: the photos are `ids`, else the selection + let ids = s.targets(&json!({"ids": p.get("ids")}))?; let ops = ids .iter() .filter_map(|id| s.develop_of(*id).map(|d| (*id, d))) @@ -1060,14 +1062,14 @@ pub fn specs() -> Vec<CommandSpec> { }), cmd!("version.restore", "Restore Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.restore")?; - let i = f64_req(p, "index", "version.restore")? as usize; + let i = index_req(p, "index", "version.restore")?; let v = s.catalog.photo(id).and_then(|ph| ph.versions.get(i).cloned()).ok_or_else(|| bad("version.restore", "no such version"))?; s.set_develop(id, (*v.settings).clone(), &format!("Restore {}", v.name))?; ok() }), cmd!("version.delete", "Delete Version", [], None, "{index}", has_active, |s, p| { let id = active(s, "version.delete")?; - let i = f64_req(p, "index", "version.delete")? as usize; + let i = index_req(p, "index", "version.delete")?; let mut versions = s.catalog.photo(id).map(|ph| ph.versions.clone()).unwrap_or_default(); if i >= versions.len() { return Err(bad("version.delete", "no such version")); diff --git a/crates/engine/src/cmd/face_detect.rs b/crates/engine/src/cmd/face_detect.rs index 4676d1c65..711bebb10 100644 --- a/crates/engine/src/cmd/face_detect.rs +++ b/crates/engine/src/cmd/face_detect.rs @@ -17,7 +17,7 @@ use lightcraft_faces::yunet::{Detector, Face, Options}; use lightcraft_meta::{Region, RegionKind}; use serde_json::{Value, json}; -use super::{CommandSpec, bad, cmd, has_selection}; +use super::{CommandSpec, always, bad, cmd, has_selection}; use crate::{AiModel, EngineError, Result, Session}; const C: &str = "faces.detect"; @@ -54,10 +54,12 @@ impl NoDetector { } } +/// The YuNet loaded last, and from where. +static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None); + /// The installed YuNet, loaded once and kept (a new one is loaded when the model folder changes). /// (also used by the background scan, which aligns faces by the detector's landmarks and finds faces in photos that have none) pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDetector> { - static LOADED: Mutex<Option<(PathBuf, Arc<Detector>)>> = Mutex::new(None); let home = s .face_models_dir .as_deref() @@ -82,10 +84,133 @@ pub(crate) fn detector(s: &Session) -> std::result::Result<Arc<Detector>, NoDete let d = Arc::new( Detector::new(&bytes).map_err(|e| NoDetector::Unusable(format!("{e}: remove the detector in Settings > Faces and download it again")))?, ); + #[cfg(not(target_arch = "wasm32"))] + { + let net = d.clone(); + offer_gpu(s, "detection", move |gpu| net.accelerate(gpu, true)); + } *loaded = Some((path, d.clone())); Ok(d) } +/// Where a face model runs, for Settings ▸ Faces and `faces.gpu`. +#[derive(Clone, Debug, Default, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub(crate) struct GpuState { + /// The background check is still running. + pub checking: bool, + /// The GPU it runs on; `None` when it runs on the CPU. + pub device: Option<String>, + /// Why it runs on the CPU. + pub why: Option<String>, +} + +/// Each model's [`GpuState`], by what it does ("detection", "recognition"). +static GPU_STATE: Mutex<Vec<(&'static str, GpuState)>> = Mutex::new(Vec::new()); + +pub(crate) fn gpu_state(what: &str) -> Option<GpuState> { + GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner).iter().find(|(w, _)| *w == what).map(|(_, st)| st.clone()) +} + +fn set_gpu_state(what: &'static str, st: GpuState) { + let mut all = GPU_STATE.lock().unwrap_or_else(PoisonError::into_inner); + match all.iter_mut().find(|(w, _)| *w == what) { + Some((_, old)) => *old = st, + None => all.push((what, st)), + } +} + +/// The user chose the CPU for face models (Settings ▸ Faces). +pub(crate) fn cpu_only(s: &Session) -> bool { + s.face_models_dir.as_deref().is_some_and(|d| super::face_models::read_settings(d).cpu_only) +} + +/// Check in the background whether the GPU runs a face model faster than the CPU and gives its results; the model +/// runs on the CPU meanwhile, and stays there when the GPU is off, missing, wrong or slower, or the user chose the CPU. +/// The check shows in the activity stack while it runs. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn offer_gpu( + s: &Session, + what: &'static str, + attach: impl FnOnce(Arc<dyn lightcraft_faces::net::Accelerator>) -> std::result::Result<(), String> + Send + 'static, +) { + if cpu_only(s) { + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + return; + } + let task = s.activity.start("facesGpu", "Checking the GPU for faces", crate::activity::Cancel::No); + task.detail(what); + set_gpu_state(what, GpuState { checking: true, ..GpuState::default() }); + let spawned = std::thread::Builder::new().name(format!("faces-gpu-{what}")).spawn(move || { + let _task = task; + let gpu = match lightcraft_gpu::faces::accelerator() { + Ok(gpu) => gpu, + Err(why) => { + log::info!("faces: {what} runs on the CPU ({why})"); + return set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } + }; + let device = lightcraft_faces::net::Accelerator::device(&*gpu); + match attach(gpu) { + Ok(()) => { + log::info!("faces: {what} runs on {device}"); + set_gpu_state(what, GpuState { device: Some(device), ..GpuState::default() }); + } + Err(why) => { + log::info!("faces: {what} stays on the CPU: {why}"); + set_gpu_state(what, GpuState { why: Some(why), ..GpuState::default() }); + } + } + }); + if let Err(e) = spawned { + log::warn!("faces: could not check the GPU for {what}: {e}"); + set_gpu_state(what, GpuState { why: Some(format!("could not check the GPU: {e}")), ..GpuState::default() }); + } +} + +/// `faces.gpu`: where the face models run, and the switch that keeps them on the CPU. +fn gpu(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "faces.gpu"; + if let Some(cpu) = p.get("cpuOnly") { + let cpu = cpu.as_bool().ok_or_else(|| bad(C, "`cpuOnly` is true or false"))?; + let dir = s.face_models_dir.clone().ok_or_else(|| bad(C, "this build has nowhere to keep face models (the desktop app does)"))?; + super::face_models::set_cpu_only(&dir, cpu)?; + let detector = LOADED.lock().unwrap_or_else(PoisonError::into_inner).as_ref().map(|(_, d)| d.clone()); + #[cfg(not(target_arch = "wasm32"))] + let embedder = s.faces.embedder.as_ref().map(|(_, e)| e.clone()); + #[cfg(target_arch = "wasm32")] + let embedder: Option<Arc<lightcraft_faces::runtime::Embedder>> = None; + if cpu { + for (what, net) in [("detection", detector.as_ref().map(|d| d.net())), ("recognition", embedder.as_ref().map(|e| e.net()))] { + if let Some(net) = net { + net.detach(); + } + set_gpu_state(what, GpuState { why: Some("the CPU is chosen in Settings".into()), ..GpuState::default() }); + } + } else { + #[cfg(not(target_arch = "wasm32"))] + { + if let Some(d) = detector { + offer_gpu(s, "detection", move |gpu| d.accelerate(gpu, true)); + } + if let Some(e) = embedder { + offer_gpu(s, "recognition", move |gpu| e.accelerate(gpu, true).map_err(|e| e.to_string())); + } + } + } + } + Ok(gpu_json(s)) +} + +/// What `faces.gpu` and `faces.models.list` say about the GPU. +pub(crate) fn gpu_json(s: &Session) -> Value { + json!({ + "cpuOnly": cpu_only(s), + "detection": gpu_state("detection"), + "recognition": gpu_state("recognition"), + }) +} + fn face_json(f: &Face) -> Value { json!({ "rect": {"x0": f.x0, "y0": f.y0, "x1": f.x1, "y1": f.y1}, @@ -132,7 +257,7 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> { let detector = detector(s).map_err(|e| e.into_error(C))?; let started = Instant::now(); let (mut results, mut ops) = (Vec::new(), Vec::new()); - for id in s.targets(p) { + for id in s.targets(p)? { let (rgb, width, height) = match render_rgb(s, id, LOOK_EDGE) { Ok(r) => r, Err(e) => { @@ -173,15 +298,26 @@ fn detect(s: &mut Session, p: &Value) -> Result<Value> { } pub fn specs() -> Vec<CommandSpec> { - vec![cmd!( - "faces.detect", - "Detect Faces", - ["Photo"], - None, - "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", - has_selection, - detect - )] + vec![ + cmd!( + "faces.detect", + "Detect Faces", + ["Photo"], + None, + "{ids?, apply?: bool (default true), score?: 0.6, nmsIou?: 0.3, maxFaces?} → {photos: [{id, faces: [{rect, score, landmarks}]}]}. Finds faces with the YuNet detector (download it first: `faces.models.download {id: \"yunet-2023mar\"}`) and, unless `apply` is false, adds them as unnamed face regions (one undo step), replacing earlier detections but never regions from XMP or ones you named; the sidecar is not touched", + has_selection, + detect + ), + cmd!( + query "faces.gpu", + "Face Models on the GPU", + [], + None, + "{cpuOnly?: bool} → {cpuOnly, detection, recognition}: where the face models run, each as {checking, device, why} (null until it is loaded). The GPU is used only after a background check shows it gives the CPU's results faster; `cpuOnly: true` keeps every face model on the CPU (Settings ▸ Faces), `false` checks the GPU again", + always, + gpu + ), + ] } #[cfg(test)] diff --git a/crates/engine/src/cmd/face_models.rs b/crates/engine/src/cmd/face_models.rs index 6033c4dcc..1ea39d04c 100644 --- a/crates/engine/src/cmd/face_models.rs +++ b/crates/engine/src/cmd/face_models.rs @@ -27,6 +27,9 @@ pub(super) struct FaceSettings { pub(super) enabled: bool, /// The installed recogniser in use. pub(super) embedder: Option<String>, + /// Keep every face model on the CPU, even when the GPU would be faster. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub(super) cpu_only: bool, } fn fail(what: &str, e: impl std::fmt::Display) -> EngineError { @@ -57,6 +60,13 @@ fn write_atomic(path: &Path, bytes: &[u8]) -> Result<()> { }) } +/// Keep the face models on the CPU (or let them use the GPU again). +pub(super) fn set_cpu_only(dir: &Path, on: bool) -> Result<()> { + let mut st = read_settings(dir); + st.cpu_only = on; + write_settings(dir, &st) +} + fn write_settings(dir: &Path, st: &FaceSettings) -> Result<()> { std::fs::create_dir_all(dir).map_err(|e| fail("could not create the face models folder", e))?; write_atomic(&dir.join("settings.json"), &serde_json::to_vec_pretty(st).map_err(|e| fail("settings", e))?) @@ -195,6 +205,8 @@ fn list(s: &mut Session, _: &Value) -> Result<Value> { "dir": dir.as_ref().map(|d| d.display().to_string()), "enabled": settings.enabled, "embedder": settings.embedder, + // where the models run: {cpuOnly, detection, recognition} as `faces.gpu` gives it + "gpu": super::face_detect::gpu_json(s), // Native builds always have the pure-Rust runner; model files/background scanning remain native-only. "runtime": cfg!(not(target_arch = "wasm32")), "models": models, diff --git a/crates/engine/src/cmd/face_recognize.rs b/crates/engine/src/cmd/face_recognize.rs index e099f13ec..228a1ce3d 100644 --- a/crates/engine/src/cmd/face_recognize.rs +++ b/crates/engine/src/cmd/face_recognize.rs @@ -1,10 +1,11 @@ //! Recognition commands: `faces.index` (embed the faces in the library), `faces.suggest` (who might an unnamed //! face be?), `faces.person` (one person's faces, and the unnamed faces that look like them), `faces.unnamed` (every //! unnamed face, look-alikes together) and `faces.setName` / `faces.nameFaces` (name one face, or many at once, which is -//! what teaches the suggestions). +//! what teaches the suggestions). `faces.renamePerson` renames every in-library face of one person, or merges two +//! people when asked. //! //! Everything here is a suggestion for the user to confirm; nothing is ever named automatically. All of it is -//! catalog-only (no sidecar is written) and `faces.setName` is undoable. +//! catalog-only (no sidecar is written) and naming is undoable. use lightcraft_catalog::Op; use serde_json::{Value, json}; @@ -92,6 +93,92 @@ fn set_name(s: &mut Session, p: &Value) -> Result<Value> { Ok(json!({"id": id.0, "index": index, "name": name})) } +/// `faces.renamePerson {from, to, merge?}`: give every in-library face of `from` the exact name `to`, as one undo +/// step. Names match the People view (trimmed, case-insensitive). A `to` that is already someone else is refused +/// unless `merge` is set, and then the two people become one. The sidecar is not touched, and a refusal changes nothing. +fn rename_person(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "faces.renamePerson"; + let from = match p.get("from") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "missing `from`")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`from` must be text")), + None => return Err(bad(C, "missing `from`")), + }; + let to = match p.get("to") { + Some(Value::String(v)) => { + let t = v.trim(); + if t.is_empty() { + return Err(bad(C, "`to` must be a name")); + } + if t.chars().count() > 200 || t.chars().any(char::is_control) { + return Err(bad(C, "a name is at most 200 characters, without control characters")); + } + t.to_string() + } + Some(_) => return Err(bad(C, "`to` must be text")), + None => return Err(bad(C, "missing `to`")), + }; + let merge = super::bool_or(p, "merge", false); + let from_key = from.to_lowercase(); + let to_key = to.to_lowercase(); + let mut matched = 0usize; + let mut other = false; + let mut faces = 0usize; + let mut ops = Vec::new(); + let ids: Vec<_> = s.catalog.photos().filter(|ph| ph.in_library()).map(|ph| ph.id).collect(); + for id in ids { + let Some(ph) = s.catalog.photo(id) else { continue }; + let mut indexes = Vec::new(); + for (i, r) in ph.meta.regions.iter().enumerate() { + if r.kind != lightcraft_meta::RegionKind::Face { + continue; + } + let Some(n) = r.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = n.to_lowercase(); + if key == from_key { + matched += 1; + if r.name.as_deref() != Some(to.as_str()) { + indexes.push(i); + } + } else if key == to_key { + other = true; + } + } + if indexes.is_empty() { + continue; + } + let mut meta = ph.meta.clone(); + for i in indexes { + if let Some(region) = meta.regions.get_mut(i) { + region.name = Some(to.clone()); + faces += 1; + } + } + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + if matched == 0 { + return Err(bad(C, format!("no person named {from}"))); + } + if faces == 0 { + return Err(bad(C, format!("{to} is already their name"))); + } + if other && !merge { + return Err(bad(C, format!("{to} is already another person; pass merge: true to combine them"))); + } + let photos = ops.len(); + s.commit(if other { "Merge People" } else { "Rename Person" }, Op::Batch { ops })?; + s.skip_auto_write = true; + if s.filter.person.as_deref().is_some_and(|n| n.trim().to_lowercase() == from_key) { + s.filter.person = Some(to.clone()); + } + Ok(json!({"faces": faces, "photos": photos, "from": from, "to": to, "merged": other})) +} + /// Most faces `faces.unnamed` lists. const MAX_UNNAMED: usize = 3000; @@ -234,6 +321,8 @@ mod imp { return Ok(e.clone()); } let embedder = Arc::new(Embedder::load(&dir.join(&id).join("model.onnx"), &installed.manifest).map_err(|e| bad(cmd, e.to_string()))?); + let net = embedder.clone(); + super::super::face_detect::offer_gpu(s, "recognition", move |gpu| net.accelerate(gpu, true).map_err(|e| e.to_string())); let dim = match installed.manifest.output { lightcraft_faces::OutputSpec::Embedding { dim } => dim as usize, _ => return Err(bad(cmd, "the chosen model is not a recognition model")), @@ -275,7 +364,7 @@ mod imp { } let job = s.preview_job(id, EDGE, EDGE, false, &DevelopSettings::default())?; let preview = s.scan_preview_of(&photo); - Some(Prepared { tag, epoch: s.faces.epoch, id, job, preview, todo, detect, detector }) + Some(Prepared { tag, epoch: s.faces.epoch, id, content: crate::media::content_key(&photo), job, preview, todo, detect, detector }) } /// The faces the detector found in a photo that had none become its face regions: unnamed, marked as detected (so a @@ -299,13 +388,16 @@ mod imp { } } - /// Take a finished photo into the index (ignored when it was made for another model or another library). Returns - /// (embedded, failed). + /// Take a finished photo into the index (ignored when it was made for another model or another library, or from + /// a file the photo no longer has: issue #1524). Returns (embedded, failed). fn ingest(s: &mut Session, d: Done) -> (usize, usize) { s.faces.in_flight.remove(&d.id); if d.epoch != s.faces.epoch || d.tag != s.faces.index.tag { return (0, 0); } + if s.catalog.photo(d.id).is_none_or(|p| crate::media::content_key(p) != d.content) { + return (0, 0); + } if d.detect_wanted { match &d.found { Some(found) => { @@ -582,6 +674,9 @@ mod imp { if let Some(w) = &s.faces.worker { w.allow(target, pace); } + // the GPU is held back the same way: a light pace leaves it mostly free for the window's own renders + let (runs, share) = crate::faces_worker::gpu_pace(pace); + lightcraft_gpu::faces::set_pace(runs, share); while target > 0 && s.faces.in_flight.len() < target + PREFETCH { let Some(id) = s.faces.queue.pop() else { break }; let Some(job) = prepare(s, id, true) else { continue }; @@ -751,7 +846,7 @@ mod imp { const C: &str = "faces.suggest"; let embedder = current(s, C)?; collect(s); - let targets = s.targets(p); + let targets = s.targets(p)?; let model_threshold = embedder.manifest().thresholds.match_cosine; let num = |k: &str| p.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()).map(|v| v as f32); let threshold = num("threshold").or(model_threshold).unwrap_or(DEFAULT_THRESHOLD); @@ -830,7 +925,8 @@ mod imp { } fn finished(s: &Session, id: PhotoId, found: Option<Vec<Rect>>, results: Vec<(Rect, Option<Vec<f32>>, Rect)>) -> Done { - Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, results, detect_wanted: true, found } + let content = s.catalog.photo(id).map(|p| crate::media::content_key(p)).unwrap_or_default(); + Done { tag: s.faces.index.tag.clone(), epoch: s.faces.epoch, id, content, results, detect_wanted: true, found } } #[test] @@ -915,6 +1011,42 @@ mod imp { let _ = std::fs::remove_dir_all(&d); } + /// A Reload that finds the file replaced (an outside edit) gives the photo other pixels: what a worker made from + /// the old file is dropped when it comes back, the faces already embedded from it are forgotten and the photo is + /// searched again (issue #1524). + #[test] + fn faces_made_from_a_replaced_file_are_dropped() { + let (mut s, d) = loaded("replaced"); + let (a, b) = two_photos(&s); + let rect = region(0.2, None).rect; + s.faces.index.insert(a.0, &rect, unit(&[(0, 1.0)])); + s.faces.scanned.insert(a.0); + s.faces.index.insert(b.0, &rect, unit(&[(1, 1.0)])); + s.faces.scanned.insert(b.0); + let old = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(2, 1.0)])), rect)]); + let (width, height) = s.catalog.photo(a).map(|p| (p.width, p.height)).unwrap(); + let replace = Op::SetContent { + id: a, + width, + height, + file_size: 1234, + content_hash: Some("edited".into()), + preview_only: None, + preview_checked: String::new(), + }; + s.commit("Reload Changed Files", replace).unwrap(); + assert!(s.faces.index.get(a.0, &rect).is_none() && !s.faces.scanned.contains(a.0), "the old file's faces are forgotten"); + assert!(needs_search(&s, s.catalog.photo(a).unwrap()), "and the photo is searched again"); + assert!(s.faces.index.get(b.0, &rect).is_some() && s.faces.scanned.contains(b.0), "other photos keep theirs"); + assert_eq!(ingest(&mut s, old), (0, 0), "a result made from the old file is dropped"); + assert!(s.catalog.photo(a).unwrap().meta.regions.is_empty() && !s.faces.scanned.contains(a.0)); + // what is made from the new file is taken in + let new = finished(&s, a, Some(vec![rect]), vec![(rect, Some(unit(&[(3, 1.0)])), rect)]); + assert_eq!(ingest(&mut s, new), (1, 0)); + assert!(s.faces.scanned.contains(a.0) && s.faces.index.get(a.0, &rect).is_some()); + let _ = std::fs::remove_dir_all(&d); + } + #[test] fn the_unnamed_faces_that_look_like_a_person_come_best_first() { let (mut s, d) = loaded("similar"); @@ -1047,5 +1179,14 @@ pub fn specs() -> Vec<CommandSpec> { super::always, set_name ), + cmd!( + "faces.renamePerson", + "Rename Person", + [], + None, + "{from, to, merge?: bool} → {faces, photos, from, to, merged} — give every in-library face of `from` the name `to` (trimmed, any capitalization), one undo step; refused when `to` is already someone else unless `merge` is true, which combines the two; the sidecar is not touched", + super::always, + rename_person + ), ] } diff --git a/crates/engine/src/cmd/focus.rs b/crates/engine/src/cmd/focus.rs new file mode 100644 index 000000000..efe1d1b7f --- /dev/null +++ b/crates/engine/src/cmd/focus.rs @@ -0,0 +1,50 @@ +//! Focus points for photos already in the library: `photo.readFocusPoints` (Photo ▸ Read Focus Points) and the +//! per-frame `focus.pump` that keeps the background job going. The job itself is `crate::focus_scan`. + +use serde_json::Value; + +use super::{CommandSpec, cmd}; +use crate::{Result, Session}; + +#[cfg(not(target_arch = "wasm32"))] +fn read_points(s: &mut Session, p: &Value) -> Result<Value> { + crate::focus_scan::read_points(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn read_points(_: &mut Session, _: &Value) -> Result<Value> { + Err(super::bad("photo.readFocusPoints", "reading focus points needs the photos' files on disk")) +} + +#[cfg(not(target_arch = "wasm32"))] +fn pump(s: &mut Session, p: &Value) -> Result<Value> { + crate::focus_scan::pump(s, p) +} + +#[cfg(target_arch = "wasm32")] +fn pump(_: &mut Session, _: &Value) -> Result<Value> { + Ok(serde_json::json!({"active": false})) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "photo.readFocusPoints", + "Read Focus Points", + ["Photo"], + None, + "{ids?, wait?: false} → {queued, running, last?: {total, done, found, none, unavailable, bytesRead, seconds, cancelled}} — read the camera's focus point for photos imported before it was read (Sony α1 and A7S III raws and JPEGs), from the few KB of each file that hold it, on a background job with a row in the activity stack. Without `ids`, every photo in the library not looked at yet; with `ids`, those photos, looked at again. Photos that have a point keep it. No undo step and no sidecar write. `wait: true` returns when all are read (scripts)", + super::always, + read_points + ), + cmd!( + query "focus.pump", + "Read Focus Points in the Background", + [], + None, + "{pace?: pause | light | normal} → {active, running?: {done, total, found}, last?, lookedAt} — cheap to call every frame (the app does): takes in what the background focus-point reader has read and commits it in batches; reads nothing new while paused and rests between files when light. Starts the reader by itself once per library opening when photos are left to look at", + super::always, + pump + ), + ] +} diff --git a/crates/engine/src/cmd/geocode.rs b/crates/engine/src/cmd/geocode.rs index ddbe6b13a..085183d58 100644 --- a/crates/engine/src/cmd/geocode.rs +++ b/crates/engine/src/cmd/geocode.rs @@ -115,7 +115,7 @@ fn fill(meta: &mut lightcraft_catalog::Meta, place: &lightcraft_geocode::Place, fn reverse(s: &mut Session, p: &Value) -> Result<Value> { const C: &str = "metadata.reverseGeocode"; let overwrite = bool_or(p, "overwrite", false); - let ids = s.targets(p); + let ids = s.targets(p)?; let todo: Vec<(PhotoId, LatLon)> = ids .iter() .filter_map(|id| { diff --git a/crates/engine/src/cmd/keywords.rs b/crates/engine/src/cmd/keywords.rs index b2c7e90f0..fe09a92f9 100644 --- a/crates/engine/src/cmd/keywords.rs +++ b/crates/engine/src/cmd/keywords.rs @@ -256,7 +256,7 @@ pub fn specs() -> Vec<CommandSpec> { let Some(k) = current_keywords(s).get(i as usize - 1).cloned().filter(|k| !k.is_empty()) else { return Ok(json!({"changed": 0})); }; - let ids = s.targets(p); + let ids = s.targets(p)?; let all = !ids.is_empty() && ids.iter().all(|id| { s.catalog @@ -288,7 +288,7 @@ pub fn specs() -> Vec<CommandSpec> { always, |s, p| { let mut current: Vec<String> = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { if let Some(ph) = s.catalog.photo(id) { current.extend(ph.meta.keywords.iter().cloned()); } @@ -336,7 +336,7 @@ pub fn specs() -> Vec<CommandSpec> { Some(parent) => format!("{parent}|{name}"), None => name, }); - let photos = if bool_or(p, "addToSelected", false) { s.targets(p) } else { Vec::new() }; + let photos = if bool_or(p, "addToSelected", false) { s.targets(p)? } else { Vec::new() }; let op = s .catalog .create_keyword_ops(&path, info_from(p, KeywordInfo::default()), &photos) diff --git a/crates/engine/src/cmd/library.rs b/crates/engine/src/cmd/library.rs index 3a5575ea8..860cf089f 100644 --- a/crates/engine/src/cmd/library.rs +++ b/crates/engine/src/cmd/library.rs @@ -167,9 +167,22 @@ fn album_targets_or_err(s: &Session, command: &str, targets: Vec<PhotoId>) -> Re Ok((kept, skipped)) } +/// Move to Recently Deleted and drop the photos from stacks so leftover members do not keep +/// showing `i/n` (issue #1493). +fn mark_deleted(s: &mut Session, ids: &[PhotoId], label: &str) -> Result<usize> { + let mut ops: Vec<Op> = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); + let n = ops.len(); + if n == 0 { + return Ok(0); + } + ops.extend(s.catalog.remove_from_stacks_ops(ids)); + s.commit(label, Op::Batch { ops })?; + Ok(n) +} + /// Apply one op per target as a single undo step. fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> Option<Op>) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let ops: Vec<Op> = targets.iter().filter_map(|id| f(*id)).collect(); let n = ops.len(); if n > 0 { @@ -181,7 +194,7 @@ fn for_targets(s: &mut Session, p: &Value, label: &str, f: impl Fn(PhotoId) -> O /// Adjust each target's own rating; unchanged bounds do not create an undo step. fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<Value> { let mut ops = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.rating; let rating = (i16::from(old) + i16::from(delta)).clamp(0, 5) as u8; if rating != old { @@ -196,6 +209,25 @@ fn change_rating(s: &mut Session, p: &Value, delta: i8, label: &str) -> Result<V Ok(json!({"changed": n})) } +/// Move each target's own flag by `step`: cycling wraps unflagged -> pick -> reject -> unflagged, +/// raising/lowering clamps on reject < unflagged < pick. Unchanged photos make no history step. +fn change_flag(s: &mut Session, p: &Value, label: &str, step: impl Fn(Flag) -> Flag) -> Result<Value> { + let mut ops = Vec::new(); + for id in s.targets(p)? { + let old = s.catalog.photo(id).ok_or(lightcraft_catalog::CatalogError::NoPhoto(id))?.flag; + let flag = step(old); + if flag != old { + ops.push(Op::SetFlag { id, flag }); + } + } + let n = ops.len(); + if n > 0 { + s.commit(label, Op::Batch { ops })?; + } + advance_if(s, p); + Ok(json!({"changed": n})) +} + fn advance_if(s: &mut Session, p: &Value) { if bool_or(p, "advance", false) { let _ = step(s, 1); @@ -226,12 +258,16 @@ fn row_step(p: &Value) -> isize { } fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { let mut d = (*d).clone(); d.orientation = d.orientation.rotated(cw); + // the crop lives in the oriented frame, so it turns with the image (else it keeps the old width and height and cuts the photo) + let turn = lightcraft_geom::Orientation::Normal.rotated(cw); + d.crop.geometry.rect = turn.map_norm_rect(d.crop.geometry.rect); + d.crop.aspect = d.crop.aspect.map(|(w, h)| (h, w)); ops.extend(s.develop_op(id, d, "Rotate")); } } @@ -241,7 +277,7 @@ fn rotate(s: &mut Session, p: &Value, cw: bool) -> Result<Value> { } fn flip(s: &mut Session, p: &Value, horizontal: bool) -> Result<Value> { - let targets = s.targets(p); + let targets = s.targets(p)?; let mut ops = Vec::new(); for id in targets { if let Some(d) = s.develop_of(id) { @@ -362,11 +398,8 @@ pub fn specs() -> Vec<CommandSpec> { } other => return Err(bad("library.source", format!("unknown source `{other}`"))), }; - let vis = s.visible_cloned(); - if s.selection.active.is_none_or(|a| !vis.contains(&a)) { - s.selection = vis.first().map(|f| Selection::single(*f)).unwrap_or_default(); - } - Ok(json!({"count": vis.len()})) + s.keep_active_in_view(); + Ok(json!({"count": s.visible().len()})) } ), cmd!( @@ -436,8 +469,7 @@ pub fn specs() -> Vec<CommandSpec> { if covers_other_disks(s, path, &ids) && !bool_or(p, "disk", false) { return Err(bad(C, format!("{path} holds whole disks: pass `disk: true` to remove everything on them"))); } - let ops = ids.iter().map(|id| Op::SetDeleted { id: *id, deleted: true }).collect(); - s.commit("Remove Folder from Library", Op::Batch { ops })?; + mark_deleted(s, &ids, "Remove Folder from Library")?; if s.filter.library_folder.as_deref().is_some_and(|c| lightcraft_catalog::query::folder_within(c, path)) { s.filter.library_folder = None; } @@ -510,6 +542,44 @@ pub fn specs() -> Vec<CommandSpec> { ok() } ), + cmd!( + "library.refresh", + "Refresh View", + ["View"], + None, + "{} — show what matches now: a view that holds its photos (`library.holdView`) lets go of the ones that stopped matching a smart album's rules, Picks or the filter bar since it was opened, sorted or last refreshed", + always, + |s, _| { + s.refresh_view(); + ok() + } + ), + cmd!( + "library.holdView", + "Hold View Until Refreshed", + ["View"], + None, + "{on?: bool (toggles when omitted)} → {hold} — when on, an edit that makes a photo stop matching the view (a keyword given in a smart album of photos without keywords, a flag taken off in Picks, a rating under the filter bar's) keeps it in the view until `library.refresh`, Reshuffle, or another source, filter or sort; photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder shown still takes it out of the view. Changing a smart album's rules is another view too. Off (the default): the view follows every edit. Kept with the library's preferences; asking for the state it already has changes nothing", + always, + |s, p| { + let on = match p.get("on") { + None | Some(Value::Null) => !s.hold_view, + Some(Value::Bool(on)) => *on, + Some(_) => return Err(bad("library.holdView", "on must be true or false")), + }; + if on != s.hold_view { + let was = s.hold_view; + s.hold_view = on; + if let Err(e) = s.save_prefs() { + // not kept: not changed either, so asking again does what was asked + s.hold_view = was; + return Err(e); + } + s.refresh_view(); + } + Ok(json!({"hold": s.hold_view})) + } + ), cmd!( "library.shuffle", "Reshuffle", @@ -687,6 +757,40 @@ pub fn specs() -> Vec<CommandSpec> { advance_if(s, p); Ok(v) }), + // Classic: ` cycles the flag; Cmd+Up / Cmd+Down raise / lower it (Ctrl on Windows and Linux) + cmd!( + "photo.cycleFlag", + "Cycle Flag", + ["Photo", "Set Flag"], + Some("`"), + "{ids?, advance?: bool} — each photo's own flag: unflagged -> pick -> reject -> unflagged", + has_selection, + |s, p| { + change_flag(s, p, "Cycle Flag", |f| match f { + Flag::None => Flag::Pick, + Flag::Pick => Flag::Reject, + Flag::Reject => Flag::None, + }) + } + ), + cmd!( + "photo.increaseFlag", + "Increase Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Up"), + "{ids?, advance?: bool} — reject -> unflagged -> pick (stops at pick)", + has_selection, + |s, p| { change_flag(s, p, "Increase Flag Status", |f| if f == Flag::Reject { Flag::None } else { Flag::Pick }) } + ), + cmd!( + "photo.decreaseFlag", + "Decrease Flag Status", + ["Photo", "Set Flag"], + Some("Cmd+Down"), + "{ids?, advance?: bool} — pick -> unflagged -> reject (stops at reject)", + has_selection, + |s, p| { change_flag(s, p, "Decrease Flag Status", |f| if f == Flag::Pick { Flag::None } else { Flag::Reject }) } + ), cmd!("photo.pick", "Flag as Pick", ["Photo", "Set Flag"], Some("P"), "{ids?}", has_selection, |s, p| { for_targets(s, p, "Flag as Pick", |id| Some(Op::SetFlag { id, flag: Flag::Pick })) }), @@ -779,9 +883,10 @@ pub fn specs() -> Vec<CommandSpec> { // ---- delete / restore cmd!("photo.delete", "Delete Photo", ["Photo"], Some("Delete"), "{ids?} — moves to Recently Deleted", has_selection, |s, p| { let before = s.visible_cloned(); - let v = for_targets(s, p, "Delete", |id| Some(Op::SetDeleted { id, deleted: true }))?; + let targets = s.targets(p)?; + let n = mark_deleted(s, &targets, "Delete")?; select_after_deletion(s, &before); - Ok(v) + Ok(json!({"changed": n})) }), cmd!("photo.restore", "Restore", ["Photo"], None, "{ids?}", has_selection, |s, p| for_targets(s, p, "Restore", |id| Some(Op::SetDeleted { id, @@ -807,7 +912,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("photo.deletePermanently", "Delete Permanently", ["Photo"], None, "{ids?}", has_selection, |s, p| { let before = s.visible_cloned(); - let t = s.targets(p); + let t = s.targets(p)?; let op = s.catalog.delete_photos_permanently_ops(&t); s.commit("Delete Permanently", op)?; select_after_deletion(s, &before); @@ -824,7 +929,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { let strs = |k: &str| p.get(k).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect::<Vec<_>>()); - let targets = s.targets(p); + let targets = s.targets(p)?; // GPS: "lat, lon" (decimal or 51°30'26"N 0°7'39"W), [lat, lon], or null / "" to clear let gps: Option<Option<(f64, f64)>> = match p.get("gps") { None => None, @@ -922,8 +1027,7 @@ pub fn specs() -> Vec<CommandSpec> { } let folder = bool_or(p, "folder", false); let parent = p.get("parent").and_then(Value::as_u64).map(AlbumId); - let (photos, skipped) = - if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.targets(&Value::Null)) } else { (vec![], 0) }; + let (photos, skipped) = if !folder && bool_or(p, "addSelected", false) { album_targets(s, s.selected()) } else { (vec![], 0) }; let id = s.catalog.alloc_album_id(); let cover = photos.first().copied(); s.commit( @@ -1163,7 +1267,7 @@ pub fn specs() -> Vec<CommandSpec> { has_library_selection, |s, p| { let id = album_param(p, "id", "album.addPhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); + let targets = ids_param(p).unwrap_or_else(|| s.selected()); let al = s.catalog.album(id).ok_or_else(|| bad("album.addPhotos", "no such album"))?; if al.is_smart() || al.folder { return Err(bad("album.addPhotos", "smart albums and folders can't hold photos")); @@ -1171,8 +1275,10 @@ pub fn specs() -> Vec<CommandSpec> { let (targets, skipped) = album_targets_or_err(s, "album.addPhotos", targets)?; let mut photos = al.photos.clone(); let before = photos.len(); + // looked up, not searched for: adding thousands to a big album stays linear + let mut held: std::collections::HashSet<PhotoId> = photos.iter().copied().collect(); for t in targets { - if !photos.contains(&t) { + if held.insert(t) { photos.push(t); } } @@ -1190,7 +1296,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?} — B: adds the photos to the target album (the Quick Collection unless one is set), or removes them when they're all in it → {album, added, skipped}; Local photos are skipped", has_library_selection, |s, p| { - let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)))?; + let targets = album_targets_or_err(s, "album.toggleTarget", ids_param(p).unwrap_or_else(|| s.selected()))?; let (targets, skipped) = targets; let target = s.target_album.filter(|a| s.catalog.album(*a).is_some_and(|al| !al.is_smart() && !al.folder)); let id = match target.or_else(|| s.catalog.quick_collection()) { @@ -1203,11 +1309,14 @@ pub fn specs() -> Vec<CommandSpec> { } }; let al = s.catalog.album(id).ok_or_else(|| bad("album.toggleTarget", "no target album"))?; - let all_in = !targets.is_empty() && targets.iter().all(|t| al.photos.contains(t)); + // looked up, not searched for: thousands toggled in a big album stay linear + let mut held: std::collections::HashSet<PhotoId> = al.photos.iter().copied().collect(); + let all_in = !targets.is_empty() && targets.iter().all(|t| held.contains(t)); let photos: Vec<PhotoId> = if all_in { + let targets: std::collections::HashSet<PhotoId> = targets.iter().copied().collect(); al.photos.iter().copied().filter(|x| !targets.contains(x)).collect() } else { - al.photos.iter().copied().chain(targets.iter().copied().filter(|t| !al.photos.contains(t))).collect() + al.photos.iter().copied().chain(targets.iter().copied().filter(|t| held.insert(*t))).collect() }; let name = al.name.clone(); let cover = al.cover.filter(|c| photos.contains(c)).or(photos.first().copied()); @@ -1334,17 +1443,39 @@ pub fn specs() -> Vec<CommandSpec> { Ok(result) } ), - cmd!("album.removePhotos", "Remove from Album", [], None, "{id: albumId, ids?}", has_selection, |s, p| { - let id = album_param(p, "id", "album.removePhotos")?; - let targets = ids_param(p).unwrap_or_else(|| s.targets(&Value::Null)); - let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; - if al.is_smart() { - return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + cmd!( + "album.removePhotos", + "Remove from Album", + ["Photo"], + None, + "{id?: albumId (default: the album on screen), ids?} — takes the photos out of the album only: they stay in the library and in their folders (issue #1805; photo.delete removes them from the library) → {removed, album: name}", + has_selection, + |s, p| { + let id = match p.get("id") { + Some(_) => album_param(p, "id", "album.removePhotos")?, + None => match s.source { + LibrarySource::Album(a) => a, + _ => return Err(bad("album.removePhotos", "no album: pass `id` or show an album")), + }, + }; + let targets = ids_param(p).unwrap_or_else(|| s.selected()); + let al = s.catalog.album(id).ok_or_else(|| bad("album.removePhotos", "no such album"))?; + if al.is_smart() { + return Err(bad("album.removePhotos", "smart albums update automatically: change their rules")); + } + if al.folder { + return Err(bad("album.removePhotos", "album folders don't hold photos")); + } + let name = al.name.clone(); + let targets: std::collections::HashSet<PhotoId> = targets.into_iter().collect(); + let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); + let removed = al.photos.len().saturating_sub(photos.len()); + if removed > 0 { + s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; + } + Ok(json!({"removed": removed, "album": name})) } - let photos: Vec<PhotoId> = al.photos.iter().copied().filter(|x| !targets.contains(x)).collect(); - s.commit("Remove from Album", Op::SetAlbumPhotos { id, photos })?; - ok() - }), + ), cmd!("album.setCover", "Set as Album Cover", [], None, "{id: albumId, photo?: photoId}", has_active, |s, p| { let id = album_param(p, "id", "album.setCover")?; let photo = p.get("photo").and_then(Value::as_u64).map(PhotoId).or(s.active()); @@ -1357,7 +1488,7 @@ pub fn specs() -> Vec<CommandSpec> { always, |_, p| { let path = str_param(p,"path").ok_or_else(|| bad("library.inspectLightroom","missing path"))?; let data = crate::lightroom_catalog::read(std::path::Path::new(path)).map_err(|e| bad("library.inspectLightroom",e))?; - Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings})) + Ok(json!({"photos":data.photos.len(),"collections":data.collections.iter().filter(|r|r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0)==0.0).count(),"missing":data.photos.iter().filter(|p| !p.original_is_file()).map(|p|&p.path).collect::<Vec<_>>(),"warnings":data.warnings})) }), cmd!( "library.importLightroom", @@ -1623,3 +1754,7 @@ mod gps_tests { #[cfg(test)] #[path = "library/tests_relative_ratings.rs"] mod relative_rating_tests; + +#[cfg(test)] +#[path = "library/tests_flag_cycle.rs"] +mod flag_cycle_tests; diff --git a/crates/engine/src/cmd/library/tests_flag_cycle.rs b/crates/engine/src/cmd/library/tests_flag_cycle.rs new file mode 100644 index 000000000..6a476dc1d --- /dev/null +++ b/crates/engine/src/cmd/library/tests_flag_cycle.rs @@ -0,0 +1,100 @@ +//! `photo.cycleFlag` / `photo.increaseFlag` / `photo.decreaseFlag` through the real engine. +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::{Result, Selection, Session}; + +fn session() -> Result<Session> { + let mut s = Session::new(); + for (id, flag) in [(1, Flag::None), (2, Flag::Pick), (3, Flag::Reject), (4, Flag::None)] { + let mut photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-flag-cycle/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-13", + ); + photo.flag = flag; + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2}))?; + Ok(s) +} + +fn flags(s: &Session) -> Vec<Flag> { + (1..=4).map(|id| s.catalog.photo(PhotoId(id)).unwrap().flag).collect() +} + +#[test] +fn cycle_steps_unflagged_pick_reject_and_wraps() { + let mut s = session().unwrap(); + s.execute("library.select", &json!({"ids": [1]})).unwrap(); + let seen: Vec<Flag> = (0..4) + .map(|_| { + s.execute("photo.cycleFlag", &json!({})).unwrap(); + flags(&s)[0] + }) + .collect(); + assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None, Flag::Pick]); +} + +#[test] +fn cycle_uses_each_photos_own_flag_in_one_undo_step() { + let mut s = session().unwrap(); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.cycleFlag", &json!({})).unwrap()["changed"], 3); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.active(), Some(PhotoId(2))); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::None]); + s.execute("edit.redo", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Pick, Flag::Reject, Flag::None, Flag::None]); +} + +#[test] +fn explicit_ids_active_fallback_advance_and_errors() { + let mut s = session().unwrap(); + s.execute("photo.cycleFlag", &json!({"ids": [4]})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::Pick, Flag::Reject, Flag::Pick]); + s.selection = Selection { ids: vec![], active: Some(PhotoId(2)) }; + s.execute("photo.cycleFlag", &json!({})).unwrap(); + assert_eq!(flags(&s)[1], Flag::Reject); + s.execute("photo.cycleFlag", &json!({"advance": true})).unwrap(); + assert_eq!(flags(&s)[1], Flag::None); + assert_eq!(s.active(), Some(PhotoId(3))); + let undo = s.undo.len(); + assert!(s.execute("photo.cycleFlag", &json!({"ids": [1, 999]})).is_err()); + assert_eq!(flags(&s)[0], Flag::None); + assert_eq!(s.undo.len(), undo); + s.selection = Selection::default(); + assert!(s.execute("photo.cycleFlag", &json!({})).is_err()); + assert_eq!(s.undo.len(), undo); +} + +#[test] +fn increase_and_decrease_clamp_without_wrapping() { + let mut s = session().unwrap(); + assert_eq!(s.execute("photo.increaseFlag", &json!({})).unwrap()["changed"], 2); + assert_eq!(flags(&s), [Flag::Pick, Flag::Pick, Flag::None, Flag::None]); + assert_eq!(s.execute("photo.increaseFlag", &json!({"ids": [2]})).unwrap()["changed"], 0); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::None, Flag::None, Flag::Reject, Flag::None]); + s.execute("photo.decreaseFlag", &json!({})).unwrap(); + assert_eq!(flags(&s), [Flag::Reject, Flag::Reject, Flag::Reject, Flag::None]); + let undo = s.undo.len(); + assert_eq!(s.execute("photo.decreaseFlag", &json!({})).unwrap()["changed"], 0); + assert_eq!(s.undo.len(), undo, "no change, no history step"); +} + +#[test] +fn registered_with_classic_shortcuts() { + let specs = crate::command_specs(); + let key = |id: &str| specs.iter().find(|c| c.id == id).and_then(|c| c.shortcut); + assert_eq!(key("photo.cycleFlag"), Some("`")); + assert_eq!(key("photo.increaseFlag"), Some("Cmd+Up")); + assert_eq!(key("photo.decreaseFlag"), Some("Cmd+Down")); +} diff --git a/crates/engine/src/cmd/library/tests_relative_ratings.rs b/crates/engine/src/cmd/library/tests_relative_ratings.rs index 1a1b8b23c..8af63cd9b 100644 --- a/crates/engine/src/cmd/library/tests_relative_ratings.rs +++ b/crates/engine/src/cmd/library/tests_relative_ratings.rs @@ -77,6 +77,7 @@ fn relative_ratings_and_history_use_auto_write() { std::fs::create_dir_all(&dir).unwrap(); // Metadata-only fixtures: no import, image decoding or model/service dependency. for id in 1..=4 { + std::fs::write(dir.join(format!("{id}.jpg")), b"original").unwrap(); s.catalog .apply(Op::SetFile { id: PhotoId(id), diff --git a/crates/engine/src/cmd/lut_profiles.rs b/crates/engine/src/cmd/lut_profiles.rs index 349e66ea8..f03809b62 100644 --- a/crates/engine/src/cmd/lut_profiles.rs +++ b/crates/engine/src/cmd/lut_profiles.rs @@ -135,6 +135,8 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { imported.push(json!({"id": id, "name": display, "group": group})); } s.save_prefs()?; + // presets that name these profiles use them now + crate::profile_refs::refresh(s); Ok(json!({"imported": imported, "failed": failed})) } @@ -160,6 +162,7 @@ pub fn specs() -> Vec<CommandSpec> { } lightcraft_pipeline::lut::unregister(&id); s.save_prefs()?; + crate::profile_refs::refresh(s); Ok(json!({"deleted": id})) }), ] diff --git a/crates/engine/src/cmd/manage.rs b/crates/engine/src/cmd/manage.rs index d06271525..339b20b90 100644 --- a/crates/engine/src/cmd/manage.rs +++ b/crates/engine/src/cmd/manage.rs @@ -9,8 +9,13 @@ use crate::Result; fn rename_args(s: &crate::Session, p: &Value, c: &str) -> Result<(Vec<lightcraft_catalog::PhotoId>, String, usize)> { let template = str_param(p, "template").ok_or_else(|| bad(c, "missing `template`"))?.to_string(); - let start = p.get("start").and_then(Value::as_u64).unwrap_or(1) as usize; - Ok((s.targets(p), template, start)) + let start = p.get("start").and_then(Value::as_u64).unwrap_or(1); + let ids = s.targets(p)?; + // every number of the sequence `start..start + n` must fit + let last = start.checked_add((ids.len() as u64).saturating_sub(1)); + let start = last.and_then(|l| usize::try_from(l).ok()).and_then(|_| usize::try_from(start).ok()); + let start = start.ok_or_else(|| bad(c, "start out of range"))?; + Ok((ids, template, start)) } /// A named set of colour-label names (red, yellow, green, blue, purple; empty = the colour's name). @@ -96,7 +101,7 @@ fn auto_tag_tracklog(s: &mut crate::Session, p: &Value) -> Result<Value> { let (mut no_time, mut outside, mut has_gps, mut assumed_utc, mut interpolated) = (0, 0, 0, 0, 0); let mut ops = Vec::new(); let mut photos = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { let Some(ph) = s.catalog.photo(id) else { continue }; if ph.meta.gps.is_some() && !replace { has_gps += 1; @@ -183,7 +188,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { use lightcraft_catalog::dates::{iso_seconds, normalize_iso, shift_iso}; let c = "photo.setCaptureTime"; - let targets: Vec<_> = s.targets(p).into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); + let targets: Vec<_> = s.targets(p)?.into_iter().filter(|id| s.catalog.photo(*id).is_some()).collect(); if targets.is_empty() { return Err(bad(c, "no photos")); } diff --git a/crates/engine/src/cmd/mask_effect_files.rs b/crates/engine/src/cmd/mask_effect_files.rs new file mode 100644 index 000000000..e1e099bc8 --- /dev/null +++ b/crates/engine/src/cmd/mask_effect_files.rs @@ -0,0 +1,681 @@ +//! Mask effect preset files: Lightroom Classic local adjustment presets (`.lrtemplate`, Lua, +//! `type = "LocalizedAdjustmentPreset"`), Camera Raw local correction presets (`.xmp` with +//! `crs:Local*` fields, from its `LocalCorrections` folder), our `.lceffect` JSON, and `.zip` +//! bundles of these. Folders, and Lightroom users' "divider" presets (`10 ----- DETAIL ----- 10`, +//! empty presets that head a numbered section of the menu), become groups. +//! +//! Read from the files' public structure and black-box observation; settings we have no slider +//! for are reported and kept with the effect (an export carries them). + +use lightcraft_develop::LocalAdjustments; +use serde_json::{Map, Value, json}; + +use super::mask_effects::{MaskEffect, upsert}; +use super::{bad, str_param}; +use crate::preset_import::{Lua, group_from_dir, parse_lua, read_zip}; +use crate::{Result, Session}; + +/// File extension of exported effect presets (JSON). +pub const EFFECT_EXT: &str = "lceffect"; +/// `format` tag of an exported effect-preset file. +const FILE_FORMAT: &str = "lightcraft.maskEffects"; +/// The Lua `type` of a Lightroom local adjustment preset. +const LR_LOCAL_TYPE: &str = "LocalizedAdjustmentPreset"; +/// Larger files aren't presets (or bundles of them). +const MAX_FILE: u64 = 64 << 20; +/// How deep nested Lua tables are kept as JSON. +const MAX_DEPTH: usize = 16; + +/// An effect read from a file, with the settings it couldn't keep and where it came from. +#[derive(Clone, Debug)] +pub struct Found { + pub effect: MaskEffect, + pub unmapped: Vec<String>, + pub file: String, + /// Effects of one folder (or folder inside a bundle) share this key: divider presets group + /// the effects next to them. + pub dir: String, + /// The group the folder gives (when the file names none). + pub dir_group: Option<String>, + /// The file named its group (`.lceffect`, `crs:Group`): kept. + pub has_group: bool, +} + +/// A readable name for a Lightroom local setting we can't carry over. +fn setting_label(key: &str) -> String { + let k = key.trim_start_matches("Local"); + let lower = k.to_ascii_lowercase(); + match lower.as_str() { + "grain" => "Grain".into(), + "toningluminance" => "Color luminance".into(), + "pointcolors" => "Point Color".into(), + "refinesaturation" => "Refine Saturation".into(), + "curverefinesaturation" => "Refine Saturation".into(), + "maincurve" | "redcurve" | "greencurve" | "bluecurve" => "Curve".into(), + _ => k.to_string(), + } +} + +/// A Lua value as JSON (tables nested at most [`MAX_DEPTH`] deep). +fn lua_json(v: &Lua, depth: usize) -> Value { + match v { + Lua::Nil => Value::Null, + Lua::Bool(b) => json!(b), + Lua::Num(n) => serde_json::Number::from_f64(*n).map(Value::Number).unwrap_or(Value::Null), + Lua::Str(s) => json!(s), + Lua::Table(..) if depth >= MAX_DEPTH => Value::Null, + Lua::Table(items, fields) if fields.is_empty() => Value::Array(items.iter().map(|x| lua_json(x, depth + 1)).collect()), + Lua::Table(_, fields) => Value::Object(fields.iter().map(|(k, x)| (k.clone(), lua_json(x, depth + 1))).collect()), + } +} + +/// Does a setting value do anything (a non-zero number, a non-empty table, …)? +fn meaningful(v: &Value) -> bool { + match v { + Value::Null => false, + Value::Bool(b) => *b, + Value::Number(n) => n.as_f64().is_some_and(|x| x.abs() > 1e-9), + Value::String(s) => !s.is_empty(), + Value::Array(a) => !a.is_empty(), + Value::Object(o) => !o.is_empty(), + } +} + +/// Is `text` a Lightroom local adjustment preset (`.lrtemplate`)? +pub fn is_lr_local_template(text: &str) -> bool { + parse_lua(text.trim_start_matches('\u{feff}')) + .ok() + .and_then(|root| root.get("type").and_then(Lua::str).map(|t| t == LR_LOCAL_TYPE)) + .unwrap_or(false) +} + +/// `$$$/Path/Key=Default` (a localisable string) → `Default`. +fn delocalize(t: &str) -> &str { + t.strip_prefix("$$$/").and_then(|r| r.split_once('=')).map_or(t, |(_, d)| d) +} + +/// A Lightroom local adjustment preset → an effect named after its title (else `stem`), and the +/// settings we have no equivalent for (e.g. `Grain`, `Point Color`), which the effect keeps in +/// `extra`. +pub fn parse_lr_local_template(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let root = parse_lua(text.trim_start_matches('\u{feff}'))?; + if root.get("type").and_then(Lua::str) != Some(LR_LOCAL_TYPE) { + return Err("not a local adjustment preset".into()); + } + let Some(Lua::Table(_, fields)) = root.get("value") else { return Err("no adjustments in this local preset".into()) }; + // `exposure2012` → `LocalExposure2012`: the template's keys are the XMP `Local*` names, lower camel case + let crs = |k: &str| { + let mut c = k.chars(); + c.next().map(|f| format!("Local{}{}", f.to_ascii_uppercase(), c.as_str())).unwrap_or_default() + }; + let num = |k: &str| fields.iter().find(|(f, _)| crs(f) == k).and_then(|(_, v)| if let Lua::Num(n) = v { Some(*n) } else { None }); + let values = crate::crs_masks::local_values(num); + let mut extra = Map::new(); + for (k, v) in fields { + if crate::crs_masks::is_local_field(&crs(k)) { + continue; + } + let j = lua_json(v, 0); + if meaningful(&j) { + extra.insert(k.clone(), j); + } + } + let title = root.get("title").or_else(|| root.get("internalName")).and_then(Lua::str).map(str::trim).filter(|t| !t.is_empty()); + let name = title.map(|t| delocalize(t).trim().to_string()).filter(|t| !t.is_empty()).unwrap_or_else(|| stem.to_string()); + build(name, values, extra) +} + +fn build(name: String, values: Map<String, Value>, extra: Map<String, Value>) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let adjust: LocalAdjustments = serde_json::from_value(Value::Object(values)).map_err(|e| format!("unreadable local preset: {e}"))?; + let mut unmapped: Vec<String> = extra.keys().map(|k| setting_label(k)).collect(); + unmapped.sort(); + unmapped.dedup(); + let effect = MaskEffect { name, adjust, extra, ..Default::default() }.normalized(); + Ok((effect, unmapped)) +} + +/// The local slider fields of a Camera Raw local correction preset: its top-level `crs:Local*` +/// properties, or (a preset typed `Local` without them) its first correction's. +fn xmp_local_fields(d: &lightcraft_meta::XmpData) -> Option<Map<String, Value>> { + let mut out = Map::new(); + for (k, v) in &d.properties { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local") && !n.contains('/')) + && let Some(x) = v.first() + { + out.insert(name.to_string(), json!(x.trim())); + } + } + if !out.is_empty() { + return Some(out); + } + let typed_local = d.properties.get("crs:PresetType").and_then(|v| v.first()).is_some_and(|t| t.trim().eq_ignore_ascii_case("local")); + if !typed_local { + return None; + } + let correction = crate::crs_masks::CONTAINERS.iter().find_map(|c| d.values.get(*c).and_then(|l| l.items().first()))?; + let lightcraft_meta::XmpValue::Struct(m) = correction else { return None }; + for (k, v) in m { + if let Some(name) = k.strip_prefix("crs:").filter(|n| n.starts_with("Local")) + && let Some(x) = v.text() + { + out.insert(name.to_string(), json!(x)); + } + } + Some(out) +} + +/// Is `text` a Camera Raw local correction preset (XMP)? +pub fn is_local_xmp(text: &str) -> bool { + lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).ok().and_then(|d| xmp_local_fields(&d)).is_some() +} + +/// A Camera Raw local correction preset (XMP) → an effect named after `crs:Name` (else `stem`), +/// in `crs:Group` if it names one. +pub fn parse_local_xmp(text: &str, stem: &str) -> std::result::Result<(MaskEffect, Vec<String>), String> { + let d = lightcraft_meta::parse_xmp(text.trim_start_matches('\u{feff}')).map_err(|e| e.to_string())?; + let fields = xmp_local_fields(&d).ok_or("not a local correction preset (no crs:Local… settings)")?; + let num = |v: &Value| v.as_str().map(|s| s.strip_prefix('+').unwrap_or(s)).and_then(|s| s.parse::<f64>().ok()).filter(|x| x.is_finite()); + let values = crate::crs_masks::local_values(|k| fields.get(k).and_then(num)); + let mut extra = Map::new(); + for (k, v) in &fields { + if crate::crs_masks::is_local_field(k) { + continue; + } + let v = num(v).map_or_else(|| v.clone(), |x| json!(x)); + if meaningful(&v) { + extra.insert(k.clone(), v); + } + } + let first = |k: &str| d.properties.get(k).and_then(|v| v.first()).map(|s| s.trim().to_string()).filter(|s| !s.is_empty()); + let name = first("crs:Name").unwrap_or_else(|| stem.to_string()); + let (mut e, unmapped) = build(name, values, extra)?; + e.group = first("crs:Group").unwrap_or_default(); + Ok((e, unmapped)) +} + +/// Effects in an `.lceffect` file: `{format, version, effects: [...]}`, a bare array, or one effect. +pub fn parse_file(bytes: &[u8]) -> std::result::Result<Vec<MaskEffect>, String> { + let v: Value = serde_json::from_slice(bytes).map_err(|e| format!("not an effect preset file: {e}"))?; + let items = match v { + Value::Object(mut o) if o.contains_key("effects") => o.remove("effects").unwrap_or_default(), + v @ Value::Array(_) => v, + v @ Value::Object(_) => Value::Array(vec![v]), + _ => return Err("not an effect preset file".into()), + }; + let list: Vec<MaskEffect> = serde_json::from_value(items).map_err(|e| format!("not an effect preset file: {e}"))?; + let list: Vec<MaskEffect> = list.into_iter().filter(|e| !e.name.trim().is_empty()).map(MaskEffect::normalized).collect(); + if list.is_empty() { + return Err("no effect presets in the file".into()); + } + Ok(list) +} + +fn ext_of(name: &str) -> String { + std::path::Path::new(name).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default() +} + +fn stem_of(name: &str) -> String { + std::path::Path::new(name).file_stem().map(|s| s.to_string_lossy().to_string()).filter(|s| !s.is_empty()).unwrap_or_else(|| "Effect".into()) +} + +type Parsed = std::result::Result<Vec<(MaskEffect, Vec<String>)>, String>; + +/// The effects in file `name` (`bytes`) if it is a local preset (`.lrtemplate` of the local type, +/// a local correction `.xmp`, an `.lceffect`); `None` for any other file (a develop preset…). +pub fn parse_bytes(name: &str, bytes: &[u8]) -> Option<Parsed> { + let stem = stem_of(name); + match ext_of(name).as_str() { + "lrtemplate" => { + let text = String::from_utf8_lossy(bytes); + is_lr_local_template(&text).then(|| parse_lr_local_template(&text, &stem).map(|e| vec![e])) + } + "xmp" => { + let text = String::from_utf8_lossy(bytes); + is_local_xmp(&text).then(|| parse_local_xmp(&text, &stem).map(|e| vec![e])) + } + EFFECT_EXT => Some(parse_file(bytes).map(|v| v.into_iter().map(|e| (e, Vec::new())).collect())), + _ => None, + } +} + +/// The group effects in folder `dir` (relative to the imported folder) go to. +pub fn group_for_dir(dir: &str) -> Option<String> { + effect_group_from_dir(dir) +} + +/// Folder names that only say "local presets" (`Local Adjustment Presets`, `LocalCorrections`…) +/// give no group. +fn effect_group_from_dir(dir: &str) -> Option<String> { + const FILLER: &[&str] = &[ + "local", + "adjustment", + "adjustments", + "localcorrections", + "corrections", + "correction", + "brush", + "brushes", + "mask", + "masks", + "effect", + "effects", + ]; + let generic = |part: &str| { + let lower = part.to_ascii_lowercase(); + let words: Vec<&str> = lower.split(|c: char| !c.is_alphanumeric()).filter(|w| !w.is_empty()).collect(); + words.iter().any(|w| FILLER.contains(w)) + && words.iter().all(|w| FILLER.contains(w) || matches!(*w, "presets" | "preset" | "lightroom" | "camera" | "raw" | "classic")) + }; + let kept: Vec<&str> = dir.split(['/', '\\']).filter(|p| !p.is_empty() && !generic(p)).collect(); + group_from_dir(&kept.join("/")) +} + +/// Effects found in `bytes` of file `file` (a `.zip` is opened: the local presets inside). +/// `dir_group`: the group the file's folder gives. Errors are `(file, message)`. +pub fn found_in(file: &str, bytes: &[u8], dir_group: Option<String>, out: &mut Vec<Found>, failed: &mut Vec<(String, String)>) -> bool { + let dir = std::path::Path::new(file).parent().map(|d| d.to_string_lossy().to_string()).unwrap_or_default(); + if ext_of(file) == "zip" { + let Ok(entries) = read_zip(bytes) else { return false }; + let zip_group = effect_group_from_dir(&stem_of(file)).or(dir_group); + let mut any = false; + for (inner, data) in entries { + let inner_dir = inner.rsplit_once('/').map(|(d, _)| d.to_string()).unwrap_or_default(); + let group = effect_group_from_dir(&inner_dir).or_else(|| zip_group.clone()); + let label = format!("{file}/{inner}"); + match parse_bytes(&inner, &data) { + Some(Ok(list)) => { + any = true; + push(out, list, &label, format!("{file}/{inner_dir}"), group, &inner); + } + Some(Err(e)) => { + any = true; + failed.push((label, e)); + } + None => {} + } + } + return any; + } + match parse_bytes(file, bytes) { + Some(Ok(list)) => { + push(out, list, file, dir, dir_group, file); + true + } + Some(Err(e)) => { + failed.push((file.to_string(), e)); + true + } + None => false, + } +} + +fn push(out: &mut Vec<Found>, list: Vec<(MaskEffect, Vec<String>)>, file: &str, dir: String, dir_group: Option<String>, name: &str) { + let named = ext_of(name) == EFFECT_EXT; + for (effect, unmapped) in list { + let has_group = named || !effect.group.is_empty(); + out.push(Found { effect, unmapped, file: file.to_string(), dir: dir.clone(), dir_group: dir_group.clone(), has_group }); + } +} + +/// The number a preset name starts with (`24a-Eye` → 24). +fn leading_number(name: &str) -> Option<u64> { + let digits: String = name.trim().chars().take_while(char::is_ascii_digit).take(18).collect(); + digits.parse().ok() +} + +/// The section title of a divider preset — an effect that sets nothing, named like +/// `10 ----- DETAIL ENHANCERS ----- 10` (a run of at least three `-`, `=`, `_`, `*`, `#`, `~`, `—`). +pub fn divider_title(e: &MaskEffect) -> Option<String> { + if !e.is_empty() { + return None; + } + let marks = ['-', '=', '_', '*', '#', '~', '—', '–', '•', '.']; + let chars: Vec<char> = e.name.chars().collect(); + let has_run = chars.windows(3).any(|w| marks.contains(&w[0]) && w[0] == w[1] && w[1] == w[2]); + if !has_run { + return None; + } + let title: String = e.name.chars().map(|c| if marks.contains(&c) { ' ' } else { c }).collect(); + let title = title.trim().trim_start_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.trim_end_matches(|c: char| c.is_ascii_digit() || c.is_whitespace()); + let title = title.split_whitespace().collect::<Vec<_>>().join(" "); + if !title.chars().any(char::is_alphanumeric) { + return None; + } + // "DETAIL ENHANCERS" → "Detail Enhancers" + let shouting = title.chars().any(char::is_alphabetic) && !title.chars().any(char::is_lowercase); + Some(if shouting { + title + .split(' ') + .map(|w| { + let mut c = w.chars(); + c.next().map(|f| f.to_uppercase().chain(c.flat_map(char::to_lowercase)).collect::<String>()).unwrap_or_default() + }) + .collect::<Vec<_>>() + .join(" ") + } else { + title + }) +} + +/// Give `found` their groups; returns the divider presets (which become groups, not effects) as +/// `(found, group)`. A numbered effect goes to the divider with the largest number not above its +/// own (`12 Sky` under `10 --- DETAIL --- 10`); others to their folder's group. +pub fn assign_groups(found: Vec<Found>, group: Option<&str>) -> (Vec<Found>, Vec<(Found, String)>) { + let (dividers, mut effects): (Vec<(Found, Option<String>)>, Vec<(Found, Option<String>)>) = + found.into_iter().map(|f| (divider_title(&f.effect).filter(|_| !f.has_group), f)).map(|(t, f)| (f, t)).partition(|(_, t)| t.is_some()); + let dividers: Vec<(Found, String)> = dividers.into_iter().filter_map(|(f, t)| t.map(|t| (f, t))).collect(); + for (f, _) in &mut effects { + if let Some(g) = group { + f.effect.group = g.to_string(); + continue; + } + if f.has_group { + continue; + } + let section = leading_number(&f.effect.name).and_then(|n| { + dividers + .iter() + .filter(|(d, _)| d.dir == f.dir) + .filter_map(|(d, t)| leading_number(&d.effect.name).filter(|m| *m <= n).map(|m| (m, t))) + .max_by_key(|(m, _)| *m) + .map(|(_, t)| t.clone()) + }); + f.effect.group = section.or_else(|| f.dir_group.clone()).unwrap_or_default(); + } + (effects.into_iter().map(|(f, _)| f).collect(), dividers) +} + +/// Add the found effects (grouped; same-named ones replaced; effects left from an earlier import +/// of a divider dropped) and save → the report rows `(imported, dividers)`. +pub fn add_found(s: &mut Session, found: Vec<Found>, group: Option<&str>, dry: bool) -> Result<(Vec<Value>, Vec<Value>)> { + let (effects, dividers) = assign_groups(found, group); + let rows: Vec<Value> = + effects.iter().map(|f| json!({"name": f.effect.name, "group": f.effect.group, "file": f.file, "unmapped": f.unmapped})).collect(); + let div_rows: Vec<Value> = dividers.iter().map(|(f, g)| json!({"name": f.effect.name, "group": g, "file": f.file})).collect(); + if dry || (effects.is_empty() && dividers.is_empty()) { + return Ok((rows, div_rows)); + } + s.mask_effects.retain(|e| !(e.is_empty() && dividers.iter().any(|(d, _)| d.effect.name.eq_ignore_ascii_case(&e.name)))); + for f in effects { + upsert(s, f.effect); + } + s.save_prefs()?; + Ok((rows, div_rows)) +} + +/// The files under `paths` an effect import reads (folders walked, at most 8 levels), each with +/// the group its folder gives and whether it was named (not found in a folder). +fn effect_files(paths: &[String]) -> Vec<(String, Option<String>, bool)> { + fn walk(dir: &std::path::Path, base: &std::path::Path, depth: usize, out: &mut Vec<(String, Option<String>, bool)>) { + let Ok(rd) = std::fs::read_dir(dir) else { return }; + let mut entries: Vec<_> = rd.flatten().map(|e| e.path()).collect(); + entries.sort(); + for p in entries { + if p.is_dir() { + if depth < 8 { + walk(&p, base, depth + 1, out); + } + } else if p.extension().is_some_and(|e| ["lrtemplate", EFFECT_EXT, "xmp", "zip"].iter().any(|x| e.eq_ignore_ascii_case(x))) { + let rel = p.parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); + out.push((p.to_string_lossy().to_string(), rel.and_then(|d| effect_group_from_dir(&d)), false)); + } + } + } + let mut out = Vec::new(); + for x in paths { + let p = std::path::Path::new(x); + if p.is_dir() { + walk(p, p.parent().unwrap_or(std::path::Path::new("")), 0, &mut out); + } else { + out.push((x.clone(), None, true)); + } + } + out +} + +/// Read a file of at most [`MAX_FILE`] bytes. +pub fn read_capped(path: &str) -> std::result::Result<Vec<u8>, String> { + let len = std::fs::metadata(path).map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err(format!("too large for a preset file ({} MB)", len >> 20)); + } + std::fs::read(path).map_err(|e| e.to_string()) +} + +pub fn import_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.importEffects"; + let mut paths: Vec<String> = + p.get("paths").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()).unwrap_or_default(); + if let Some(x) = str_param(p, "path") { + paths.push(x.to_string()); + } + if paths.is_empty() { + return Err(bad(C, "missing `path` / `paths`")); + } + let group = str_param(p, "group").map(str::trim).filter(|g| !g.is_empty()); + let dry = p.get("dryRun").and_then(Value::as_bool).unwrap_or(false); + let (mut found, mut failed) = (Vec::new(), Vec::new()); + for (f, dir_group, named) in effect_files(&paths) { + match read_capped(&f) { + Ok(bytes) => { + if !found_in(&f, &bytes, dir_group, &mut found, &mut failed) && named { + failed.push((f, "not a local adjustment preset".into())); + } + } + Err(e) => failed.push((f, e)), + } + } + let (imported, dividers) = add_found(s, found, group, dry)?; + Ok(json!({"imported": imported, "dividers": dividers, "failed": failed.into_iter().map(|(f, e)| json!([f, e])).collect::<Vec<_>>()})) +} + +pub fn export_effects(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.exportEffects"; + let path = str_param(p, "path").filter(|x| !x.trim().is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let mut path = std::path::PathBuf::from(path); + if path.extension().is_none() { + path.set_extension(EFFECT_EXT); + } + let names: Option<Vec<String>> = + p.get("names").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()); + let group = str_param(p, "group").map(str::trim); + let chosen: Vec<&MaskEffect> = s + .mask_effects + .iter() + .filter(|e| names.as_ref().is_none_or(|n| n.iter().any(|x| x.eq_ignore_ascii_case(&e.name)))) + .filter(|e| group.is_none_or(|g| g.eq_ignore_ascii_case(&e.group))) + .collect(); + if chosen.is_empty() { + return Err(bad(C, "no effect presets to export")); + } + if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; + } + let text = serde_json::to_string_pretty(&json!({"format": FILE_FORMAT, "version": 2, "effects": chosen})) + .map_err(|e| crate::EngineError::Other(e.to_string()))?; + std::fs::write(&path, text).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) +} + +#[cfg(test)] +mod tests { + use super::*; + + // A synthetic template in the shape Lightroom writes (our own values). + const TEMPLATE: &str = r#"s = { + id = "00000000-0000-0000-0000-000000000001", + internalName = "Soft Glow", + title = "Soft Glow", + type = "LocalizedAdjustmentPreset", + value = { + clarity = 0.5, + clarity2012 = -0.25, + exposure = 0, + exposure2012 = 0.125, + grain = 0.25, + highlights2012 = -0.5, + luminanceNoise = 0.1, + refineSaturation = 0, + temperature = 0.2, + texture = -0.3, + toningHue = 240, + toningLuminance = 0.1, + toningSaturation = 0, + pointColors = { + { + HueRange = { LowerFull = 0.3, LowerNone = 0, UpperFull = 0.6, UpperNone = 1, }, + HueShift = -0.05, + RangeAmount = 0.5, + SrcHue = 0.8, + }, + }, + }, + version = 0, +}"#; + + #[test] + fn reads_a_lightroom_local_template() { + assert!(is_lr_local_template(TEMPLATE)); + let (e, unmapped) = parse_lr_local_template(TEMPLATE, "file").unwrap(); + assert_eq!(e.name, "Soft Glow"); + assert!((e.adjust.exposure - 0.5).abs() < 1e-9, "exposure2012 is a quarter of the stops"); + assert!((e.adjust.clarity + 25.0).abs() < 1e-9, "the 2012 field wins over the old one"); + assert!((e.adjust.highlights + 50.0).abs() < 1e-9); + assert!((e.adjust.temp - 20.0).abs() < 1e-9); + assert!((e.adjust.texture + 30.0).abs() < 1e-9); + assert!((e.adjust.noise - 10.0).abs() < 1e-9); + assert!((e.adjust.amount - 100.0).abs() < 1e-9); + assert_eq!(unmapped, vec!["Color luminance".to_string(), "Grain".into(), "Point Color".into()]); + assert_eq!(e.extra.get("grain"), Some(&json!(0.25)), "kept for export"); + assert_eq!(e.extra["pointColors"][0]["HueRange"]["UpperFull"], json!(0.6)); + assert!(!e.extra.contains_key("refineSaturation"), "zero settings aren't kept"); + } + + #[test] + fn develop_templates_are_not_local_ones() { + let develop = r#"s = { title = "X", type = "Develop", value = { settings = { Exposure2012 = 1 } } }"#; + assert!(!is_lr_local_template(develop)); + assert!(parse_lr_local_template(develop, "x").is_err()); + assert!(parse_bytes("x.lrtemplate", develop.as_bytes()).is_none(), "left to the develop preset import"); + } + + #[test] + fn malformed_files_are_errors_not_panics() { + for bad in [ + "garbage {", + "", + "s = {", + r#"s = { type = "LocalizedAdjustmentPreset", value = 3 }"#, + r#"s = { type = "LocalizedAdjustmentPreset" }"#, + r#"s = { type = "LocalizedAdjustmentPreset", value = { exposure2012 = "x", grain = {{{{}}}} } }"#, + "s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 1e400, contrast2012 = -1e400 } }", + "\u{feff}s = { type = \"LocalizedAdjustmentPreset\", title = \"\", value = {} }", + ] { + let _ = parse_lr_local_template(bad, "x"); + let _ = parse_bytes("x.lrtemplate", bad.as_bytes()); + } + // deeply nested tables + let deep = format!("s = {{ type = \"LocalizedAdjustmentPreset\", value = {{ pointColors = {}1{} }} }}", "{".repeat(200), "}".repeat(200)); + let _ = parse_lr_local_template(&deep, "x"); + let (e, _) = parse_lr_local_template("s = { type = \"LocalizedAdjustmentPreset\", value = { exposure2012 = 9 } }", "Stem").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.exposure), ("Stem", 4.0), "values clamp, the file name names it"); + for bad in ["", "<x:xmpmeta", "<x:xmpmeta></x:xmpmeta>", "\u{0}\u{1}"] { + assert!(parse_bytes("x.xmp", bad.as_bytes()).is_none_or(|r| r.is_err())); + } + for bad in ["", "3", "[]", "{\"effects\": 3}", "[{\"name\": 4}]", "{\"effects\": [{\"name\": \" \"}]}"] { + assert!(parse_file(bad.as_bytes()).is_err(), "{bad}"); + } + assert!(!found_in("x.zip", b"PK\x05\x06garbage", None, &mut Vec::new(), &mut Vec::new())); + } + + /// A Camera Raw local correction preset (our own values). + const ACR: &str = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" + crs:PresetType="Local" crs:LocalExposure2012="+0.25" crs:LocalShadows2012="0.4" crs:LocalGrain="0.2" crs:LocalTexture="0"> + <crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Lift Shadows</rdf:li></rdf:Alt></crs:Name> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Faces</rdf:li></rdf:Alt></crs:Group> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + + #[test] + fn reads_a_camera_raw_local_preset() { + assert!(is_local_xmp(ACR)); + let (e, unmapped) = parse_local_xmp(ACR, "file").unwrap(); + assert_eq!((e.name.as_str(), e.group.as_str()), ("Lift Shadows", "Faces")); + assert_eq!((e.adjust.exposure, e.adjust.shadows), (1.0, 40.0)); + assert_eq!(unmapped, vec!["Grain".to_string()]); + // a develop preset is not one + let develop = ACR + .replace( + "crs:LocalExposure2012=\"+0.25\" crs:LocalShadows2012=\"0.4\" crs:LocalGrain=\"0.2\" crs:LocalTexture=\"0\"", + "crs:Exposure2012=\"1\"", + ) + .replace("PresetType=\"Local\"", "PresetType=\"Normal\""); + assert!(!is_local_xmp(&develop)); + // a preset typed Local that keeps its sliders in a correction + let nested = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local"> + <crs:MaskGroupBasedCorrections><rdf:Seq><rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.5"/></rdf:li></rdf:Seq></crs:MaskGroupBasedCorrections> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let (e, _) = parse_local_xmp(nested, "Soft").unwrap(); + assert_eq!((e.name.as_str(), e.adjust.clarity), ("Soft", -50.0)); + } + + fn found(name: &str, exposure: f64, dir: &str) -> Found { + Found { + effect: MaskEffect { name: name.into(), adjust: LocalAdjustments { exposure, ..Default::default() }, ..Default::default() }, + unmapped: Vec::new(), + file: format!("{dir}/{name}.lrtemplate"), + dir: dir.into(), + dir_group: None, + has_group: false, + } + } + + #[test] + fn dividers_become_groups() { + let e = |n: &str| MaskEffect { name: n.into(), ..Default::default() }; + assert_eq!(divider_title(&e("10 ----- DETAIL ENHANCERS ----- 10")).as_deref(), Some("Detail Enhancers")); + assert_eq!(divider_title(&e("50--------MISC-----50")).as_deref(), Some("Misc")); + assert_eq!(divider_title(&e("=== Portraits ===")).as_deref(), Some("Portraits")); + assert_eq!(divider_title(&e("11_General_-_All_Purpose")), None, "no run of marks"); + assert_eq!(divider_title(&e("-----")), None, "no title"); + let busy = MaskEffect { adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..e("20 --- X --- 20") }; + assert_eq!(divider_title(&busy), None, "a divider sets nothing"); + + let list = vec![ + found("01 Burn", -1.0, "a"), + found("10 ----- DETAIL ENHANCERS ----- 10", 0.0, "a"), + found("12 Sky", 0.5, "a"), + found("20 ----- RETOUCHING ----- 20", 0.0, "a"), + found("24a-Eye-Cleaner", 0.1, "a"), + found("99 Late", 0.1, "a"), + found("Burn (Darken)", -0.5, "a"), + Found { dir_group: Some("Other".into()), ..found("15 Elsewhere", 0.2, "b") }, + ]; + let (effects, dividers) = assign_groups(list.clone(), None); + let groups: Vec<(&str, &str)> = effects.iter().map(|f| (f.effect.name.as_str(), f.effect.group.as_str())).collect(); + assert_eq!( + groups, + vec![ + ("01 Burn", ""), + ("12 Sky", "Detail Enhancers"), + ("24a-Eye-Cleaner", "Retouching"), + ("99 Late", "Retouching"), + ("Burn (Darken)", ""), + ("15 Elsewhere", "Other"), + ] + ); + assert_eq!(dividers.len(), 2); + let (effects, _) = assign_groups(list, Some("Mine")); + assert!(effects.iter().all(|f| f.effect.group == "Mine")); + } + + #[test] + fn folder_names_that_only_say_local_presets_give_no_group() { + assert_eq!(effect_group_from_dir("Local Adjustment Presets"), None); + assert_eq!(effect_group_from_dir("LocalCorrections"), None); + assert_eq!(effect_group_from_dir("Local Adjustment Presets/Portrait Brushes"), Some("Portrait Brushes".into())); + assert_eq!(effect_group_from_dir("Skin Kit"), Some("Skin Kit".into())); + } +} diff --git a/crates/engine/src/cmd/mask_effects.rs b/crates/engine/src/cmd/mask_effects.rs new file mode 100644 index 000000000..387909a6d --- /dev/null +++ b/crates/engine/src/cmd/mask_effects.rs @@ -0,0 +1,756 @@ +//! Mask effect presets: named sets of local adjustment sliders picked from the Masking panel's +//! Effect menu (Lightroom's local adjustment / brush presets), saved with the library preferences. +//! Grouped (folders in the menu), favourites, partial presets (only some sliders, with or without +//! Amount) and optional brush settings. Files: [`super::mask_effect_files`]. + +use std::cmp::Ordering; + +use lightcraft_catalog::PhotoId; +use lightcraft_develop::{DevelopSettings, LocalAdjustments, Mask}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use super::mask_effect_files as files; +use super::{CommandSpec, always, bad, cmd, has_active, str_param}; +use crate::{Result, Session}; + +/// The local sliders an effect can set (`LocalAdjustments` field names; Amount aside). +pub const FIELDS: &[&str] = &[ + "temp", + "tint", + "exposure", + "contrast", + "highlights", + "shadows", + "whites", + "blacks", + "texture", + "clarity", + "dehaze", + "hue", + "saturation", + "sharpness", + "noise", + "moire", + "defringe", + "color_hue", + "color_sat", +]; + +/// Brush settings an effect can carry (Lightroom keeps these per brush, not in presets). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct BrushSettings { + /// Radius as a fraction of the image's long edge. + pub size: f64, + /// 0..100. + pub feather: f64, + pub flow: f64, + pub density: f64, + pub auto_mask: bool, +} + +impl Default for BrushSettings { + fn default() -> Self { + Self { size: 0.04, feather: 50.0, flow: 100.0, density: 100.0, auto_mask: false } + } +} + +impl BrushSettings { + fn clamped(self) -> Self { + let c = |v: f64, lo: f64, hi: f64, d: f64| if v.is_finite() { v.clamp(lo, hi) } else { d }; + let d = Self::default(); + Self { + size: c(self.size, 0.002, 0.5, d.size), + feather: c(self.feather, 0.0, 100.0, d.feather), + flow: c(self.flow, 1.0, 100.0, d.flow), + density: c(self.density, 1.0, 100.0, d.density), + auto_mask: self.auto_mask, + } + } +} + +/// A named set of local adjustment sliders. Applying one sets the sliders it names (all of them +/// when `fields` is empty, as Lightroom's presets do: those it doesn't change go to 0); the mask's +/// Amount stays unless `with_amount`. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct MaskEffect { + pub name: String, + pub adjust: LocalAdjustments, + /// The Effect menu folder ("" = none). + #[serde(skip_serializing_if = "String::is_empty")] + pub group: String, + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub favorite: bool, + /// The sliders this effect sets ([`FIELDS`] names); empty = all of them. + #[serde(skip_serializing_if = "Vec::is_empty")] + pub fields: Vec<String>, + /// Applying sets the mask's Amount to `adjust.amount` too. + #[serde(skip_serializing_if = "std::ops::Not::not")] + pub with_amount: bool, + /// Brush settings the brush takes when the effect is applied. + #[serde(skip_serializing_if = "Option::is_none")] + pub brush: Option<BrushSettings>, + /// Settings of an imported preset that we have no slider for (Lightroom key → value), kept so + /// an export carries them. + #[serde(skip_serializing_if = "Map::is_empty")] + pub extra: Map<String, Value>, +} + +impl Default for MaskEffect { + fn default() -> Self { + Self { + name: String::new(), + adjust: LocalAdjustments::default(), + group: String::new(), + favorite: false, + fields: Vec::new(), + with_amount: false, + brush: None, + extra: Map::new(), + } + } +} + +/// Field `k` of `a` (0 for an unknown name). +pub fn get(a: &LocalAdjustments, k: &str) -> f64 { + serde_json::to_value(a).ok().and_then(|v| v.get(k).and_then(Value::as_f64)).unwrap_or(0.0) +} + +/// `a` with field `k` set to `v` (unchanged for an unknown name). +pub fn set(a: &LocalAdjustments, k: &str, v: f64) -> LocalAdjustments { + let Ok(Value::Object(mut o)) = serde_json::to_value(a) else { return *a }; + if !o.contains_key(k) || !v.is_finite() { + return *a; + } + o.insert(k.into(), json!(v)); + serde_json::from_value(Value::Object(o)).unwrap_or(*a) +} + +/// The sliders of `a` away from 0 (the colour's hue only with its saturation). +pub fn changed_fields(a: &LocalAdjustments) -> Vec<String> { + let mut out: Vec<String> = FIELDS.iter().filter(|k| get(a, k).abs() > 1e-9 && **k != "color_hue").map(|k| k.to_string()).collect(); + if a.color_sat.abs() > 1e-9 { + out.push("color_hue".into()); + } + out +} + +impl MaskEffect { + /// The sliders this effect sets. + pub fn field_list(&self) -> Vec<&str> { + if self.fields.is_empty() { FIELDS.to_vec() } else { FIELDS.iter().copied().filter(|k| self.fields.iter().any(|f| f == k)).collect() } + } + /// `a` with this preset's sliders (and Amount, if it carries it). + pub fn apply(&self, a: &LocalAdjustments) -> LocalAdjustments { + let mut out = if self.fields.is_empty() { + LocalAdjustments { amount: a.amount, ..self.adjust } + } else { + self.field_list().iter().fold(*a, |acc, k| set(&acc, k, get(&self.adjust, k))) + }; + if self.with_amount { + out.amount = self.adjust.amount; + } + out + } + /// Does `a` carry exactly this preset's sliders? A tint's hue doesn't count without its + /// saturation (it does nothing then). + pub fn matches(&self, a: &LocalAdjustments) -> bool { + let fields = self.field_list(); + let colorless = |x: &LocalAdjustments| x.color_sat.abs() < 1e-9; + let same = |k: &str| (get(&self.adjust, k) - get(a, k)).abs() < 1e-6; + fields.iter().all(|k| *k == "color_hue" && colorless(&self.adjust) && colorless(a) || same(k)) + && (!self.with_amount || (self.adjust.amount - a.amount).abs() < 1e-6) + } + /// Does the effect set nothing (all its sliders at 0, nothing kept from an import)? + pub fn is_empty(&self) -> bool { + self.extra.is_empty() && changed_fields(&self.adjust).iter().all(|k| !self.field_list().contains(&k.as_str())) + } + /// The same effect with its values in range and its field list normalised. + pub fn normalized(mut self) -> Self { + self.name = self.name.trim().to_string(); + self.group = self.group.trim().to_string(); + let amount = if self.with_amount { self.adjust.amount } else { 100.0 }; + self.adjust = super::masks::clamp_local(LocalAdjustments { amount, ..self.adjust }); + self.fields.retain(|f| FIELDS.contains(&f.as_str())); + if self.fields.iter().any(|f| f == "color_sat") && !self.fields.iter().any(|f| f == "color_hue") { + self.fields.push("color_hue".into()); + } + self.fields.sort_by_key(|f| FIELDS.iter().position(|k| k == f)); + self.fields.dedup(); + if self.fields.len() == FIELDS.len() { + self.fields.clear(); + } + self.brush = self.brush.map(BrushSettings::clamped); + self + } +} + +/// Compare names the way people number their presets: digit runs as numbers ("2 …" before +/// "10 …"), letters ignoring case. +pub fn natural_cmp(a: &str, b: &str) -> Ordering { + fn chunks(s: &str) -> Vec<(bool, String)> { + let mut out: Vec<(bool, String)> = Vec::new(); + for c in s.chars() { + let d = c.is_ascii_digit(); + match out.last_mut() { + Some((kind, text)) if *kind == d => text.push(c), + _ => out.push((d, c.to_string())), + } + } + out + } + let (x, y) = (chunks(a), chunks(b)); + for (p, q) in x.iter().zip(y.iter()) { + let o = match (p, q) { + ((true, m), (true, n)) => { + let (m, n) = (m.trim_start_matches('0'), n.trim_start_matches('0')); + m.len().cmp(&n.len()).then_with(|| m.cmp(n)) + } + ((_, m), (_, n)) => m.to_lowercase().cmp(&n.to_lowercase()), + }; + if o != Ordering::Equal { + return o; + } + } + x.len().cmp(&y.len()).then_with(|| a.cmp(b)) +} + +/// The Effect menu's order: groups in the order they first appear (ungrouped first), each with +/// its effects (indices into `effects`) sorted by name. +pub fn menu_order(effects: &[MaskEffect]) -> Vec<(String, Vec<usize>)> { + let mut groups: Vec<(String, Vec<usize>)> = Vec::new(); + if effects.iter().any(|e| e.group.is_empty()) { + groups.push((String::new(), Vec::new())); + } + for (i, e) in effects.iter().enumerate() { + match groups.iter_mut().find(|(g, _)| g.eq_ignore_ascii_case(&e.group)) { + Some((_, v)) => v.push(i), + None => groups.push((e.group.clone(), vec![i])), + } + } + for (_, v) in &mut groups { + v.sort_by(|a, b| match (effects.get(*a), effects.get(*b)) { + (Some(x), Some(y)) => natural_cmp(&x.name, &y.name), + _ => Ordering::Equal, + }); + } + groups +} + +/// Name of the effect the mask adjustments `a` match, if any (full presets before partial ones). +pub fn matching_effect(s: &Session, a: &LocalAdjustments) -> Option<String> { + let full = s.mask_effects.iter().filter(|e| e.fields.is_empty()).find(|e| e.matches(a)); + full.or_else(|| s.mask_effects.iter().find(|e| e.matches(a))).map(|e| e.name.clone()) +} + +fn position(s: &Session, name: &str) -> Option<usize> { + s.mask_effects.iter().position(|x| x.name.eq_ignore_ascii_case(name.trim())) +} + +/// Add or replace (same name, ignoring case) an effect; a replaced one keeps its star. +pub(crate) fn upsert(s: &mut Session, e: MaskEffect) { + match position(s, &e.name).and_then(|i| s.mask_effects.get_mut(i)) { + Some(x) => { + let favorite = x.favorite || e.favorite; + *x = MaskEffect { favorite, ..e }; + } + None => s.mask_effects.push(e), + } +} + +fn name_param(p: &Value, c: &str) -> Result<String> { + name_of(p, "name", c) +} + +fn name_of(p: &Value, key: &str, c: &str) -> Result<String> { + str_param(p, key).map(str::trim).filter(|n| !n.is_empty()).map(str::to_string).ok_or_else(|| bad(c, format!("missing `{key}`"))) +} + +fn strings(p: &Value, key: &str) -> Option<Vec<String>> { + p.get(key).and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_str).map(str::to_string).collect()) +} + +/// The names a command acts on: `names: [...]` or `name`. +fn names_param(p: &Value, c: &str) -> Result<Vec<String>> { + match strings(p, "names").filter(|v| !v.is_empty()) { + Some(v) => Ok(v), + None => name_param(p, c).map(|n| vec![n]), + } +} + +fn effect_index(s: &Session, name: &str, c: &str) -> Result<usize> { + position(s, name).ok_or_else(|| bad(c, format!("no effect `{name}`"))) +} + +/// The selected mask's adjustments on the active photo. +fn selected_mask(s: &Session, p: &Value, c: &str) -> Result<Mask> { + let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; + let mid = p + .get("id") + .and_then(Value::as_u64) + .and_then(|v| u32::try_from(v).ok()) + .or(s.active_mask) + .ok_or_else(|| bad(c, "no mask selected (give `id`)"))?; + let d = s.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| m.id == mid).cloned().ok_or_else(|| bad(c, format!("no mask {mid}"))) +} + +/// The kind of a mask's first component (`brush`, `linear`, `radial`, `subject`…): the tool the +/// effect is remembered for. +pub fn mask_tool(m: &Mask) -> Option<String> { + m.components.first().and_then(|c| serde_json::to_value(&c.shape).ok()).and_then(|v| v.get("kind").and_then(Value::as_str).map(str::to_string)) +} + +/// The adjustments a new mask made with `tool` starts with: the effect last applied to a mask of +/// that kind (Lightroom carries the chosen effect over to the next mask), else all at 0. +pub fn new_mask_adjust(s: &Session, tool: &str) -> LocalAdjustments { + s.mask_tool_effects + .get(tool) + .and_then(|name| position(s, name)) + .and_then(|i| s.mask_effects.get(i)) + .map(|e| super::masks::clamp_local(e.apply(&LocalAdjustments::default()))) + .unwrap_or_default() +} + +fn list(s: &Session) -> Value { + let current = s + .active() + .and_then(|id| s.develop_of(id)) + .and_then(|d| s.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))) + .and_then(|a| matching_effect(s, &a)); + let groups: Vec<Value> = menu_order(&s.mask_effects) + .into_iter() + .map(|(g, ix)| json!({"name": g, "effects": ix.iter().filter_map(|i| s.mask_effects.get(*i)).map(|e| e.name.clone()).collect::<Vec<_>>()})) + .collect(); + json!({"effects": s.mask_effects, "groups": groups, "current": current, "toolEffects": s.mask_tool_effects}) +} + +/// Masks on `d` named like one of `names` (ignoring case). +fn masks_named(d: &DevelopSettings, names: &[String]) -> Vec<u32> { + d.masks.iter().filter(|m| names.iter().any(|n| n.eq_ignore_ascii_case(&m.name))).map(|m| m.id).collect() +} + +fn apply_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.applyEffect"; + let name = name_param(p, C)?; + let effect = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let id = s.active().ok_or_else(|| bad(C, "no active photo"))?; + let d = s.develop_of(id).unwrap_or_default(); + // the masks: `ids`, `all`, else `id` or the selected one + let ids: Vec<u32> = if p.get("all").and_then(Value::as_bool).unwrap_or(false) { + d.masks.iter().map(|m| m.id).collect() + } else if let Some(a) = p.get("ids").and_then(Value::as_array) { + a.iter().filter_map(Value::as_u64).filter_map(|v| u32::try_from(v).ok()).collect() + } else { + vec![selected_mask(s, p, C)?.id] + }; + if ids.is_empty() { + return Err(bad(C, "no masks to apply the effect to")); + } + if let Some(missing) = ids.iter().find(|m| !d.masks.iter().any(|x| x.id == **m)) { + return Err(bad(C, format!("no mask {missing}"))); + } + let set_masks = |d: &DevelopSettings, ids: &[u32]| { + let mut d = d.clone(); + for m in d.masks.iter_mut().filter(|m| ids.contains(&m.id)) { + m.adjust = super::masks::clamp_local(effect.apply(&m.adjust)); + } + d + }; + let label = format!("Mask Effect: {}", effect.name); + // with `photos: "selected"`, the other selected photos' masks of the same names too + let others: Vec<(PhotoId, DevelopSettings)> = if str_param(p, "photos") == Some("selected") { + let names: Vec<String> = d.masks.iter().filter(|m| ids.contains(&m.id)).map(|m| m.name.clone()).collect(); + s.selection + .ids + .iter() + .filter(|x| **x != id) + .filter_map(|x| s.develop_of(*x).map(|o| (*x, o))) + .filter_map(|(x, o)| { + let found = masks_named(&o, &names); + (!found.is_empty()).then(|| (x, set_masks(&o, &found))) + }) + .collect() + } else { + Vec::new() + }; + let mine = set_masks(&d, &ids); + let photos = 1 + others.len(); + if others.is_empty() { + s.set_develop(id, mine, &label)?; + } else { + // one undo step for every photo + let mut ops: Vec<_> = s.develop_op(id, mine, &label).into_iter().collect(); + ops.extend(others.into_iter().filter_map(|(x, o)| s.develop_op(x, o, &label))); + s.commit(&label, lightcraft_catalog::Op::Batch { ops })?; + } + // the next mask made with this tool starts with the effect + if p.get("remember").and_then(Value::as_bool).unwrap_or(true) { + for m in d.masks.iter().filter(|m| ids.contains(&m.id)) { + if let Some(tool) = mask_tool(m) { + s.mask_tool_effects.insert(tool, effect.name.clone()); + } + } + s.save_prefs()?; + } + Ok(json!({"name": effect.name, "masks": ids, "photos": photos, "brush": effect.brush})) +} + +/// The fields a save keeps: `fields: [..]` | `"changed"` | `"all"` (default). +fn fields_param(p: &Value, a: &LocalAdjustments, c: &str) -> Result<Vec<String>> { + match p.get("fields") { + None | Some(Value::Null) => Ok(Vec::new()), + Some(Value::String(x)) if x == "all" => Ok(Vec::new()), + Some(Value::String(x)) if x == "changed" => { + let f = changed_fields(a); + if f.is_empty() { Err(bad(c, "the mask changes no slider; nothing to save")) } else { Ok(f) } + } + Some(Value::Array(v)) => { + let f: Vec<String> = v.iter().filter_map(Value::as_str).filter(|k| FIELDS.contains(k)).map(str::to_string).collect(); + if f.is_empty() { Err(bad(c, format!("`fields` names no slider (one of {})", FIELDS.join(", ")))) } else { Ok(f) } + } + Some(_) => Err(bad(c, "`fields` is \"all\", \"changed\" or a list of slider names")), + } +} + +fn brush_param(p: &Value, c: &str) -> Result<Option<BrushSettings>> { + match p.get("brush") { + None | Some(Value::Null) | Some(Value::Bool(false)) => Ok(None), + Some(v) => serde_json::from_value::<BrushSettings>(v.clone()).map(|b| Some(b.clamped())).map_err(|e| bad(c, format!("`brush`: {e}"))), + } +} + +fn save_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.saveEffect"; + let name = name_param(p, C)?; + let adjust = selected_mask(s, p, C)?.adjust; + let group = str_param(p, "group").map(|g| g.trim().to_string()).unwrap_or_default(); + let e = MaskEffect { + name, + adjust, + group, + fields: fields_param(p, &adjust, C)?, + with_amount: p.get("amount").and_then(Value::as_bool).unwrap_or(false), + brush: brush_param(p, C)?, + ..Default::default() + }; + upsert(s, e.normalized()); + s.save_prefs()?; + Ok(list(s)) +} + +/// Overwrite an effect's sliders with the selected mask's (keeping which sliders and whether +/// Amount it carries, its group and star). +fn update_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.updateEffect"; + let name = name_param(p, C)?; + let i = effect_index(s, &name, C)?; + let adjust = selected_mask(s, p, C)?.adjust; + let brush = brush_param(p, C)?; + let e = s.mask_effects.get_mut(i).ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let mut next = MaskEffect { adjust, extra: Map::new(), ..e.clone() }; + if let Some(f) = p.get("fields").filter(|v| !v.is_null()) { + next.fields = fields_param(&json!({"fields": f}), &adjust, C)?; + } + if let Some(a) = p.get("amount").and_then(Value::as_bool) { + next.with_amount = a; + } + if brush.is_some() { + next.brush = brush; + } + *e = next.normalized(); + s.save_prefs()?; + Ok(list(s)) +} + +fn rename_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.renameEffect"; + let name = name_param(p, C)?; + let to = name_of(p, "to", C)?; + let i = effect_index(s, &name, C)?; + if position(s, &to).is_some_and(|j| j != i) { + return Err(bad(C, format!("an effect named `{to}` exists already"))); + } + if let Some(e) = s.mask_effects.get_mut(i) { + e.name = to.clone(); + } + for v in s.mask_tool_effects.values_mut() { + if v.eq_ignore_ascii_case(&name) { + *v = to.clone(); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +/// A name like `base` that no effect has: `base`, `base 2`, `base 3`… +fn free_name(s: &Session, base: &str) -> String { + (1..10_000) + .map(|n| if n == 1 { base.to_string() } else { format!("{base} {n}") }) + .find(|n| position(s, n).is_none()) + .unwrap_or_else(|| base.into()) +} + +fn duplicate_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.duplicateEffect"; + let name = name_param(p, C)?; + let e = s.mask_effects.get(effect_index(s, &name, C)?).cloned().ok_or_else(|| bad(C, format!("no effect `{name}`")))?; + let to = match str_param(p, "to").map(str::trim).filter(|t| !t.is_empty()) { + Some(t) if position(s, t).is_some() => return Err(bad(C, format!("an effect named `{t}` exists already"))), + Some(t) => t.to_string(), + None => free_name(s, &format!("{} copy", e.name)), + }; + s.mask_effects.push(MaskEffect { name: to.clone(), favorite: false, ..e }); + s.save_prefs()?; + let mut out = list(s); + out["name"] = json!(to); + Ok(out) +} + +fn delete_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.deleteEffect"; + let names = names_param(p, C)?; + if let Some(missing) = names.iter().find(|n| position(s, n).is_none()) { + return Err(bad(C, format!("no effect `{missing}`"))); + } + s.mask_effects.retain(|x| !names.iter().any(|n| n.eq_ignore_ascii_case(&x.name))); + s.mask_tool_effects.retain(|_, v| !names.iter().any(|n| n.eq_ignore_ascii_case(v))); + s.save_prefs()?; + Ok(list(s)) +} + +fn favorite_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.favoriteEffect"; + let names = names_param(p, C)?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); + } + let want = p.get("favorite").and_then(Value::as_bool); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.favorite = want.unwrap_or(!e.favorite); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +fn set_group(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.setEffectGroup"; + let names = names_param(p, C)?; + let group = str_param(p, "group").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `group` (\"\" = none)"))?; + let mut idx = Vec::new(); + for n in &names { + idx.push(effect_index(s, n, C)?); + } + // an existing group's spelling wins + let group = s.mask_effects.iter().map(|e| &e.group).find(|g| g.eq_ignore_ascii_case(&group)).cloned().unwrap_or(group); + for i in idx { + if let Some(e) = s.mask_effects.get_mut(i) { + e.group = group.clone(); + } + } + s.save_prefs()?; + Ok(list(s)) +} + +fn rename_group(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.renameEffectGroup"; + let group = name_of(p, "group", C)?; + let to = str_param(p, "to").map(|g| g.trim().to_string()).ok_or_else(|| bad(C, "missing `to` (\"\" = ungroup)"))?; + let mut n = 0; + for e in s.mask_effects.iter_mut().filter(|e| e.group.eq_ignore_ascii_case(&group)) { + e.group = to.clone(); + n += 1; + } + if n == 0 { + return Err(bad(C, format!("no group `{group}`"))); + } + s.save_prefs()?; + Ok(list(s)) +} + +fn tool_effect(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "mask.toolEffect"; + let tool = name_of(p, "tool", C)?; + match str_param(p, "name").map(str::trim).filter(|n| !n.is_empty()) { + Some(n) => { + let i = effect_index(s, n, C)?; + let name = s.mask_effects.get(i).map(|e| e.name.clone()).unwrap_or_default(); + s.mask_tool_effects.insert(tool, name); + } + None => { + s.mask_tool_effects.remove(&tool); + } + } + s.save_prefs()?; + Ok(json!({"toolEffects": s.mask_tool_effects})) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + query "mask.effects", + "Mask Effect Presets", + [], + None, + "{} → {effects: [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}], groups: [{name, effects: [name]}] (menu order), current: the effect the selected mask matches or null, toolEffects: {tool: effect a new mask of that kind starts with}}", + always, + |s, _| Ok(list(s)) + ), + cmd!( + "mask.applyEffect", + "Apply Mask Effect", + [], + None, + "{name, id? | ids?: [maskId] | all?: bool, photos?: \"selected\" (also the masks of the same names on the other selected photos, one undo step), remember?: bool (true: new masks of this kind start with it)} — sets the effect's sliders (all, or the ones it keeps; Amount only if it carries it) → {name, masks, photos, brush}", + has_active, + apply_effect + ), + cmd!( + "mask.saveEffect", + "Save Mask Effect", + [], + None, + "{name, id?, group?, fields?: \"all\" (default) | \"changed\" | [slider], amount?: bool (carry Amount), brush?: {size, feather, flow, density, auto_mask}} — the selected mask's sliders as an effect preset (replaces one of that name), saved with the library", + has_active, + save_effect + ), + cmd!( + "mask.updateEffect", + "Update Mask Effect", + [], + None, + "{name, id?, fields?, amount?, brush?} — overwrite the effect with the selected mask's sliders (keeps its group, star and which sliders it carries unless given)", + has_active, + update_effect + ), + cmd!("mask.renameEffect", "Rename Mask Effect", [], None, "{name, to}", always, rename_effect), + cmd!( + "mask.duplicateEffect", + "Duplicate Mask Effect", + [], + None, + "{name, to?} (default \"<name> copy\") → list + {name}", + always, + duplicate_effect + ), + cmd!("mask.deleteEffect", "Delete Mask Effect", [], None, "{name | names: [name]} — removes effect presets", always, delete_effect), + cmd!("mask.favoriteEffect", "Favorite Mask Effect", [], None, "{name | names, favorite?: bool (default: toggle)}", always, favorite_effect), + cmd!("mask.setEffectGroup", "Move Mask Effect to Group", [], None, "{name | names, group: name (\"\" = none)}", always, set_group), + cmd!( + "mask.renameEffectGroup", + "Rename Mask Effect Group", + [], + None, + "{group, to} (\"\" ungroups; an existing name merges)", + always, + rename_group + ), + cmd!( + "mask.toolEffect", + "Set Effect for New Masks", + [], + None, + "{tool: brush|linear|radial|subject|…, name?} — the effect new masks of that kind start with (no name: none)", + always, + tool_effect + ), + cmd!( + "mask.importEffects", + "Import Mask Effects", + [], + None, + "{path | paths: [file or folder], group?, dryRun?} — Lightroom Classic local adjustment presets (.lrtemplate), Camera Raw local correction presets (.xmp), .lceffect files and .zip bundles of these; folders and Lightroom's divider presets (\"10 ----- DETAIL ----- 10\") become groups; same-named effects are replaced → {imported: [{name, group, file, unmapped}], dividers: [{name, group, file}], failed: [[file, error]]}", + always, + files::import_effects + ), + cmd!( + "mask.exportEffects", + "Export Mask Effects", + [], + None, + "{path, names?: [name], group?: name (default: all)} — writes an .lceffect JSON file (groups, stars and kept Lightroom settings included) → {path, count}", + always, + files::export_effects + ), + ] +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn apply_keeps_amount_and_matches_ignore_it() { + let e = MaskEffect { name: "E".into(), adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, ..Default::default() }; + let current = LocalAdjustments { shadows: 40.0, amount: 60.0, ..Default::default() }; + let out = e.apply(&current); + assert_eq!((out.exposure, out.shadows, out.amount), (1.0, 0.0, 60.0)); + assert!(e.matches(&out)); + assert!(!e.matches(&current)); + } + + #[test] + fn partial_effects_leave_other_sliders_and_can_carry_amount() { + let e = MaskEffect { + name: "Lift".into(), + adjust: LocalAdjustments { shadows: 30.0, clarity: 99.0, amount: 70.0, ..Default::default() }, + fields: vec!["shadows".into()], + with_amount: true, + ..Default::default() + }; + let current = LocalAdjustments { exposure: 0.5, clarity: 10.0, ..Default::default() }; + let out = e.apply(&current); + assert_eq!((out.exposure, out.shadows, out.clarity, out.amount), (0.5, 30.0, 10.0, 70.0)); + assert!(e.matches(&out)); + assert!(!e.matches(&LocalAdjustments { amount: 100.0, ..out }), "Amount counts when carried"); + } + + #[test] + fn hue_without_saturation_still_matches() { + let e = MaskEffect { + name: "E".into(), + adjust: LocalAdjustments { color_hue: 240.0, exposure: 0.25, ..Default::default() }, + ..Default::default() + }; + assert!(e.matches(&LocalAdjustments { exposure: 0.25, ..Default::default() })); + assert!(!e.matches(&LocalAdjustments { exposure: 0.25, color_sat: 10.0, ..Default::default() })); + } + + #[test] + fn natural_order_and_groups() { + let mut v = vec!["10 b", "2 a", "Burn", "01 x", "1 y"]; + v.sort_by(|a, b| natural_cmp(a, b)); + assert_eq!(v, vec!["01 x", "1 y", "2 a", "10 b", "Burn"]); + let e = |n: &str, g: &str| MaskEffect { name: n.into(), group: g.into(), ..Default::default() }; + let list = vec![e("12 b", "Detail"), e("3 a", ""), e("11 a", "detail"), e("20 r", "Retouch"), e("Burn", "")]; + let order = menu_order(&list); + let names: Vec<(String, Vec<&str>)> = order.iter().map(|(g, ix)| (g.clone(), ix.iter().map(|i| list[*i].name.as_str()).collect())).collect(); + assert_eq!( + names, + vec![("".to_string(), vec!["3 a", "Burn"]), ("Detail".to_string(), vec!["11 a", "12 b"]), ("Retouch".to_string(), vec!["20 r"]),] + ); + } + + #[test] + fn normalized_clamps_and_tidies_fields() { + let e = MaskEffect { + name: " X ".into(), + adjust: LocalAdjustments { exposure: 99.0, amount: 150.0, ..Default::default() }, + fields: vec!["color_sat".into(), "bogus".into(), "exposure".into()], + brush: Some(BrushSettings { size: f64::NAN, flow: 0.0, ..Default::default() }), + ..Default::default() + } + .normalized(); + assert_eq!(e.name, "X"); + assert_eq!(e.adjust.exposure, 4.0); + assert_eq!(e.adjust.amount, 100.0, "Amount isn't carried"); + assert_eq!(e.fields, vec!["exposure", "color_hue", "color_sat"]); + let b = e.brush.unwrap(); + assert_eq!((b.size, b.flow), (0.04, 1.0)); + } +} diff --git a/crates/engine/src/cmd/masks.rs b/crates/engine/src/cmd/masks.rs index 25ff3f24d..0efb3b658 100644 --- a/crates/engine/src/cmd/masks.rs +++ b/crates/engine/src/cmd/masks.rs @@ -150,6 +150,55 @@ fn masks_edit(s: &mut Session, c: &str, label: &str, f: impl FnOnce(&mut Vec<Mas Ok(json!({"activeMask": active})) } +/// Most pieces a tapered ⇧-click segment is cut into. +const MAX_TAPER_STEPS: usize = 64; + +/// The strokes a ⇧-click adds: `to` drawn on from point `from`, the last point of stroke `last` (a straight segment +/// to `to`'s first point, then the rest of `to`). When the brush's size, feather or flow changed since `last`, the +/// segment is cut into pieces whose values step from `last`'s to `to`'s, so the line tapers; strokes combine by their +/// maximum, so the pieces join without building up. +fn connected_strokes(from: Point, last: &BrushStroke, to: BrushStroke) -> Vec<BrushStroke> { + let Some(&first) = to.points.first() else { return vec![to] }; + let same = |a: f64, b: f64| (a - b).abs() <= 1e-9; + // pen pressure along the line: the last dab's at its start, the click's at its end (none when neither has any) + let pressured = !last.pressure.is_empty() || !to.pressure.is_empty(); + let (p_from, p_to) = (last.pressure_at(0), to.pressure_at(0)); + if same(last.size, to.size) && same(last.feather, to.feather) && same(last.flow, to.flow) { + let mut points = Vec::with_capacity(to.points.len() + 1); + points.push(from); + points.extend(to.points.iter().copied()); + let pressure = if pressured { std::iter::once(p_from).chain((0..to.points.len()).map(|i| to.pressure_at(i))).collect() } else { Vec::new() }; + return vec![BrushStroke { points, pressure, ..to }]; + } + // about 5% of size, or 2 points of feather or flow, per piece + let ratio = if last.size > 0.0 && to.size > 0.0 { (to.size / last.size).ln().abs() / 1.05f64.ln() } else { 0.0 }; + let needed = [ratio, (to.feather - last.feather).abs() / 2.0, (to.flow - last.flow).abs() / 2.0] + .into_iter() + .filter(|v| v.is_finite()) + .fold(1.0f64, f64::max); + let steps = (needed.ceil() as usize).clamp(1, MAX_TAPER_STEPS); + let lerp = |a: f64, b: f64, t: f64| a + (b - a) * t; + let at = |t: f64| Point::new(lerp(from.x, first.x, t), lerp(from.y, first.y, t)); + let mut out: Vec<BrushStroke> = (0..steps) + .map(|i| { + let (t0, t1) = (i as f64 / steps as f64, (i + 1) as f64 / steps as f64); + let p = |t: f64| (f64::from(p_from) + (f64::from(p_to) - f64::from(p_from)) * t) as f32; + BrushStroke { + points: vec![at(t0), at(t1)], + size: lerp(last.size, to.size, t1), + feather: lerp(last.feather, to.feather, t1), + flow: lerp(last.flow, to.flow, t1), + pressure: if pressured { vec![p(t0), p(t1)] } else { Vec::new() }, + ..to.clone() + } + }) + .collect(); + if to.points.len() > 1 { + out.push(to); + } + out +} + fn mask_id(p: &Value, active: Option<u32>, c: &str) -> Result<u32> { p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(active).ok_or_else(|| bad(c, "no mask selected (give `id`)")) } @@ -178,11 +227,14 @@ pub fn specs() -> Vec<CommandSpec> { return Ok(json!({"pending": true})); } let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); + // the effect last applied to a mask of this kind + let adjust = super::mask_effects::new_mask_adjust(s, &kind); let out = masks_edit(s, "mask.add", "Add Mask", |masks, active| { masks.push(Mask { id: next, name: name.unwrap_or_else(|| format!("Mask {next}")), components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust, ..Default::default() }); *active = Some(next); @@ -230,7 +282,7 @@ pub fn specs() -> Vec<CommandSpec> { "Brush Stroke", [], None, - "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool}", + "{id?: maskId, points: [[x,y],…] normalized, size?: fraction of long edge (0.03), feather?, flow?, density?, erase?: bool, autoMask?: bool, pressure?: [p,…] pen pressure 0..1 per point (scales each dab's size and flow; omitted: full), connect?: bool — start from the last point of the mask's last stroke (⇧-click), size / feather / flow tapering from that stroke's to these} → {activeMask, connected}", has_active, |s, p| { let pts: Vec<Point> = p @@ -240,6 +292,7 @@ pub fn specs() -> Vec<CommandSpec> { .iter() .filter_map(|q| Some(Point::new(q.get(0)?.as_f64()?, q.get(1)?.as_f64()?))) .collect(); + let pts_len = pts.len(); let d = BrushStroke::default(); let stroke = BrushStroke { points: pts, @@ -249,14 +302,18 @@ pub fn specs() -> Vec<CommandSpec> { density: f64_or(p, "density", d.density), erase: bool_or(p, "erase", false), auto_mask: bool_or(p, "autoMask", false), + pressure: pressure_list(p.get("pressure"), pts_len)?, }; + let connect = bool_or(p, "connect", false); + let mut connected = false; let next = s.active().and_then(|id| s.develop_of(id)).map(|d| d.next_mask_id()).unwrap_or(1); let want = p.get("id").and_then(Value::as_u64).map(|v| v as u32).or(s.active_mask); - masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { + let adjust = super::mask_effects::new_mask_adjust(s, "brush"); + let mut out = masks_edit(s, "mask.brushStroke", "Brush", |masks, active| { let i = match want.and_then(|m| masks.iter().position(|x| x.id == m)) { Some(i) => i, None => { - masks.push(Mask { id: next, name: format!("Mask {next}"), ..Default::default() }); + masks.push(Mask { id: next, name: format!("Mask {next}"), adjust, ..Default::default() }); *active = Some(next); masks.len() - 1 } @@ -265,7 +322,28 @@ pub fn specs() -> Vec<CommandSpec> { if let Some(MaskComponent { shape: MaskShape::Brush { strokes }, .. }) = m.components.iter_mut().rev().find(|c| matches!(c.shape, MaskShape::Brush { .. })) { - strokes.push(stroke); + // the previous stroke's end and its brush values (not its points) + let anchor = strokes.last().filter(|_| connect).and_then(|l| { + Some(( + *l.points.last()?, + BrushStroke { + points: Vec::new(), + size: l.size, + feather: l.feather, + flow: l.flow, + // the pressure the last dab was painted with, when the stroke recorded any + pressure: if l.pressure.is_empty() { Vec::new() } else { vec![l.pressure_at(l.points.len().saturating_sub(1))] }, + ..BrushStroke::default() + }, + )) + }); + match anchor { + Some((from, last)) => { + strokes.extend(connected_strokes(from, &last, stroke)); + connected = true; + } + None => strokes.push(stroke), + } } else { m.components.push(MaskComponent { name: None, @@ -275,7 +353,11 @@ pub fn specs() -> Vec<CommandSpec> { }); } Ok(()) - }) + })?; + if let Some(o) = out.as_object_mut() { + o.insert("connected".into(), json!(connected)); + } + Ok(out) } ), cmd!("mask.update", "Update Mask Shape", [], None, "{id?, component?: index (0), shape: MaskShape JSON}", has_active, |s, p| { @@ -921,7 +1003,7 @@ pub fn specs() -> Vec<CommandSpec> { has_active, |s, p| { let c = "redeye.catchlight"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); let eye = dd.red_eye.get_mut(i).ok_or_else(|| bad(c, "no such eye"))?; @@ -936,7 +1018,7 @@ pub fn specs() -> Vec<CommandSpec> { ), cmd!("redeye.delete", "Delete Red Eye Correction", [], None, "{index}", has_active, |s, p| { let c = "redeye.delete"; - let i = super::f64_req(p, "index", c)? as usize; + let i = super::index_req(p, "index", c)?; let id = s.active().ok_or_else(|| bad(c, "no active photo"))?; let mut dd = (*s.develop_of(id).unwrap_or_default()).clone(); if i >= dd.red_eye.len() { @@ -979,7 +1061,7 @@ fn pick_source(s: &mut Session, id: crate::PhotoId, d: &lightcraft_develop::Deve lightcraft_pipeline::spots::pick_source(&src, &info, d, spot, avoid) } -fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { +pub(crate) fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { let c = |v: &mut f64, lo: f64, hi: f64| *v = if v.is_finite() { v.clamp(lo, hi) } else { 0.0 }; c(&mut a.exposure, -4.0, 4.0); for v in [ @@ -1007,3 +1089,17 @@ fn clamp_local(mut a: LocalAdjustments) -> LocalAdjustments { c(&mut a.amount, 0.0, 200.0); a } + +/// `mask.brushStroke`'s `pressure`: one number 0..1 per point (clamped), at most one per point; absent or `null` +/// is no pressure data. +fn pressure_list(v: Option<&Value>, points: usize) -> Result<Vec<f32>> { + let Some(v) = v.filter(|v| !v.is_null()) else { return Ok(Vec::new()) }; + let list = v.as_array().ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1"))?; + if list.len() > points { + return Err(bad("mask.brushStroke", "pressure has more values than points")); + } + list.iter() + .map(|q| q.as_f64().filter(|f| f.is_finite()).map(|f| f.clamp(0.0, 1.0) as f32)) + .collect::<Option<Vec<f32>>>() + .ok_or_else(|| bad("mask.brushStroke", "pressure must be a list of numbers 0..1")) +} diff --git a/crates/engine/src/cmd/merge.rs b/crates/engine/src/cmd/merge.rs index a8869153a..c07065cde 100644 --- a/crates/engine/src/cmd/merge.rs +++ b/crates/engine/src/cmd/merge.rs @@ -1,4 +1,4 @@ -//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`. +//! Photo Merge commands: `merge.hdr`, `merge.panorama`, `merge.hdrPanorama`, `merge.hdrBatch`. //! //! Run synchronously here (CLI, MCP, control channel); the desktop UI plans the same job with //! [`Session::plan_merge`] and runs it on a worker thread. `preview: true` merges ≤ 1024 px frames @@ -20,7 +20,7 @@ fn can_merge(s: &Session) -> std::result::Result<(), String> { fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> { let (kind, finish) = crate::merge::parse(id, p)?; - let ids: Vec<PhotoId> = s.targets(p); + let ids: Vec<PhotoId> = s.targets(p)?; if !bool_or(p, "preview", false) { return s.merge_now(kind, finish, &ids); } @@ -40,6 +40,26 @@ fn run(s: &mut Session, id: &str, p: &Value) -> Result<Value> { Ok(info) } +/// `merge.hdrBatch`: an HDR merge of each bracket set among the photos ([`lightcraft_catalog::Catalog::bracket_sets`]), +/// one after the other. A set that fails is reported and the others still run. +fn run_batch(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "merge.hdrBatch"; + let (kind, finish) = crate::merge::parse("merge.hdr", p)?; + let ids: Vec<PhotoId> = s.targets(p)?; + let sets = s.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(bad(ID, crate::merge::NO_BRACKET_SETS)); + } + let (mut merged, mut failed) = (Vec::new(), Vec::new()); + for set in sets { + match s.merge_now(kind.clone(), finish.clone(), &set) { + Ok(v) => merged.push(v), + Err(e) => failed.push(json!({"photos": set.iter().map(|p| p.0).collect::<Vec<_>>(), "error": e.to_string()})), + } + } + Ok(json!({"merged": merged, "failed": failed})) +} + pub fn specs() -> Vec<CommandSpec> { vec![ cmd!( @@ -56,7 +76,7 @@ pub fn specs() -> Vec<CommandSpec> { "Panorama Merge", [], None, - "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=40, preview=false, previewPath?}", + "{ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, stack=false, maxMegapixels=full size (≤ 512, held to memory), preview=false, previewPath?}", can_merge, |s, p| run(s, "merge.panorama", p) ), @@ -69,5 +89,14 @@ pub fn specs() -> Vec<CommandSpec> { can_merge, |s, p| run(s, "merge.hdrPanorama", p) ), + cmd!( + "merge.hdrBatch", + "HDR for Each Bracket Set", + ["Photo", "Photo Merge"], + None, + "{ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false}: an HDR merge of each bracket set among the photos (each stack, then photos taken less than a second apart at exposures ≥ ½ EV apart) → {merged: [{id, path, …}], failed: [{photos, error}]}", + can_merge, + run_batch + ), ] } diff --git a/crates/engine/src/cmd/mod.rs b/crates/engine/src/cmd/mod.rs index 4de460761..4b86d4012 100644 --- a/crates/engine/src/cmd/mod.rs +++ b/crates/engine/src/cmd/mod.rs @@ -19,11 +19,14 @@ pub(crate) mod face_detect; mod face_models; mod face_recognize; pub mod filters; +mod focus; pub mod geocode; pub mod keywords; pub mod library; pub mod lut_profiles; pub mod manage; +pub mod mask_effect_files; +pub mod mask_effects; mod masks; mod merge; pub mod metadata; @@ -32,7 +35,10 @@ mod organize; mod prefs; mod preset_files; pub mod previews; +mod print; mod query; +pub mod recheck; +mod slideshow; mod sync; mod xmp; @@ -105,7 +111,7 @@ pub fn has_selection(s: &Session) -> std::result::Result<(), String> { /// can take (issue #684; Add to My Photos first). pub fn has_library_selection(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if s.targets(&serde_json::Value::Null).iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } + if s.selected().iter().any(|id| s.catalog.photo(*id).is_none_or(|p| !p.local)) { Ok(()) } else { Err(LOCAL_ONLY.into()) } } /// Why an album command refused a Local-only selection. pub const LOCAL_ONLY: &str = "only Local photos: albums hold library photos (Add to My Photos first)"; @@ -145,6 +151,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(manage::specs()); v.extend(previews::specs()); v.extend(lut_profiles::specs()); + v.extend(mask_effects::specs()); v.extend(cull::specs()); v.extend(convert::specs()); v.extend(convert::edit_specs()); @@ -153,10 +160,13 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(xmp::specs()); v.extend(preset_files::specs()); v.extend(prefs::specs()); + v.extend(slideshow::specs()); v.extend(export::specs()); + v.extend(print::specs()); v.extend(before::specs()); v.extend(browse::specs()); v.extend(missing::specs()); + v.extend(recheck::specs()); v.extend(sync::specs()); v.extend(metadata::specs()); v.extend(geocode::specs()); @@ -165,6 +175,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(face_models::specs()); v.extend(face_detect::specs()); v.extend(face_recognize::specs()); + v.extend(focus::specs()); v.extend(activity::specs()); v }) @@ -187,6 +198,15 @@ pub(crate) fn bad(cmd: &str, msg: impl Into<String>) -> EngineError { pub(crate) fn f64_req(p: &Value, key: &str, cmd: &str) -> Result<f64> { p.get(key).and_then(Value::as_f64).ok_or_else(|| bad(cmd, format!("missing number `{key}`"))) } +/// A non-negative integer parameter: `index: -1` must be an error, not element 0 (`-1.0 as usize` +/// saturates to 0), just as `history.restore` already refuses a negative step index. +pub(crate) fn index_req(p: &Value, key: &str, cmd: &str) -> Result<usize> { + let i = f64_req(p, key, cmd)?; + if !(i.is_finite() && i >= 0.0) { + return Err(bad(cmd, format!("`{key}` must be a non-negative integer"))); + } + Ok(i as usize) +} pub(crate) fn f64_or(p: &Value, key: &str, d: f64) -> f64 { p.get(key).and_then(Value::as_f64).unwrap_or(d) } diff --git a/crates/engine/src/cmd/organize.rs b/crates/engine/src/cmd/organize.rs index 494785628..24cc2061c 100644 --- a/crates/engine/src/cmd/organize.rs +++ b/crates/engine/src/cmd/organize.rs @@ -22,7 +22,7 @@ fn stacks_of(s: &Session, targets: &[PhotoId]) -> Vec<StackId> { fn in_stack(s: &Session) -> std::result::Result<(), String> { has_selection(s)?; - if stacks_of(s, &s.targets(&Value::Null)).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } + if stacks_of(s, &s.selected()).is_empty() { Err("no stacked photo selected".into()) } else { Ok(()) } } fn has_stacks(s: &Session) -> std::result::Result<(), String> { @@ -111,7 +111,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?, name?} → {ids: [new photo ids]} — a new catalog entry sharing the original file, with independent settings; stacked with its original and named `name` or \"Copy N\"", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let name = str_param(p, "name").filter(|n| !n.trim().is_empty()).map(str::to_string); let mut made = Vec::new(); for t in targets { @@ -134,7 +134,7 @@ pub fn specs() -> Vec<CommandSpec> { "{ids?, top?: photoId (default: the active photo), collapsed?: bool (default true)} — stacks the photos are already in are merged", has_selection, |s, p| { - let targets = s.targets(p); + let targets = s.targets(p)?; let top = p .get("top") .and_then(Value::as_u64) @@ -153,11 +153,11 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("stack.ungroup", "Ungroup Stack", ["Photo", "Stack"], Some("Cmd+Shift+G"), "{ids?}", in_stack, |s, p| { - let ops = s.catalog.ungroup_ops(&s.targets(p)); + let ops = s.catalog.ungroup_ops(&s.targets(p)?); Ok(json!({"ungrouped": commit_ops(s, "Ungroup Stack", ops)?})) }), cmd!("stack.remove", "Remove from Stack", ["Photo", "Stack"], None, "{ids?}", in_stack, |s, p| { - let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)); + let ops = s.catalog.remove_from_stacks_ops(&s.targets(p)?); Ok(json!({"changed": commit_ops(s, "Remove from Stack", ops)?})) }), cmd!("stack.setTop", "Set as Top of Stack", ["Photo", "Stack"], Some("Shift+S"), "{id?} (default: the active photo)", in_stack, |s, p| { @@ -189,7 +189,7 @@ pub fn specs() -> Vec<CommandSpec> { } ), cmd!("stack.toggle", "Expand/Collapse Stack", ["Photo", "Stack"], Some("S"), "{ids?, collapsed?: bool}", in_stack, |s, p| { - let stacks = stacks_of(s, &s.targets(p)); + let stacks = stacks_of(s, &s.targets(p)?); let first = stacks.first().and_then(|id| s.catalog.stack(*id)).map(|st| st.collapsed).unwrap_or(false); let collapsed = bool_or(p, "collapsed", !first); set_collapsed(s, stacks, collapsed, if collapsed { "Collapse Stack" } else { "Expand Stack" }) @@ -212,7 +212,7 @@ pub fn specs() -> Vec<CommandSpec> { |s, p| { let gap = f64_or(p, "gap", 60.0).max(0.0); let explicit = p.get("ids").is_some(); - let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p) } else { s.visible_cloned() }; + let pool = if explicit || s.selection.ids.len() > 1 { s.targets(p)? } else { s.visible_cloned() }; let groups = s.catalog.auto_stack_groups(&pool, gap); let photos: usize = groups.iter().map(Vec::len).sum(); if bool_or(p, "preview", false) { diff --git a/crates/engine/src/cmd/prefs.rs b/crates/engine/src/cmd/prefs.rs index 78de9d850..1f1301a51 100644 --- a/crates/engine/src/cmd/prefs.rs +++ b/crates/engine/src/cmd/prefs.rs @@ -43,6 +43,9 @@ fn prefs(s: &mut Session, p: &Value) -> Result<Value> { if let Some(v) = i.get("perCamera").and_then(Value::as_bool) { d.per_camera = v; } + if let Some(v) = i.get("showOnCard").and_then(Value::as_bool) { + d.show_on_card = v; + } if let Some(v) = i.get("copyright").and_then(Value::as_str) { d.copyright = v.trim().to_string(); } @@ -107,7 +110,7 @@ pub fn specs() -> Vec<CommandSpec> { "Library Preferences", [], None, - "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", + "{import?: {rawPreset?: presetId|\"default\", otherPreset?: presetId|\"default\", perCamera?: bool, cameras?: [{camera: \"Make Model\", preset: presetId|null}], copyright?: text, creator?: text (given to imported photos without one), showOnCard?: bool (open the import review when a camera or card is connected)}, camera?: {camera, preset?, remove?: bool}, cacheMb?: n (0 = default), forgetLocalDays?: n (forget untouched Local photos of folders not browsed for n days; 0 = never)} — develop defaults applied on import (raws / other images / per camera) and the thumbnail cache size, saved with the library → {xmp, import, cacheMb, forgetLocalDays, persistent}", always, prefs )] diff --git a/crates/engine/src/cmd/preset_files.rs b/crates/engine/src/cmd/preset_files.rs index fc385f34c..b64a69fc7 100644 --- a/crates/engine/src/cmd/preset_files.rs +++ b/crates/engine/src/cmd/preset_files.rs @@ -2,6 +2,7 @@ use serde_json::{Value, json}; +use super::mask_effect_files as mef; use super::{CommandSpec, always, bad, cmd, str_param}; use crate::preset_import::{group_from_dir, read_presets}; use crate::presets::{LCPRESET_EXT, expand_preset_paths, to_lcpreset}; @@ -18,6 +19,10 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { let mut imported = Vec::new(); let mut failed = Vec::new(); let mut skipped = 0usize; + // local adjustment (brush) presets — Lightroom's .lrtemplate, Camera Raw's local .xmp, our + // .lceffect, also inside zips — become mask effects + let mut effects = Vec::new(); + let mut effect_failed = Vec::new(); for top in &paths { let from_dir = std::path::Path::new(top).is_dir(); for f in expand_preset_paths(std::slice::from_ref(top)) { @@ -25,18 +30,47 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { // the path from the imported folder (inclusive) to the file's folder let base = std::path::Path::new(top).parent().unwrap_or(std::path::Path::new("")); let rel = std::path::Path::new(&f).parent().and_then(|d| d.strip_prefix(base).ok()).map(|d| d.to_string_lossy().to_string()); - let dir_group = rel.filter(|_| from_dir).and_then(|d| group_from_dir(&d)); - let parsed = std::fs::read(&f).map_err(|e| e.to_string()).and_then(|b| read_presets(&f, &b, dir_group)); + let rel = rel.filter(|_| from_dir); + let bytes = match mef::read_capped(&f) { + Ok(b) => b, + Err(e) => { + failed.push(json!([f, e])); + continue; + } + }; + let lower = f.to_ascii_lowercase(); + let maybe_local = [".lrtemplate", ".xmp", ".zip", ".lceffect"].iter().any(|x| lower.ends_with(x)); + let before = effects.len() + effect_failed.len(); + let local = maybe_local && mef::found_in(&f, &bytes, rel.as_deref().and_then(mef::group_for_dir), &mut effects, &mut effect_failed); + // a zip may hold develop presets as well + if local && !lower.ends_with(".zip") { + continue; + } + let dir_group = rel.and_then(|d| group_from_dir(&d)); + let parsed = read_presets(&f, &bytes, dir_group); + if local && parsed.is_err() && effects.len() + effect_failed.len() > before { + continue; + } match parsed { Ok(list) => { for mut it in list { if let Some(g) = &group { it.preset.group = g.clone(); } + // the profile it names (issue #1001): one the user imported, or ours for a basic one + let mut profile = Value::Null; + if let Some((r, _)) = crate::profile_refs::apply(&mut it.preset.settings, &s.lut_profiles) { + let look = it.preset.settings["profile"]["look"].clone(); + it.unmapped.retain(|u| u != "Look"); + it.unmapped.extend(crate::profile_refs::report(&look, &r)); + profile = json!({"name": look["name"], "id": it.preset.settings["profile"].get("id")}); + } let (name, grp) = (it.preset.name.clone(), it.preset.group.clone()); let id = if dry { Some(it.preset.id.clone()) } else { s.add_presets(vec![it.preset]).pop() }; match id { - Some(id) => imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped})), + Some(id) => { + imported.push(json!({"id": id, "name": name, "group": grp, "file": f, "unmapped": it.unmapped, "profile": profile})) + } None => skipped += 1, } } @@ -45,7 +79,9 @@ fn import(s: &mut Session, p: &Value) -> Result<Value> { } } } - Ok(json!({"imported": imported, "skipped": skipped, "failed": failed})) + failed.extend(effect_failed.into_iter().map(|(f, e)| json!([f, e]))); + let (effects, dividers) = mef::add_found(s, effects, group.as_deref(), dry)?; + Ok(json!({"imported": imported, "skipped": skipped, "failed": failed, "effects": effects, "effectDividers": dividers})) } fn export(s: &mut Session, p: &Value) -> Result<Value> { @@ -69,10 +105,9 @@ fn export(s: &mut Session, p: &Value) -> Result<Value> { if chosen.is_empty() { return Err(bad("preset.export", "no presets to export (create one first, or pass ids/group)")); } - if let Some(dir) = path.parent().filter(|d| !d.as_os_str().is_empty()) { - std::fs::create_dir_all(dir).map_err(|e| crate::EngineError::Other(format!("{}: {e}", dir.display())))?; - } - std::fs::write(&path, to_lcpreset(&chosen)).map_err(|e| crate::EngineError::Other(format!("{}: {e}", path.display())))?; + let target = path.to_string_lossy(); + s.check_write_target(&target).map_err(|e| bad("preset.export", e))?; + crate::export::write_file_durable(&target, to_lcpreset(&chosen).as_bytes()).map_err(crate::EngineError::Other)?; Ok(json!({"path": path.display().to_string(), "count": chosen.len()})) } @@ -83,7 +118,7 @@ pub fn specs() -> Vec<CommandSpec> { "Import Presets", [], None, - "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md → {imported: [{id,name,group,file,unmapped}], skipped, failed}", + "{paths: [file or folder], group?, dryRun?} — .lcpreset, XMP presets, .lrtemplate, photos carrying edits (DNG/JPEG/TIFF \"DNG presets\"), Luminar looks (.lmp, .mplumpack collections) and .zip bundles of these; folders give their name as group; crs: fields mapped per docs/xmp-interop.md; local adjustment presets (Lightroom .lrtemplate, Camera Raw local .xmp, .lceffect) become mask effects (`mask.effects`; divider presets become their groups); a profile the preset names applies when it was imported with profile.import (before or after) → {imported: [{id,name,group,file,unmapped,profile: {name, id}|null}], skipped, failed, effects: [{name,group,file,unmapped}], effectDividers: [{name,group,file}]}", always, import ), diff --git a/crates/engine/src/cmd/previews.rs b/crates/engine/src/cmd/previews.rs index 4fc7ababa..b63ccc2b4 100644 --- a/crates/engine/src/cmd/previews.rs +++ b/crates/engine/src/cmd/previews.rs @@ -152,7 +152,7 @@ fn build(s: &mut Session, p: &Value) -> Result<Value> { return Err(bad(C, "a preview build is already running")); } // explicit ids, else the selection, else everything in view - let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || p.get("id").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let jobs: Vec<Vec<RenderJob>> = ids.iter().map(|id| jobs_for(s, *id, edge)).filter(|j| !j.is_empty()).collect(); let state = Arc::new(PreviewBuild { total: jobs.len(), ..Default::default() }); s.preview_build = Some(state.clone()); @@ -324,7 +324,7 @@ fn smart(s: &mut Session, p: &Value) -> Result<Value> { if background && s.preview_build.as_ref().is_some_and(|b| !b.finished.load(Ordering::Relaxed)) { return Err(bad(C, "a preview build is already running")); } - let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p) } else { s.visible_cloned() }; + let ids = if p.get("ids").is_some() || !s.selection.ids.is_empty() { s.targets(p)? } else { s.visible_cloned() }; let mut jobs: Vec<SmartJob> = Vec::new(); for id in ids { let Some(ph) = s.catalog.photo(id).cloned() else { continue }; @@ -503,7 +503,7 @@ pub fn specs() -> Vec<CommandSpec> { smart_location ), cmd!(query "photo.smartPreview", "Smart Preview Status", [], None, "{id?} → {smartPreview: bool, originalOnline: bool}", always, |s, p| { - let id = s.targets(p).first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; + let id = s.targets(p)?.first().copied().ok_or_else(|| bad("photo.smartPreview", "no photo"))?; let online = match s.catalog.photo(id).map(|p| p.source.clone()) { Some(lightcraft_catalog::Source::File { path }) => std::path::Path::new(&path).exists(), _ => true, diff --git a/crates/engine/src/cmd/print.rs b/crates/engine/src/cmd/print.rs new file mode 100644 index 000000000..26f5d9bc4 --- /dev/null +++ b/crates/engine/src/cmd/print.rs @@ -0,0 +1,353 @@ +//! `print.*`: the Print module as commands. +//! +//! The module's state is [`Session::print`] (layout, image settings, page, guides, job), so the +//! app, CLI, control channel and MCP share one print. Lengths are inches in page space (origin at +//! the top-left of the page as printed); cell ids and photo ids are numbers. Nothing here touches +//! the catalog, so none of it is journaled or undoable; saved templates live in prefs.json. +//! +//! * `print.get`, `print.set {layout?, image?, page?, guides?, job?}`, `print.reset` +//! * `print.cell.add|move|resize|delete|stack|photo|page`, `print.layout.autoLayout|newPage|clear` +//! * `print.exportPdf {path, ids?}` +//! * `print.template.list|save|apply|delete` +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, f64_req}; +use crate::print::layout::{Layout, MAX_PAGES, Rect, Snap, Stack, paper_names, paper_size}; +use crate::print::template::{MAX_TEMPLATES, PrintSettings, PrintTemplate, clean_name}; +use crate::{Result, Session}; + +fn rect_json(r: &Rect) -> Value { + json!({"x": r.x, "y": r.y, "w": r.w, "h": r.h}) +} + +/// The state, with what a front end needs to draw it (page size, printable area, grid cells). +fn state(s: &Session, photos: usize) -> Value { + let l = &s.print.layout; + let mut out = json!({ + "settings": s.print, + "papers": paper_names().filter_map(|n| paper_size(n).map(|(w, h)| json!({"name": n, "width": w, "height": h}))).collect::<Vec<_>>(), + "overlaps": l.has_overlaps(), + "photos": photos, + }); + if let (Ok((w, h)), Ok(area), Some(o)) = (l.page_size(), l.printable(), out.as_object_mut()) { + o.insert("pageWidth".into(), json!(w)); + o.insert("pageHeight".into(), json!(h)); + o.insert("printable".into(), rect_json(&area)); + o.insert("pageCount".into(), json!(l.page_count(photos))); + o.insert("cellsPerPage".into(), json!(l.cells_per_page())); + if !l.style.is_package() { + o.insert("gridCells".into(), json!(l.grid_rects().unwrap_or_default().iter().map(rect_json).collect::<Vec<_>>())); + } + } + out +} + +fn reply(s: &Session) -> Result<Value> { + Ok(state(s, s.targets(&Value::Null)?.len())) +} + +/// Applies `patch` over `dst`: objects merge key by key, anything else replaces. +fn merge(dst: &mut Value, patch: &Value, depth: usize) { + match (dst, patch) { + (Value::Object(d), Value::Object(p)) if depth < 8 => { + for (k, v) in p { + match d.get_mut(k) { + Some(slot) => merge(slot, v, depth + 1), + None => { + d.insert(k.clone(), v.clone()); + } + } + } + } + (d, p) => *d = p.clone(), + } +} + +fn get(s: &mut Session, p: &Value) -> Result<Value> { + Ok(state(s, s.targets(p)?.len())) +} + +fn set(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.set"; + let patch = p.as_object().ok_or_else(|| bad(ID, "params must be an object"))?; + if let Some(k) = patch.keys().find(|k| !matches!(k.as_str(), "layout" | "image" | "page" | "guides" | "job")) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of layout, image, page, guides, job)"))); + } + if let Some(l) = patch.get("layout").and_then(Value::as_object) + && let Some(k) = l.keys().find(|k| matches!(k.as_str(), "cells" | "pages" | "nextId")) + { + return Err(bad(ID, format!("`layout.{k}` is changed with the print.cell.* and print.layout.* commands"))); + } + let mut value = serde_json::to_value(&s.print).map_err(|e| bad(ID, e.to_string()))?; + merge(&mut value, p, 0); + let mut next: PrintSettings = serde_json::from_value(value).map_err(|e| bad(ID, e.to_string()))?; + // A new paper, orientation or margin may leave cells outside the page: pull them back. + if next.layout.page.printable().is_ok() { + next.layout.fit_cells().map_err(|e| bad(ID, e))?; + } + next.validate().map_err(|e| bad(ID, e))?; + s.print = next; + reply(s) +} + +fn reset(s: &mut Session, _: &Value) -> Result<Value> { + s.print = PrintSettings::default(); + reply(s) +} + +fn cell_id(p: &Value, id: &str) -> Result<u32> { + p.get("id").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(id, "missing cell `id`")) +} + +fn opt_f64(p: &Value, key: &str, id: &str) -> Result<Option<f64>> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v.as_f64().map(Some).ok_or_else(|| bad(id, format!("`{key}` must be a number"))), + } +} + +/// Snaps `r` (a cell being moved or resized) when the call asks for it. +fn snapped(l: &Layout, guides: &crate::print::layout::Guides, id: u32, r: Rect, p: &Value, resize: bool, cmd: &str) -> Result<Rect> { + let on = match p.get("snap") { + None | Some(Value::Null) => false, + Some(Value::Bool(b)) => *b, + Some(_) => return Err(bad(cmd, "`snap` must be true or false")), + }; + if !on || guides.snap == Snap::Off { + return Ok(r); + } + let threshold = opt_f64(p, "threshold", cmd)?.unwrap_or(0.1); + let f = if resize { Layout::snap_resize } else { Layout::snap_move }; + f(l, id, r, guides.snap, guides.grid_size, threshold).map_err(|e| bad(cmd, e)) +} + +fn cell_add(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.add"; + let (w, h) = (f64_req(p, "width", ID)?, f64_req(p, "height", ID)?); + let mut next = s.print.layout.clone(); + let id = next.add_cell(w, h).map_err(|e| bad(ID, e))?; + if let (Some(x), Some(y)) = (opt_f64(p, "x", ID)?, opt_f64(p, "y", ID)?) { + if let Some(page) = p.get("page").and_then(Value::as_u64) { + let page = u32::try_from(page).map_err(|_| bad(ID, "`page` is too large"))?; + next.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + } + next.move_cell(id, x, y).map_err(|e| bad(ID, e))?; + } + if let Some(v) = p.get("photo").filter(|v| !v.is_null()) { + let photo = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id"))?; + next.set_cell_photo(id, Some(photo)).map_err(|e| bad(ID, e))?; + } + s.print.layout = next; + let cell = s.print.layout.cell(id).cloned(); + Ok(json!({"id": id, "cell": cell, "pages": s.print.layout.pages})) +} + +fn cell_move(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.move"; + let id = cell_id(p, ID)?; + let (x, y) = (f64_req(p, "x", ID)?, f64_req(p, "y", ID)?); + let mut next = s.print.layout.clone(); + let size = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let target = snapped(&next, &s.print.guides, id, Rect::new(x, y, size.w, size.h), p, false, ID)?; + let r = next.move_cell(id, target.x, target.y).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_resize(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.resize"; + let id = cell_id(p, ID)?; + let mut next = s.print.layout.clone(); + let cur = next.cell(id).map(|c| c.rect).ok_or_else(|| bad(ID, format!("no cell {id}")))?; + let want = Rect::new( + opt_f64(p, "x", ID)?.unwrap_or(cur.x), + opt_f64(p, "y", ID)?.unwrap_or(cur.y), + opt_f64(p, "width", ID)?.unwrap_or(cur.w), + opt_f64(p, "height", ID)?.unwrap_or(cur.h), + ); + let target = snapped(&next, &s.print.guides, id, want, p, true, ID)?; + let r = next.resize_cell(id, target).map_err(|e| bad(ID, e))?; + s.print.layout = next; + Ok(json!({"id": id, "rect": r})) +} + +fn cell_delete(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.delete"; + let id = cell_id(p, ID)?; + s.print.layout.delete_cell(id).map_err(|e| bad(ID, e))?; + Ok(json!({"deleted": id, "cells": s.print.layout.cells.len()})) +} + +fn cell_stack(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.stack"; + let id = cell_id(p, ID)?; + let how = match p.get("order").and_then(Value::as_str) { + Some("toBack") => Stack::ToBack, + Some("backward") => Stack::Backward, + Some("forward") => Stack::Forward, + Some("toFront") => Stack::ToFront, + _ => return Err(bad(ID, "`order` must be toBack, backward, forward or toFront")), + }; + s.print.layout.restack(id, how).map_err(|e| bad(ID, e))?; + Ok(json!({"order": s.print.layout.cells.iter().map(|c| c.id).collect::<Vec<_>>()})) +} + +fn cell_photo(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.photo"; + let id = cell_id(p, ID)?; + let photo = match p.get("photo") { + None | Some(Value::Null) => None, + Some(v) => { + let n = v.as_u64().ok_or_else(|| bad(ID, "`photo` must be a photo id or null"))?; + if s.catalog.photo(lightcraft_catalog::PhotoId(n)).is_none() { + return Err(bad(ID, format!("no photo {n}"))); + } + Some(n) + } + }; + s.print.layout.set_cell_photo(id, photo).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "photo": photo})) +} + +fn cell_page(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.cell.page"; + let id = cell_id(p, ID)?; + let page = p.get("page").and_then(Value::as_u64).and_then(|v| u32::try_from(v).ok()).ok_or_else(|| bad(ID, "missing zero-based `page`"))?; + s.print.layout.set_cell_page(id, page).map_err(|e| bad(ID, e))?; + Ok(json!({"id": id, "page": page})) +} + +fn auto_layout(s: &mut Session, _: &Value) -> Result<Value> { + s.print.layout.auto_layout().map_err(|e| bad("print.layout.autoLayout", e))?; + Ok(json!({"pages": s.print.layout.pages, "cells": s.print.layout.cells})) +} + +fn new_page(s: &mut Session, _: &Value) -> Result<Value> { + let page = s.print.layout.new_page().map_err(|e| bad("print.layout.newPage", e))?; + Ok(json!({"page": page, "pages": s.print.layout.pages, "max": MAX_PAGES})) +} + +fn clear(s: &mut Session, _: &Value) -> Result<Value> { + s.print.layout.clear(); + Ok(json!({"pages": 1, "cells": 0})) +} + +fn export_pdf(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.exportPdf"; + let path = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or_else(|| bad(ID, "missing path"))?; + if let Some(k) = p.as_object().and_then(|o| o.keys().find(|k| !matches!(k.as_str(), "path" | "ids" | "id"))) { + return Err(bad(ID, format!("unknown parameter `{k}` (one of path, ids; change the print with print.set)"))); + } + if p.get("ids").is_some_and(|v| v.as_array().is_none_or(|a| a.iter().any(|v| v.as_u64().is_none()))) { + return Err(bad(ID, "ids must be an array of photo ids")); + } + if p.get("id").is_some_and(|v| v.as_u64().is_none()) { + return Err(bad(ID, "id must be a photo id")); + } + s.check_write_target(path).map_err(|e| bad(ID, e))?; + let ids = s.targets(p)?; + let settings = s.print.clone(); + // a printer profile that can't be used fails the print before anything renders + crate::print_color::PrintColorJob::new(&settings.job.color).map_err(|e| bad(ID, e))?; + let title = std::path::Path::new(path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let work = crate::print::compose::prepare(s, &settings, &ids).map_err(|e| bad(ID, e))?; + let total = work.len() as u64; + let row = s.activity.start("export", "Printing to PDF", crate::activity::Cancel::No); + row.progress(0, total); + let pages = work + .run(&mut |done, name| { + row.progress(done as u64, total); + row.detail(name); + true + }) + .map_err(|e| bad(ID, e))?; + let bytes = crate::print::output::write(&pages, &settings.job, &title).map_err(|e| bad(ID, e))?; + s.check_write_target(path).map_err(|e| bad(ID, e))?; + crate::export::write_file(path, &bytes).map_err(|e| bad(ID, e))?; + Ok(json!({"path": path, "pages": pages.len(), "photos": ids.len(), "bytes": bytes.len()})) +} + +// ---- templates ------------------------------------------------------------------------------ + +fn template_list(s: &Session) -> Value { + json!({"templates": s.print_templates.iter().map(|t| json!({"name": t.name, "style": t.settings.pointer("/layout/style"), "settings": t.settings})).collect::<Vec<_>>()}) +} + +fn template_name(p: &Value, id: &str) -> Result<String> { + clean_name(p.get("name").and_then(Value::as_str).ok_or_else(|| bad(id, "missing `name`"))?).map_err(|e| bad(id, e)) +} + +fn template_save(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.save"; + let name = template_name(p, ID)?; + let t = PrintTemplate::new(&name, &s.print).map_err(|e| bad(ID, e))?; + match s.print_templates.iter_mut().find(|x| x.name.eq_ignore_ascii_case(&name)) { + Some(x) => *x = t, + None => { + if s.print_templates.len() >= MAX_TEMPLATES { + return Err(bad(ID, format!("at most {MAX_TEMPLATES} templates; delete one first"))); + } + s.print_templates.push(t); + } + } + s.save_prefs()?; + Ok(template_list(s)) +} + +fn template_apply(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.apply"; + let name = template_name(p, ID)?; + let t = s + .print_templates + .iter() + .find(|x| x.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| bad(ID, format!("no print template `{name}` (see print.template.list)")))?; + s.print = t.settings(&s.print.guides).map_err(|e| bad(ID, e))?; + reply(s) +} + +fn template_delete(s: &mut Session, p: &Value) -> Result<Value> { + const ID: &str = "print.template.delete"; + let name = template_name(p, ID)?; + let before = s.print_templates.len(); + s.print_templates.retain(|x| !x.name.eq_ignore_ascii_case(&name)); + if s.print_templates.len() == before { + return Err(bad(ID, format!("no print template `{name}`"))); + } + s.save_prefs()?; + Ok(template_list(s)) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!(query "print.get", "Print Settings", [], None, + "{ids?} → {settings: {layout, image, page, guides, job}, papers, pageWidth, pageHeight, printable, pageCount, cellsPerPage, gridCells?, overlaps}; lengths are inches", + always, get), + cmd!(query "print.set", "Set Print Settings", [], None, + "{layout?: {style: singleImage|contactSheet|picturePackage|customPackage, page: {paper, customWidth, customHeight, orientation: portrait|landscape, margins: {top,right,bottom,left}}, grid: {columns, rows, spacingX, spacingY, keepSquare}, spacing}, image?: {zoomToFill, rotateToFit, borderEnabled, borderWidth, borderColor: [r,g,b], strokeEnabled, strokeWidthPt, strokeColor}, page?: {background: [r,g,b], pageNumbers, pageInfo, cropMarks} (Page Options: single image and contact sheet only), guides?: {showRulers, units: in|cm|mm|pt, snap: off|cells|grid, gridSize, showMargins, showBleed, showPageGrid, showCells, showDimensions}, job?: {ppi: 72..720, color?: {profile: {kind: managed, space: srgb|adobeRgb} | {kind: icc, path}, intent: perceptual|relative, blackPointCompensation, brightness: -100..100, contrast: -100..100, sharpen: off|low|standard|high, media: matte|glossy, sixteenBit}}} — merges into the current print; cells and pages change with print.cell.* / print.layout.*; → print.get", + always, set), + cmd!(query "print.reset", "Reset Print Settings", [], None, "{} → print.get", always, reset), + cmd!(query "print.cell.add", "Add Print Cell", [], None, + "{width, height, x?, y?, page?, photo?} inches; picture/custom package → {id, cell, pages}; the cell goes to the first free spot unless x and y are given", + always, cell_add), + cmd!(query "print.cell.move", "Move Print Cell", [], None, "{id, x, y, snap?: bool, threshold?: inches} → {id, rect}; snap uses the guides' snap mode", always, cell_move), + cmd!(query "print.cell.resize", "Resize Print Cell", [], None, + "{id, width?, height?, x?, y?, snap?: bool, threshold?} → {id, rect}", always, cell_resize), + cmd!(query "print.cell.delete", "Delete Print Cell", [], None, "{id}", always, cell_delete), + cmd!(query "print.cell.stack", "Arrange Print Cell", [], None, "{id, order: toBack|backward|forward|toFront} → {order: cell ids back to front}", always, cell_stack), + cmd!(query "print.cell.photo", "Set Print Cell Photo", [], None, "{id, photo: photo id | null} (custom package: the photo printed in the cell)", always, cell_photo), + cmd!(query "print.cell.page", "Move Print Cell to Page", [], None, "{id, page: zero-based}", always, cell_page), + cmd!(query "print.layout.autoLayout", "Auto Layout", [], None, "{} — packs the package's cells onto as few pages as possible → {pages, cells}", always, auto_layout), + cmd!(query "print.layout.newPage", "New Print Page", [], None, "{} → {page, pages}", always, new_page), + cmd!(query "print.layout.clear", "Clear Print Layout", [], None, "{} — removes every cell and extra page", always, clear), + cmd!(query "print.exportPdf", "Print to PDF", [], None, + "{path, ids?: photo ids (default selection)} → {path, pages, photos, bytes}; prints the current print.get settings; replaces an existing output, never an original", + always, export_pdf), + cmd!(query "print.template.list", "Print Templates", [], None, "{} → {templates: [{name, style, settings}]}", always, |s, _| Ok(template_list(s))), + cmd!(query "print.template.save", "Save Print Template", [], None, + "{name} — saves the layout, image settings, page and job (not the cells' photos or the guides); an existing name (any case) is replaced → print.template.list", + always, template_save), + cmd!(query "print.template.apply", "Apply Print Template", [], None, "{name} → print.get", always, template_apply), + cmd!(query "print.template.delete", "Delete Print Template", [], None, "{name} → print.template.list", always, template_delete), + ] +} diff --git a/crates/engine/src/cmd/recheck.rs b/crates/engine/src/cmd/recheck.rs new file mode 100644 index 000000000..494426b90 --- /dev/null +++ b/crates/engine/src/cmd/recheck.rs @@ -0,0 +1,129 @@ +//! Preview-only raws rechecked after an update (issue #662): a raw imported while its format or +//! variant couldn't be decoded keeps [`Photo::preview_only`] until something re-reads its file. +//! Each flag is stamped with the app version that set it ([`Photo::preview_checked`]); when a +//! library opens in another version, its stale preview-only photos are probed again and get +//! exactly what Reload from Disk would give them: a file that decodes now loses the flag, one that +//! still doesn't gets the new reason and a fresh stamp, so it isn't probed again. +//! +//! The work is split so the app can probe on a worker thread: [`stale`] lists what to probe (no +//! file-system calls), [`probe`] reads the files without the session, and [`apply`] applies the +//! results as LightCraft's own bookkeeping (journaled, not an undo step). Agents and tests use +//! `library.recheckPreviewOnly`, which does all three at once. + +use lightcraft_catalog::{Catalog, Op, Photo, PhotoId}; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd}; +use crate::media::{FileProbe, ProbeInfo}; +use crate::{Result, Session}; + +/// A preview-only photo to probe, and what the library knew about it when it was listed (so a +/// result for a photo that changed meanwhile is dropped). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Stale { + pub id: PhotoId, + pub path: String, + preview_only: Option<String>, + preview_checked: String, +} + +impl Stale { + fn of(p: &Photo, path: &str) -> Stale { + Stale { id: p.id, path: path.to_string(), preview_only: p.preview_only.clone(), preview_checked: p.preview_checked.clone() } + } + + /// The photo is still as it was listed: same file, flag and stamp. + fn current(&self, p: &Photo) -> bool { + crate::cmd::missing::checked_path(p) == Some(self.path.as_str()) + && p.preview_only == self.preview_only + && p.preview_checked == self.preview_checked + } +} + +/// The library's preview-only photos whose flag another app version set (or every preview-only +/// photo, with `all`): library photos with a file, not virtual copies (they follow the photo they +/// were copied from). No file-system calls. +pub fn stale(cat: &Catalog, all: bool) -> Vec<Stale> { + cat.photos() + .filter(|p| p.copy_of.is_none() && p.preview_only.is_some() && (all || p.preview_only_stale())) + .filter_map(|p| crate::cmd::missing::checked_path(p).map(|path| Stale::of(p, path))) + .collect() +} + +/// The session's file probe, to [`probe`] with on a worker thread; `None` when the session has no +/// file access (a demo or browser session), where nothing can be rechecked. +pub fn file_probe(s: &Session) -> Option<FileProbe> { + s.media.file_probe.clone() +} + +/// Probe each listed photo's file as Reload would. Doesn't touch the session. +pub fn probe(probe: &FileProbe, stale: Vec<Stale>) -> Vec<(Stale, std::result::Result<ProbeInfo, String>)> { + let paths: Vec<String> = stale.iter().map(|x| x.path.clone()).collect(); + stale.into_iter().zip(crate::import::probe_with(probe, &paths)).collect() +} + +/// What a recheck changed. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Rechecked { + /// Photos brought up to date (virtual copies included). + pub updated: Vec<PhotoId>, + /// Of those, the ones that develop from their raw data now. + pub cleared: Vec<PhotoId>, +} + +impl Rechecked { + pub fn to_json(&self) -> Value { + json!({"updated": self.updated.iter().map(|i| i.0).collect::<Vec<_>>(), "cleared": self.cleared.iter().map(|i| i.0).collect::<Vec<_>>()}) + } +} + +/// Apply [`probe`]'s results: each photo gets the ops Reload would give it, all in one batch that is +/// journaled but isn't an undo step. A file that can't be read (missing, offline) changes nothing, +/// so it is tried again next time; a photo that changed since it was listed (relinked, reloaded, +/// removed) is skipped. +pub fn apply(s: &mut Session, results: Vec<(Stale, std::result::Result<ProbeInfo, String>)>) -> Result<Rechecked> { + let mut ops = Vec::new(); + let mut out = Rechecked::default(); + for (item, info) in results { + let Ok(info) = info else { continue }; + if !s.catalog.photo(item.id).is_some_and(|p| item.current(p)) { + continue; + } + let first = out.updated.len(); + crate::cmd::convert::reload_ops(&s.catalog, item.id, &info, &mut ops, &mut out.updated); + if info.preview_only.is_none() { + out.cleared.extend_from_slice(&out.updated[first..]); + } + } + if !ops.is_empty() { + s.apply_system(Op::Batch { ops })?; + } + for id in &out.updated { + s.media.forget(*id); + } + Ok(out) +} + +fn recheck(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "library.recheckPreviewOnly"; + let all = p.get("all").and_then(Value::as_bool).unwrap_or(false); + // without file access every probe "succeeds" with nothing known, which would clear every flag + let fp = file_probe(s).ok_or_else(|| bad(C, "this session has no file access"))?; + let listed = stale(&s.catalog, all); + let n = listed.len(); + let mut r = apply(s, probe(&fp, listed))?.to_json(); + r["checked"] = json!(n); + Ok(r) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![cmd!( + "library.recheckPreviewOnly", + "Recheck Preview-Only Raws", + [], + None, + "{all?: bool} — probe again the raws shown from their embedded preview whose flag another LightCraft version set (the app does this in the background when a library opens; `all` rechecks every preview-only raw, e.g. when testing a decoder build of the same version), and bring each up to date as Reload from Disk would; not an undo step, develop settings and history are kept; files that can't be read are left for next time → {checked, updated: [ids], cleared: [ids] (develop from their raw data now)}", + always, + recheck + )] +} diff --git a/crates/engine/src/cmd/slideshow.rs b/crates/engine/src/cmd/slideshow.rs new file mode 100644 index 000000000..33ff76ec0 --- /dev/null +++ b/crates/engine/src/cmd/slideshow.rs @@ -0,0 +1,513 @@ +//! `slideshow.playback`: the slideshow's Playback settings (slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). `slideshow.layout`: how a slide looks +//! (Layout, Options and Backdrop: margins, aspect preview, zoom to fill, stroke, shadow, backdrop +//! colour, colour wash, background image). Both are library preferences, saved in the library's +//! `prefs.json`; the app's slideshow (View ▸ Slideshow) plays with them. `slideshow.overlays`: +//! the identity plate, rating stars, text overlays with tokens and the watermark drawn on every +//! slide (also a library preference). `slideshow.titles`: the intro and ending screens (colour, +//! identity plate or text, scale, duration; a library preference). `slideshow.preview` composes +//! one slide, or a title screen, as a PNG (with the library's settings, or a template's). +//! `slideshow.templates.*`: the Template Browser (built-in and user templates; see +//! [`crate::slide_template`]). `slideshow.export`: the slideshow as a PDF, a JPEG sequence or an +//! AV1 video, in the background ([`crate::slideshow_export`]); `slideshow.exportStatus` reads how +//! it is going. + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use super::{CommandSpec, always, bad, cmd, str_param}; +use crate::slide_template::{self as tpl, SlideLook}; +use crate::{Result, Session}; + +/// Largest side `slideshow.preview` renders. +pub const PREVIEW_MAX: usize = 4096; + +fn playback(s: &mut Session, p: &Value) -> Result<Value> { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slideshow.merged(p).map_err(|e| bad("slideshow.playback", e))?; + if next != s.slideshow { + s.slideshow = next; + s.save_prefs()?; + } + } + Ok(s.slideshow.to_json()) +} + +fn layout(s: &mut Session, p: &Value) -> Result<Value> { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_layout.merged(p).map_err(|e| bad("slideshow.layout", e))?; + if next != s.slide_layout { + s.slide_layout = next; + s.save_prefs()?; + } + } + Ok(s.slide_layout.to_json()) +} + +fn overlays(s: &mut Session, p: &Value) -> Result<Value> { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_overlays.merged(p).map_err(|e| bad("slideshow.overlays", e))?; + if next != s.slide_overlays { + s.slide_overlays = next; + s.save_prefs()?; + } + } + let mut out = s.slide_overlays.to_json(); + out["warnings"] = json!(s.slide_overlays.warnings()); + Ok(out) +} + +fn titles(s: &mut Session, p: &Value) -> Result<Value> { + let changes = p.as_object().is_some_and(|o| !o.is_empty()); + if changes { + let next = s.slide_titles.merged(p).map_err(|e| bad("slideshow.titles", e))?; + if next != s.slide_titles { + s.slide_titles = next; + s.save_prefs()?; + } + } + let mut out = s.slide_titles.to_json(); + out["warnings"] = json!(s.slide_titles.warnings(&s.slide_overlays)); + Ok(out) +} + +/// A slide size from the request: both sides given, one (the other from the aspect preview), or +/// neither (1280 wide). Each side 16–[`PREVIEW_MAX`]. +fn preview_size(layout: &lightcraft_slideshow::SlideSettings, p: &Value) -> Result<(usize, usize)> { + const C: &str = "slideshow.preview"; + let side = |k: &str| -> Result<Option<usize>> { + match p.get(k) { + None | Some(Value::Null) => Ok(None), + Some(v) => match v.as_u64() { + Some(n) if (16..=PREVIEW_MAX as u64).contains(&n) => Ok(Some(n as usize)), + _ => Err(bad(C, format!("`{k}` must be a whole number of pixels from 16 to {PREVIEW_MAX}, not {v}"))), + }, + } + }; + let ratio = layout.aspect.ratio(f64::NAN); + let clamp = |v: f64| (v.round() as usize).clamp(16, PREVIEW_MAX); + Ok(match (side("width")?, side("height")?) { + (Some(w), Some(h)) => (w, h), + (Some(w), None) => (w, clamp(w as f64 / ratio)), + (None, Some(h)) => (clamp(h as f64 * ratio), h), + (None, None) => (1280, clamp(1280.0 / ratio)), + }) +} + +/// Write `png` to `path`, or put it in the reply as base64. +fn deliver(png: &[u8], path: Option<&str>, out: &mut Value) -> Result<()> { + match path { + Some(path) => { + crate::export::write_file(path, png).map_err(crate::EngineError::Other)?; + out["path"] = json!(path); + } + None => out["png"] = json!(base64(png)), + } + Ok(()) +} + +fn encode_png(img: &lightcraft_raster::Rgba8) -> Result<Vec<u8>> { + lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(img), &lightcraft_codecs::EncodeMeta::default()) + .map_err(|e| crate::EngineError::Other(format!("PNG: {e}"))) +} + +/// `slideshow.preview {title}`: a title screen (whether or not it is switched on). +fn preview_title(s: &mut Session, p: &Value, title: &Value, look: &SlideLook) -> Result<Value> { + const C: &str = "slideshow.preview"; + let t = title + .as_str() + .and_then(crate::slideshow::Title::parse) + .ok_or_else(|| bad(C, format!("`title` must be \"intro\" or \"ending\", not {title}")))?; + if p.get("id").is_some_and(|v| !v.is_null()) { + return Err(bad(C, "pass `id` or `title`, not both")); + } + let (w, h) = preview_size(&look.layout, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let c = crate::slide_title::TitleCompose::new(&look.titles, &look.overlays, t, w, h); + let img = c.compose(); + let png = encode_png(&img)?; + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "title": t.id(), + "enabled": look.titles.screen(t).enabled, + "width": img.width, + "height": img.height, + "plate": c.plate_box().map(rect), + }); + deliver(&png, path, &mut out)?; + Ok(out) +} + +fn preview(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.preview"; + // a template's look (the Template Browser's hover preview), else the library's settings + let template = match p.get("template") { + None | Some(Value::Null) => None, + Some(Value::String(id)) => Some(s.slide_template(id.trim()).map_err(|e| bad(C, e))?), + Some(v) => return Err(bad(C, format!("`template` must be a template id, not {v}"))), + }; + let look = template.as_ref().map_or_else(|| SlideLook::of(s), |t| t.look.clone()); + if let Some(t) = p.get("title").filter(|v| !v.is_null()) { + let mut out = preview_title(s, p, t, &look)?; + if let Some(t) = &template { + out["template"] = json!(t.id); + } + return Ok(out); + } + let id = match p.get("id") { + None | Some(Value::Null) => s.active().ok_or_else(|| bad(C, "no photo selected (select one or pass `id`)"))?, + Some(v) => PhotoId(v.as_u64().ok_or_else(|| bad(C, format!("`id` must be a photo id, not {v}")))?), + }; + if s.catalog.photo(id).is_none() { + return Err(bad(C, format!("no photo {}", id.0))); + } + let (w, h) = preview_size(&look.layout, p)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()); + if let Some(path) = path { + s.check_write_target(path).map_err(|e| bad(C, e))?; + } + let settings = look.layout.clone(); + let img = s.slide_now_look(&settings, &look.overlays, id, w, h).map_err(crate::EngineError::Other)?; + let png = encode_png(&img)?; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&settings, aspect, img.width, img.height); + let rect = |r: lightcraft_slideshow::Rect| json!([r.x0, r.y0, r.x1, r.y1]); + let mut out = json!({ + "id": id.0, + "width": img.width, + "height": img.height, + "cell": rect(l.cell), + "photo": rect(l.visible), + }); + let photo = s.catalog.photo(id).cloned(); + let o = crate::slide_overlay::SlideOverlays::resolve(&look.overlays, photo.as_deref()); + out["overlays"] = o.boxes(&l).into_iter().map(|(k, r)| json!({"kind": k.id(), "box": rect(r)})).collect(); + if let Some(t) = &template { + out["template"] = json!(t.id); + } + deliver(&png, path, &mut out)?; + Ok(out) +} + +/// Standard padded base64 (for an image returned in the reply). +fn base64(bytes: &[u8]) -> String { + const A: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let b = [chunk.first().copied().unwrap_or(0), chunk.get(1).copied().unwrap_or(0), chunk.get(2).copied().unwrap_or(0)]; + let n = (b[0] as u32) << 16 | (b[1] as u32) << 8 | b[2] as u32; + let at = |shift: u32| A.get((n >> shift) as usize & 63).copied().unwrap_or(b'=') as char; + out.push(at(18)); + out.push(at(12)); + out.push(if chunk.len() > 1 { at(6) } else { '=' }); + out.push(if chunk.len() > 2 { at(0) } else { '=' }); + } + out +} + +// ------------------------------------------------------------------------------- templates + +/// The settings as the `slideshow.*` commands return them (for template replies). +fn settings_json(s: &Session) -> Value { + json!({ + "playback": s.slideshow.to_json(), + "layout": s.slide_layout.to_json(), + "overlays": s.slide_overlays.to_json(), + "titles": s.slide_titles.to_json(), + }) +} + +fn tpl_list(s: &mut Session, _p: &Value) -> Result<Value> { + let l = s.slide_templates(); + Ok(json!({ + "templates": l.templates.iter().map(|t| t.info()).collect::<Vec<_>>(), + "previous": s.slide_templates_previous.is_some(), + "folder": s.slide_templates_dir.as_ref().map(|d| d.to_string_lossy().to_string()), + "problems": l.problems, + })) +} + +/// The template `id` or `name` names. +fn named(s: &mut Session, p: &Value, c: &str) -> Result<std::sync::Arc<tpl::Template>> { + s.slide_template_named(str_param(p, "id"), str_param(p, "name")).map_err(|e| bad(c, e)) +} + +fn tpl_apply(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.apply"; + let t = named(s, p, C)?; + let warnings = s.apply_slide_look(&t.look)?; + let mut out = json!({"applied": t.id, "name": t.name, "warnings": warnings, "previous": s.slide_templates_previous.is_some()}); + out["settings"] = settings_json(s); + Ok(out) +} + +fn tpl_reply(t: &tpl::Template) -> Value { + t.info() +} + +fn tpl_save(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.save"; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let replace = match p.get("replace") { + None | Some(Value::Null) => false, + Some(v) => v.as_bool().ok_or_else(|| bad(C, format!("`replace` must be true or false, not {v}")))?, + }; + let t = s.save_slide_template(name, replace).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn id_param<'a>(p: &'a Value, c: &str) -> Result<&'a str> { + str_param(p, "id").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(c, "missing `id` (a template id from slideshow.templates.list)")) +} + +fn tpl_update(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.update"; + let id = id_param(p, C)?; + let t = s.update_slide_template(id).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_rename(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.rename"; + let id = id_param(p, C)?; + let name = str_param(p, "name").ok_or_else(|| bad(C, "missing `name`"))?; + let t = s.rename_slide_template(id, name).map_err(|e| bad(C, e))?; + Ok(tpl_reply(&t)) +} + +fn tpl_delete(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.delete"; + let id = id_param(p, C)?; + let t = s.delete_slide_template(id).map_err(|e| bad(C, e))?; + Ok(json!({"deleted": t.id, "name": t.name})) +} + +fn tpl_import(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.import"; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let (t, renamed) = s.import_slide_template(std::path::Path::new(path)).map_err(|e| bad(C, e))?; + let mut out = tpl_reply(&t); + out["renamed"] = json!(renamed); + out["warnings"] = json!(t.look.warnings(s)); + Ok(out) +} + +fn tpl_export(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.templates.export"; + let id = id_param(p, C)?; + let path = str_param(p, "path").map(str::trim).filter(|x| !x.is_empty()).ok_or_else(|| bad(C, "missing `path`"))?; + let t = s.slide_template(id).map_err(|e| bad(C, e))?; + s.check_write_target(path).map_err(|e| bad(C, e))?; + let bytes = serde_json::to_vec_pretty(&t.to_doc()).map_err(|e| crate::EngineError::Other(e.to_string()))?; + crate::export::write_file(path, &bytes).map_err(crate::EngineError::Other)?; + Ok(json!({"id": t.id, "name": t.name, "path": path})) +} + +/// `slideshow.export`: prepare on this thread, run on a worker (or here with `wait`). +fn export(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "slideshow.export"; + if cfg!(target_arch = "wasm32") { + return Err(bad(C, "slideshow export needs the desktop app or lightcraft-cli")); + } + if s.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err(bad(C, "a slideshow export is already running (activity.cancel stops it)")); + } + let prepared = crate::slideshow_export::prepare(s, p).map_err(|e| bad(C, e))?; + let wait = p.get("wait").and_then(Value::as_bool).unwrap_or(false); + let cancel = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let task = s.activity.start("export", "Exporting slideshow", crate::activity::Cancel::Flag(cancel.clone())); + let total = prepared.total(); + task.progress(0, total); + task.detail(&prepared.request.path); + let settings = &prepared.request.settings; + let run = std::sync::Arc::new(crate::slideshow_export::ExportRun { + task_id: task.id(), + format: settings.format.id().into(), + path: prepared.request.path.clone(), + total: total.into(), + cancel, + ..Default::default() + }); + s.slideshow_export = Some(run.clone()); + let started = json!({ + "taskId": task.id(), + "background": !wait, + "format": settings.format.id(), + "path": prepared.request.path, + "slides": prepared.len(), + "seconds": prepared.seconds(), + "total": total, + "width": prepared.width, + "height": prepared.height, + "warnings": prepared.warnings, + }); + if wait { + crate::slideshow_export::run_to_end(prepared, &run, &task); + drop(task); + return match run.result() { + Some(Ok(v)) => Ok(v), + Some(Err(e)) => Err(bad(C, e)), + None => Err(bad(C, "the export did not finish")), + }; + } + #[cfg(not(target_arch = "wasm32"))] + { + // the row goes when the worker ends, however it ends + let r = run.clone(); + std::thread::Builder::new() + .name("lc-slideshow-export".into()) + .spawn(move || crate::slideshow_export::run_to_end(prepared, &r, &task)) + .map_err(|e| bad(C, format!("could not start: {e}")))?; + } + Ok(started) +} + +fn export_status(s: &mut Session, _: &Value) -> Result<Value> { + Ok(s.slideshow_export.as_ref().map_or_else(|| json!({"running": false}), |r| r.json())) +} + +pub fn specs() -> Vec<CommandSpec> { + vec![ + cmd!( + "slideshow.playback", + "Slideshow Playback Settings", + [], + None, + "{slideDuration?: seconds (0.5–120), fadeDuration?: seconds (0 = cut; at most 10 and the slide duration), fadeColor?: \"#rrggbb\"|[r,g,b]|null (fade through a colour instead of crossfading), randomOrder?: bool, repeat?: bool, preparePreviews?: bool (render the next slides before they are due)} — omitted keys keep their value, {} reads them; saved with the library → {slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews}", + always, + playback + ), + cmd!( + "slideshow.layout", + "Slideshow Layout Settings", + [], + None, + "{margins?: all four, marginLeft?, marginRight?, marginTop?, marginBottom?: fractions of the slide's shorter side (0–0.45; with linkMargins one sets all four), linkMargins?: bool, showGuides?: bool (margin guides in the slide editor), aspect?: \"screen\"|\"16:9\"|\"4:3\" (aspect preview), zoomToFill?: bool (crop the photo to fill its cell), strokeBorder?: bool, strokeColor?: \"#rrggbb\"|[r,g,b], strokeWidth?: fraction of the shorter side (0–0.05), castShadow?: bool, shadowOpacity?: 0–1, shadowOffset?: 0–0.1, shadowRadius?: 0–0.1 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right (−45 = down right), backdropColor?: colour, colorWash?: bool, washColor?: colour, washOpacity?: 0–1, washAngle?: degrees (the side the wash colour is full at), backgroundImage?: photo id|null, backgroundOpacity?: 0–1} — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings", + always, + layout + ), + cmd!( + "slideshow.overlays", + "Slideshow Overlay Settings", + [], + None, + "{identityPlate?: {enabled?, text?, image?: PNG/JPEG file (drawn instead of the text; \"\" = the text), size?: height as a fraction of the slide's shorter side (0.005–0.5), color?: \"#rrggbb\"|[r,g,b], opacity?: 0–1, …placement, behind?: bool (render behind the photo)}, ratingStars?: {enabled?, color?, size?: a star's height (0.005–0.15), …placement}, texts?: [text overlay] (replaces the list, at most 16; each entry merged onto the one at its index), addText?: text overlay ({} = the default \"{file}\"), removeText?: index, showWatermark?: bool, watermark?: text | the export watermark object {text, vertical, size, opacity, anchor, inset, color, shadow, image, imageWidth} (merged; sizes relative to the photo)} where a text overlay is {text?: template with tokens ({file}, {name}, {title}, {caption}, {date} (YYYY-MM-DD) / {date:%d.%m.%Y}, {exposure}, {shutter}, {aperture}, {focal}, {iso}, {camera}, {lens}, {rating}, {creator}, {copyright}, {folder}, {ext}; unknown tags show as typed), size?: line height (0.005–0.3), color?, opacity?, …placement, shadow?: bool, shadowOpacity?: 0–1, shadowOffset?, shadowRadius?: 0–0.05 (fractions of the shorter side), shadowAngle?: degrees counter-clockwise from the right} and the placement keys are anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, offsetX?, offsetY?: fractions of the slide's shorter side (x right, y down; −1–1), relativeTo?: \"slide\"|\"photo\" (the overlay is kept on the slide) — omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library; slideshow playback and slideshow.preview draw them → the settings and warnings (an unreadable identity plate image, unknown tokens)", + always, + overlays + ), + cmd!( + "slideshow.titles", + "Slideshow Title Settings", + [], + None, + "{intro?: screen, ending?: screen} where a screen is {enabled?: bool, color?: background \"#rrggbb\"|[r,g,b], content?: \"none\"|\"identityPlate\" (the Overlays identity plate, its text or image)|\"text\", text?: the text shown with content \"text\", overrideColor?: bool (draw the plate or text in textColor instead of the identity plate's colour; an image plate keeps its colours), textColor?: colour, scale?: height of the plate or text line as a fraction of the slide's shorter side (0.005–0.5; text wider than the slide shrinks to fit), duration?: seconds (0.5–120) | null (the slide duration)} — the intro plays once before the first slide, the ending after the last one when repeat is off; both fade with the playback fade; random order never moves them. Omitted keys keep their value, {} reads them; out-of-range numbers are clamped, wrong types refused; saved with the library → the settings and warnings (an unreadable identity plate image, a screen with nothing to show)", + always, + titles + ), + cmd!( + query "slideshow.templates.list", + "Slideshow Templates", + [], + None, + "{} → {templates: [{id: \"builtin:…\"|\"user:…\", name, builtin, group: \"builtin\"|\"user\", description?, path? (a user template's file)}], previous: bool (the settings before the last apply can be restored with apply {id: \"previous\"}), folder: the user templates folder | null, problems: [\"file: why it was skipped\"]} — built-ins first, then user templates by name (files in <settings>/slideshow-templates, at most 1000)", + always, + tpl_list + ), + cmd!( + "slideshow.templates.apply", + "Apply Slideshow Template", + [], + None, + "{id: template id | \"previous\" (the settings before the last apply) | name: template name (case-insensitive)} — replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's and saves them with the library; there is no undo: the replaced settings are kept as \"previous\" for this session → {applied, name, warnings: [a background photo not in this library, an unreadable identity plate / watermark image…], previous, settings: {playback, layout, overlays, titles}}", + always, + tpl_apply + ), + cmd!( + "slideshow.templates.save", + "Save Slideshow Template", + [], + None, + "{name: 1–80 characters, replace?: bool (over a user template of that name)} — the current playback, layout, overlays and titles settings as a user template, <settings>/slideshow-templates/<name>.json (path separators and other reserved characters become _); a name taken (case-insensitive) or a built-in's is refused unless replace → {id, name, builtin, group, path}", + always, + tpl_save + ), + cmd!( + "slideshow.templates.update", + "Update Slideshow Template", + [], + None, + "{id: user template} — replace its settings with the current ones (name kept); built-ins can't change → {id, name, builtin, group, path}", + always, + tpl_update + ), + cmd!( + "slideshow.templates.rename", + "Rename Slideshow Template", + [], + None, + "{id: user template, name} — its file follows the name; a name taken is refused → {id (the new one), name, builtin, group, path}", + always, + tpl_rename + ), + cmd!( + "slideshow.templates.delete", + "Delete Slideshow Template", + [], + None, + "{id: user template} — deletes its file → {deleted, name}", + always, + tpl_delete + ), + cmd!( + "slideshow.templates.import", + "Import Slideshow Template", + [], + None, + "{path: a template .json file (at most 256 KB)} — checked like a saved one (format, version, every setting) and saved as a user template under its name (else the file's); a name taken gets \" (2)\"… → {id, name, builtin, group, path, renamed, warnings}", + always, + tpl_import + ), + cmd!( + "slideshow.templates.export", + "Export Slideshow Template", + [], + None, + "{id: template (built-in or user), path: .json file to write} → {id, name, path}", + always, + tpl_export + ), + cmd!( + query "slideshow.preview", + "Slideshow Slide Preview", + [], + None, + "{id?: photo (default: the active one) | title?: \"intro\"|\"ending\", template?: a template id (slideshow.templates.list): compose with its settings instead of the library's, which don't change (the Template Browser's hover preview), width?, height?: pixels (16–4096; one given: the other from the aspect preview; neither: 1280 wide), path?: write the PNG here} — the photo's slide composed with the slideshow layout and overlays → {id, width, height, cell: [x0,y0,x1,y1], photo: [x0,y0,x1,y1] (the visible photo, slide pixels), overlays: [{kind: identityPlate|ratingStars|textN|watermark, box: [x0,y0,x1,y1]}], path | png (base64 PNG)}; with `title`, that title screen (`slideshow.titles`; drawn even while it is off) → {title, enabled, width, height, plate: [x0,y0,x1,y1] | null (the plate or text), path | png}", + always, + preview + ), + cmd!( + query "slideshow.export", + "Export Slideshow", + [], + None, + "{format: \"pdf\"|\"jpeg\"|\"video\", path: the .pdf / .mp4 / .webm file, or for jpeg the folder (created if missing), size?: pdf/jpeg the long side in pixels (64–8192, default 1920), video the frame height (64–2160 or \"720p\"/\"1080p\", default 720), quality?: 1–100 (JPEG quality, default 90; video default 75), fps?: 24|25|30|60 (video, default 30), container?: \"mp4\"|\"webm\" (video, default mp4), fullScreen?: bool (pdf: open in the viewer's full screen mode, default true), speed?: 1–10 (video encoder speed, default 10 = fastest), name?: jpeg file name prefix (default the folder's name: <name>-001.jpg…), ids?: photo ids (default the photos in view, in view order), wait?: bool (run here and reply with the result)} — the photos' slides with the slideshow layout, overlays and the intro/ending screens that are on; video is AV1 (no audio), each slide held for its duration and fades rendered as frames, at most 2 hours. Runs in the background with a row in the activity stack (activity.cancel {id: taskId} stops it; nothing is left behind: files are written as .part and renamed when complete); a photo that can't be rendered is skipped with a warning → {taskId, background, format, path, slides, seconds, total, width, height, warnings}; with wait: the result (slideshow.exportStatus)", + always, + export + ), + cmd!( + query "slideshow.exportStatus", + "Slideshow Export Status", + [], + None, + "{} → the last slideshow export: {taskId, format, path, running, done, total (slides, or frames for video), cancelled, result?: {format, path, width, height, slides, skipped, warnings, pages? | files? | frames, samples, keyFrames, seconds, fps, container, codec: \"av1\", audio: false; bytes}, error?} | {running: false} when there was none", + always, + export_status + ), + ] +} diff --git a/crates/engine/src/cmd/xmp.rs b/crates/engine/src/cmd/xmp.rs index cd89aa896..fb0ec9883 100644 --- a/crates/engine/src/cmd/xmp.rs +++ b/crates/engine/src/cmd/xmp.rs @@ -12,7 +12,7 @@ fn save(s: &mut Session, p: &Value) -> Result<Value> { let mut merged = Vec::new(); let mut backups = Vec::new(); let mut failed = Vec::new(); - let ids = s.targets(p); + let ids = s.targets(p)?; let owners = crate::sidecar::StemOwners::among(&s.catalog, &ids); for id in ids { match s.save_sidecar_with(id, &owners) { @@ -37,7 +37,7 @@ fn read(s: &mut Session, p: &Value) -> Result<Value> { let mut read = Vec::new(); let mut failed = Vec::new(); let mut warnings = Vec::new(); - for id in s.targets(p) { + for id in s.targets(p)? { match s.read_sidecar_op_with_warnings(id) { Ok(Some((op, from, mut photo_warnings))) => { ops.push(op); @@ -108,6 +108,18 @@ pub fn specs() -> Vec<CommandSpec> { always, prefs ), + cmd!( + "library.retrySidecars", + "Write Waiting XMP Sidecars", + [], + None, + "{} — writes again the automatic XMP sidecars that failed (folder offline, read-only or full) whose originals are reachable now → {written, waiting}", + always, + |s, _| { + let written = s.retry_unsaved_sidecars(); + Ok(json!({"written": written, "waiting": s.unsaved_sidecars()})) + } + ), cmd!("library.toggleAutoWriteXmp", "Automatically Write Changes into XMP", ["File"], None, "{} → {autoWrite}", always, |s, _| { let on = !s.xmp.auto_write; prefs(s, &json!({"autoWrite": on})) diff --git a/crates/engine/src/contact_sheet.rs b/crates/engine/src/contact_sheet.rs index e83ad8c67..381e84218 100644 --- a/crates/engine/src/contact_sheet.rs +++ b/crates/engine/src/contact_sheet.rs @@ -1,4 +1,5 @@ -//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF. +//! Paginated contact sheets, rendered with the normal export pipeline and packaged as PDF +//! ([`crate::pdf`]). use crate::export::{Anchor, ExportFormat, ExportOptions, Watermark, draw_watermark, encode_image}; use crate::{Session, media::RenderJob}; use lightcraft_pipeline::{OutputDepth, OutputSpace}; @@ -82,7 +83,7 @@ pub fn prepare(session: &mut Session, p: &Value) -> Result<Prepared, String> { if p.get("id").is_some_and(|v| v.as_u64().is_none()) { return Err("id must be a photo id".into()); } - let ids = session.targets(p); + let ids = session.targets(p).map_err(|e| e.to_string())?; if ids.is_empty() || ids.len() > 1000 { return Err("select between 1 and 1000 photos".into()); } @@ -114,7 +115,7 @@ impl Prepared { let capacity = self.options.columns * self.options.rows; let photos = self.items.len(); let pages = photos.div_ceil(capacity); - let mut pdf = Pdf::new(pages, l.points); + let mut pdf = crate::pdf::Pdf::new(Vec::new())?; let mut page = Rgba8::filled(l.width, l.height, [255; 4]); for (i, (name, job)) in self.items.into_iter().enumerate() { if !progress(i, &name) { @@ -149,17 +150,17 @@ impl Prepared { } if slot + 1 == capacity || i + 1 == photos { let jpg = encode_image(&page, &ExportOptions { format: ExportFormat::Jpeg, quality: 92, ..Default::default() })?; - if pdf.bytes.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { + if pdf.len().saturating_add(jpg.len()) > 256 * 1024 * 1024 { return Err("contact sheet exceeds 256 MiB; select fewer photos".into()); } - pdf.page(i / capacity, &jpg, l.width, l.height); + pdf.image_page(&jpg, l.width, l.height, l.points, "")?; page.data.fill([255; 4]); } } if !progress(photos, "PDF") { return Err("Contact sheet cancelled".into()); } - Ok(Document { bytes: pdf.finish(), pages, photos }) + Ok(Document { bytes: pdf.finish("")?, pages, photos }) } } @@ -196,67 +197,6 @@ fn blit(dst: &mut Rgba8, src: &Rgba8, x: usize, y: usize) -> Result<(), String> Ok(()) } -// Minimal PDF 1.4: one JPEG XObject per page, explicit byte lengths and classic xref offsets. -struct Pdf { - bytes: Vec<u8>, - offsets: Vec<usize>, - 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::<Vec<_>>().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<u8> { - let start = self.bytes.len(); - self.bytes.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).as_bytes()); - for offset in self.offsets.iter().skip(1) { - self.bytes.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); - } - self.bytes.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); - self.bytes - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/engine/src/crs_masks.rs b/crates/engine/src/crs_masks.rs index 483d1778b..848251c65 100644 --- a/crates/engine/src/crs_masks.rs +++ b/crates/engine/src/crs_masks.rs @@ -64,13 +64,20 @@ const LOCAL: &[(&str, &str, f64)] = &[ ("LocalClarity", "clarity", 100.0), ]; -fn adjust(c: &XmpValue) -> Value { +/// Is `crs` (without the `crs:` prefix) a local slider field we map? +pub(crate) fn is_local_field(crs: &str) -> bool { + LOCAL.iter().any(|(k, _, _)| *k == crs) +} + +/// Our local adjustment values from the `Local*` slider fields that `get` finds (the name without +/// the `crs:` prefix → the stored fraction); process-version 2012 fields win over the older ones. +pub(crate) fn local_values(get: impl Fn(&str) -> Option<f64>) -> serde_json::Map<String, Value> { let mut a = serde_json::Map::new(); for (crs, ours, k) in LOCAL { if a.contains_key(*ours) { continue; } - if let Some(v) = num(c, &format!("crs:{crs}")) { + if let Some(v) = get(crs).filter(|v| v.is_finite()) { let lim = if *ours == "exposure" { 4.0 } else if *ours == "color_hue" { @@ -82,11 +89,31 @@ fn adjust(c: &XmpValue) -> Value { a.insert((*ours).into(), json!(v)); } } + a +} + +fn adjust(c: &XmpValue) -> Value { + let mut a = local_values(|crs| num(c, &format!("crs:{crs}"))); let amount = num(c, "crs:CorrectionAmount").unwrap_or(1.0); a.insert("amount".into(), json!((amount * 100.0).clamp(0.0, 200.0))); Value::Object(a) } +/// The correction's local sliders we have no counterpart for (e.g. `LocalGrain`), when set: +/// reported, never dropped silently. +fn unmapped_local(c: &XmpValue) -> Vec<String> { + let XmpValue::Struct(m) = c else { return Vec::new() }; + m.keys() + .filter_map(|k| { + let field = k.strip_prefix("crs:").filter(|f| f.starts_with("Local"))?; + // 100 = "curve saturation unchanged", written with every correction + let neutral = if field == "LocalCurveRefineSaturation" { 100.0 } else { 0.0 }; + let set = num(c, k).is_some_and(|v| v != neutral); + (set && !LOCAL.iter().any(|(crs, ..)| *crs == field)).then(|| field.to_string()) + }) + .collect() +} + /// Perceptual lightness 0..1 (how range masks store luminance) → our range scale: log /// luminance on −8..+4 EV mapped to 0..1. fn lightness_to_range(l: f64) -> f64 { @@ -98,8 +125,46 @@ fn pt(x: f64, y: f64) -> Value { json!({"x": x, "y": y}) } -/// One mask component → our component JSON; `Err(kind)` names an unsupported one. -fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { +/// Whether a `Mask/CircularGradient` component puts its effect inside the ellipse, given the +/// container it came from (`crs:Flipped` means different things in different formats). +/// +/// What Lightroom Classic does, from its exports of synthetic DNGs whose masks we wrote (issue #1815): +/// - **`MaskGroupBasedCorrections`, `crs:Version` ≥ 2** (what Lightroom writes today): an ordinary +/// radial — effect inside — is written `Flipped="true"`, so `Flipped` is the "inside" flag. +/// Inverting the mask is a separate field, `crs:MaskInverted` (see [`component`]). `Flipped="false"` +/// is read as outside, the same sense as the legacy filter's; Lightroom never wrote one for us. +/// - **`MaskGroupBasedCorrections` without `crs:Version`**: Lightroom renders the effect inside +/// whatever `Flipped` says, and rewrites both `Flipped="false"` and `"true"` as `Flipped="true"` +/// with `Version="2"`. So `Flipped` is ignored here. +/// - **`CircularGradientBasedCorrections`** (the process-2012 Radial Filter): its default effect was +/// outside, and "Invert Mask" — effect inside — was recorded as `Flipped="true"`. The same "inside" +/// flag as version 2, with outside as the default when it is missing. +fn radial_inside(m: &XmpValue, container: &str) -> bool { + let flipped = flag(m, "crs:Flipped"); + if container == "crs:MaskGroupBasedCorrections" { + if num(m, "crs:Version").is_some_and(|v| v >= 2.0) { flipped.unwrap_or(true) } else { true } + } else { + flipped.unwrap_or(false) + } +} + +/// A `Mask/CircularGradient` component's feather on our 0..100 scale. +/// +/// Lightroom halves `crs:Feather` when it upgrades a `MaskGroupBasedCorrections` radial without +/// `crs:Version` to version 2 (50 → 25, 100 → 50) and renders both the same, so the version-less +/// value is on twice the scale. Version 2's value is used as it is: its 25 and 50 put the middle of +/// Lightroom's fall-off at about 0.87 and 0.76 of the radius, ours at about 0.86 and 0.73. (The +/// shape differs: Lightroom's fall-off straddles the ellipse edge, ours ends at it.) The legacy +/// Radial Filter's value is used as it is: no observation says otherwise. +fn radial_feather(m: &XmpValue, container: &str) -> f64 { + let f = num(m, "crs:Feather").unwrap_or(50.0); + let versionless_group = container == "crs:MaskGroupBasedCorrections" && !num(m, "crs:Version").is_some_and(|v| v >= 2.0); + (if versionless_group { f / 2.0 } else { f }).clamp(0.0, 100.0) +} + +/// One mask component of a correction in `container` → our component JSON; `Err(kind)` names an +/// unsupported one. +fn component(m: &XmpValue, container: &str, aspect: f64) -> Result<Value, String> { let what = text(m, "crs:What").unwrap_or(""); let op = match num(m, "crs:MaskBlendMode").unwrap_or(0.0) as i64 { 1 => "subtract", @@ -123,8 +188,9 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { "rx": ((r - l).abs() / 2.0 * sx).max(1e-3), "ry": ((b - t).abs() / 2.0 * sy).max(1e-3), "angle": g("Angle", 0.0), - "feather": g("Feather", 50.0).clamp(0.0, 100.0), - "invert": flag(m, "crs:Flipped").unwrap_or(false), + "feather": radial_feather(m, container), + // our radial's `invert` = effect outside; `crs:MaskInverted` stays the component's + "invert": !radial_inside(m, container), }) } "Mask/Paint" => { @@ -163,8 +229,12 @@ fn component(m: &XmpValue, aspect: f64) -> Result<Value, String> { Some("2") => { let q: Vec<f64> = text(r, "crs:LumRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); let [a, b, c, d] = q[..] else { return Err("luminance range".into()) }; + // full strength at white (1, the top of their scale) takes in everything brighter: ours + // goes on above scene white, so that end stays open instead of cutting off at 0 EV + let open_top = c >= 1.0; let (a, b, c, d) = (lightness_to_range(a), lightness_to_range(b), lightness_to_range(c), lightness_to_range(d)); - json!({"kind": "luminanceRange", "lo": b, "hi": c, "lo_feather": (b - a).max(0.0), "hi_feather": (d - c).max(0.0)}) + let (hi, hi_feather) = if open_top { (1.0, 0.0) } else { (c, (d - c).max(0.0)) }; + json!({"kind": "luminanceRange", "lo": b, "hi": hi, "lo_feather": (b - a).max(0.0), "hi_feather": hi_feather}) } Some("3") => { let q: Vec<f64> = text(r, "crs:DepthRange").unwrap_or("0 0 1 1").split_whitespace().filter_map(|v| v.parse().ok()).collect(); @@ -193,7 +263,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) { let comps = c.field("crs:CorrectionMasks").map(XmpValue::items).unwrap_or_default(); let mut parts = Vec::new(); for m in comps { - match component(m, aspect) { + match component(m, container, aspect) { Ok(v) => parts.push(v), Err(kind) => skipped.push(kind), } @@ -201,6 +271,7 @@ pub fn masks(values: &Values, aspect: f64) -> (Vec<Value>, Vec<String>) { if parts.is_empty() { continue; } + skipped.extend(unmapped_local(c)); // brush dabs of one correction are one brush component let mut merged: Vec<Value> = Vec::new(); for p in parts { @@ -392,7 +463,7 @@ mod tests { </rdf:Description></rdf:li> <rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="0.5" crs:LocalContrast2012="0.2"> <crs:CorrectionMasks><rdf:Seq> - <rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true"/> + <rdf:li crs:What="Mask/CircularGradient" crs:Top="0.3" crs:Left="0.2" crs:Bottom="0.7" crs:Right="0.8" crs:Angle="10" crs:Feather="40" crs:Flipped="true" crs:Version="2"/> <rdf:li crs:What="Mask/RangeMask" crs:MaskBlendMode="2"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.4 0.5 0.8 0.9"/></rdf:li> <rdf:li crs:What="Mask/Image" crs:MaskSubType="9"/> </rdf:Seq></crs:CorrectionMasks> @@ -433,7 +504,8 @@ mod tests { let MaskShape::Radial { center, rx, ry, angle, feather, invert } = &rad.components[0].shape else { panic!() }; assert!((center.x - 0.5).abs() < 1e-9 && (center.y - 0.5).abs() < 1e-9); assert!((rx - 0.3).abs() < 1e-9 && (ry - 0.2 / 1.5).abs() < 1e-9, "radii in long-edge units: {rx} {ry}"); - assert_eq!((*angle, *feather, *invert), (10.0, 40.0, true)); + assert_eq!((*angle, *feather, *invert), (10.0, 40.0, false), "Flipped=\"true\" is Lightroom's ordinary radial: effect inside"); + assert!(!rad.components[0].invert); let MaskShape::LuminanceRange { lo, hi, lo_feather, .. } = &rad.components[1].shape else { panic!() }; assert!(lo < hi && *lo_feather > 0.0); let brush = &s.masks[2]; @@ -454,6 +526,28 @@ mod tests { assert_ne!(render(&DevelopSettings { light: s.light, ..Default::default() }), render(&s), "the masks change the picture"); } + #[test] + fn range_to_white_stays_open_and_unmapped_local_sliders_are_reported() { + // written for this test: a highlights range (full from 0.85 up to white) carrying a local slider we lack + let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/"> + <crs:MaskGroupBasedCorrections><rdf:Seq> + <rdf:li><rdf:Description crs:What="Correction" crs:LocalClarity2012="-0.3" crs:LocalGrain="0.4" crs:LocalCurveRefineSaturation="100"> + <crs:CorrectionMasks><rdf:Seq> + <rdf:li crs:What="Mask/RangeMask"><crs:CorrectionRangeMask crs:Type="2" crs:LumRange="0.6 0.85 1 1"/></rdf:li> + </rdf:Seq></crs:CorrectionMasks> + </rdf:Description></rdf:li> + </rdf:Seq></crs:MaskGroupBasedCorrections> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let d = lightcraft_meta::parse_xmp(x).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + let s = DevelopSettings::default().merged(&partial).expect("valid settings"); + let MaskShape::LuminanceRange { hi, hi_feather, .. } = &s.masks[0].components[0].shape else { panic!() }; + assert_eq!((*hi, *hi_feather), (1.0, 0.0), "brighter than scene white is still in the range"); + assert_eq!(s.masks[0].adjust.clarity, -30.0); + assert_eq!(unmapped, ["Mask: LocalGrain"], "a refine saturation of 100 is neutral, not reported"); + } + #[test] fn lrtemplate_corrections() { let t = r#"s = { title = "Grad", value = { settings = { Exposure2012 = 0.1, GradientBasedCorrections = { @@ -491,6 +585,114 @@ mod tests { assert!((s["ry"].as_f64().unwrap() - 0.3).abs() < 1e-9, "square photo: y radius = its box half-height"); } + /// One radial correction in `container`, with `attrs` on its mask component (values written + /// for this test; the component attributes follow what Lightroom Classic writes). + fn radial_packet(container: &str, attrs: &str) -> String { + format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0"> + <crs:{container}><rdf:Seq> + <rdf:li><rdf:Description crs:What="Correction" crs:CorrectionAmount="1" crs:LocalExposure2012="0.25"> + <crs:CorrectionMasks><rdf:Seq> + <rdf:li crs:What="Mask/CircularGradient" crs:MaskValue="1" crs:Top="0.25" crs:Left="0.25" crs:Bottom="0.75" crs:Right="0.75" crs:Angle="0" crs:Midpoint="50" crs:Roundness="0" {attrs}/> + </rdf:Seq></crs:CorrectionMasks> + </rdf:Description></rdf:li> + </rdf:Seq></crs:{container}> +</rdf:Description></rdf:RDF></x:xmpmeta>"# + ) + } + + fn radial_settings(container: &str, attrs: &str) -> DevelopSettings { + let d = lightcraft_meta::parse_xmp(&radial_packet(container, attrs)).unwrap(); + let (partial, unmapped) = crate::crs::to_partial_report(&d.properties, Some(&d.values), crate::crs::Target::Any, 1.5); + assert!(unmapped.is_empty(), "{unmapped:?}"); + let s = DevelopSettings::default().merged(&partial).unwrap(); + assert_eq!(s.masks.len(), 1, "{:?}", s.masks); + s + } + + /// (the radial's `invert`, its feather, the component's `invert`). + fn radial_of(s: &DevelopSettings) -> (bool, f64, bool) { + let c = &s.masks[0].components[0]; + let MaskShape::Radial { invert, feather, .. } = &c.shape else { panic!("{:?}", c.shape) }; + (*invert, *feather, c.invert) + } + + /// How much brighter the mask makes the centre and a corner of a flat grey frame (8-bit green). + fn lift_inside_outside(s: &DevelopSettings) -> (i32, i32) { + let img = lightcraft_raster::Image { width: 90, height: 60, data: vec![[0.18f32; 3]; 90 * 60] }; + let render = + |d: &DevelopSettings| lightcraft_pipeline::render(&img, &Default::default(), d, &lightcraft_pipeline::RenderRequest::fit(90, 60)).image; + let (with, without) = (render(s), render(&DevelopSettings { light: s.light, ..Default::default() })); + let g = |im: &lightcraft_raster::Rgba8, x: usize, y: usize| i32::from(im.data[y * im.width + x][1]); + (g(&with, 45, 30) - g(&without, 45, 30), g(&with, 3, 3) - g(&without, 3, 3)) + } + + #[test] + fn lightroom_radial_flipped_is_inside() { + // as Lightroom Classic writes an ordinary radial mask today (#1815) + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "the effect is inside the circle: {inside} {outside}"); + // no MaskInverted at all reads the same + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, false)); + } + + #[test] + fn lightroom_radial_mask_inverted_is_outside() { + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="true" crs:Feather="25" crs:Flipped="true" crs:Version="2""#); + assert_eq!(radial_of(&s), (false, 25.0, true), "the inversion is the component's"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "the effect is outside the circle: {inside} {outside}"); + // version 2 with Flipped="false": outside, the legacy sense of the flag + let s = radial_settings("MaskGroupBasedCorrections", r#"crs:MaskInverted="false" crs:Feather="25" crs:Flipped="false" crs:Version="2""#); + assert_eq!(radial_of(&s), (true, 25.0, false)); + } + + #[test] + fn versionless_group_radial_is_inside_with_half_the_feather() { + // Lightroom renders both inside and rewrites them Flipped="true", Feather halved, Version="2" + for flipped in ["true", "false"] { + let s = radial_settings("MaskGroupBasedCorrections", &format!(r#"crs:MaskInverted="false" crs:Feather="50" crs:Flipped="{flipped}""#)); + assert_eq!(radial_of(&s), (false, 25.0, false), "Flipped={flipped}"); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "Flipped={flipped}: {inside} {outside}"); + } + } + + #[test] + fn legacy_radial_filter_is_outside_unless_flipped() { + // the process-2012 Radial Filter: effect outside by default, "Invert Mask" = Flipped="true" + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="false""#); + assert_eq!(radial_of(&s), (true, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside == 0 && outside > 20, "default: outside: {inside} {outside}"); + assert_eq!(radial_of(&radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50""#)), (true, 50.0, false)); + let s = radial_settings("CircularGradientBasedCorrections", r#"crs:Feather="50" crs:Flipped="true""#); + assert_eq!(radial_of(&s), (false, 50.0, false)); + let (inside, outside) = lift_inside_outside(&s); + assert!(inside > 20 && outside == 0, "inverted: inside: {inside} {outside}"); + } + + #[test] + fn lrtemplate_radial_filter_reads_flipped() { + // .lrtemplate presets and catalog develop settings are the same Lua tables + let t = |flipped: &str| { + format!( + r#"s = {{ title = "Rad", value = {{ settings = {{ CircularGradientBasedCorrections = {{ + {{ What = "Correction", CorrectionAmount = 1, LocalExposure2012 = 0.25, CorrectionMasks = {{ + {{ What = "Mask/CircularGradient", Top = 0.25, Left = 0.25, Bottom = 0.75, Right = 0.75, Angle = 0, Feather = 50, Flipped = {flipped}, MaskValue = 1 }} }} }} }} }} }} }}"# + ) + }; + for (flipped, outside) in [("true", false), ("false", true)] { + let v = crate::preset_import::read_presets("Rad.lrtemplate", t(flipped).as_bytes(), None).unwrap(); + let s = DevelopSettings::default().merged(&v[0].preset.settings).unwrap(); + assert_eq!(radial_of(&s).0, outside, "Flipped = {flipped}"); + } + } + /// Spot removal in the shape Lightroom Classic writes it (values written for this test): /// a healed circle with an explicit source, a cloned circle with an automatic source, a /// brushed removal, a circular-gradient spot, one without a mask, and the legacy list. diff --git a/crates/engine/src/demo.rs b/crates/engine/src/demo.rs index 6a89dda88..431eec763 100644 --- a/crates/engine/src/demo.rs +++ b/crates/engine/src/demo.rs @@ -30,6 +30,16 @@ pub fn load(s: &mut Session) { creator: "LightCraft Demo".into(), ..Default::default() }; + // one demo photo carries a camera focus point (as a Sony raw would), on the bloom's centre, so + // View ▸ Show Focus Point and Zoom to Focus Point can be tried without a camera file + if sc.name == "Cosmos bloom" { + p.meta.focus = Some(lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.42, y0: 0.433, x1: 0.50, y1: 0.487 }, + mode: Some("AF-S".into()), + area: Some("Flexible Spot".into()), + tracking: None, + }); + } p.rating = [0, 3, 4, 5, 2, 0, 4, 3][sc.id as usize % 8]; p.flag = match sc.id % 6 { 1 => Flag::Pick, @@ -409,7 +419,7 @@ mod tests { #[test] fn a_scene_is_rendered_once_however_many_ask() { use std::sync::atomic::{AtomicUsize, Ordering}; - let (renders, peak) = within(60, || { + let (renders, peak) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (running, peak, renders) = (Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0)), Arc::new(AtomicUsize::new(0))); let render = { @@ -454,7 +464,7 @@ mod tests { /// and that pool's limits; other threads render in parallel on the scene pool. #[test] fn a_rayon_worker_renders_alone_on_its_own_thread() { - let ((how, caller, renderer), (other_how, other_name)) = within(30, || { + let ((how, caller, renderer), (other_how, other_name)) = within(120, || { let pool = rayon::ThreadPoolBuilder::new().num_threads(2).build().unwrap(); let slot = SceneSlot::default(); let mut seen = None; @@ -485,7 +495,7 @@ mod tests { /// minutes of CPU time on a small machine). #[test] fn callers_wait_for_the_generation_running() { - let (a, b, renders) = within(30, || { + let (a, b, renders) = within(120, || { let slot = Arc::new(SceneSlot::default()); let renders = Arc::new(std::sync::atomic::AtomicUsize::new(0)); let (started_tx, started) = std::sync::mpsc::channel::<()>(); @@ -518,7 +528,7 @@ mod tests { /// generates the scene again. #[test] fn a_failed_generation_is_an_error_and_the_next_request_tries_again() { - let (first, waiters, again) = within(30, || { + let (first, waiters, again) = within(120, || { let slot = Arc::new(SceneSlot::default()); let (started_tx, started) = std::sync::mpsc::channel::<()>(); let (go, go_rx) = std::sync::mpsc::channel::<()>(); @@ -564,7 +574,7 @@ mod tests { assert!(matches!(st.outcome(n), Some(Err(_))), "even once the retry succeeded"); assert!(matches!(st.outcome(m), Some(Ok(_)))); // and with threads: a retry racing the waiters of a failed generation never hands them its result - let wrong = within(60, || { + let wrong = within(120, || { let mut wrong = 0; for _ in 0..20 { let slot = Arc::new(SceneSlot::default()); diff --git a/crates/engine/src/devices.rs b/crates/engine/src/devices.rs index 399d03fde..366cb5273 100644 --- a/crates/engine/src/devices.rs +++ b/crates/engine/src/devices.rs @@ -104,6 +104,18 @@ impl DeviceWatch { s.list.clone() } + /// Like [`DeviceWatch::get`], but `None` until the first scan has landed, so an empty list + /// means no cards rather than not looked yet. + pub fn get_scanned(self: &Arc<Self>) -> Option<Vec<Device>> { + let _ = self.get(); + let s = self.state(); + #[cfg(not(target_arch = "wasm32"))] + let scanned = s.scanned.is_some(); + #[cfg(target_arch = "wasm32")] + let scanned = false; + scanned.then(|| s.list.clone()) + } + /// Scan now on this thread, and serve the result to later [`DeviceWatch::get`] calls. pub fn scan_now(&self) -> Vec<Device> { let list = (self.scanner)(); @@ -148,6 +160,12 @@ pub fn devices() -> Vec<Device> { global().get() } +/// [`devices`], but `None` until the first scan lands. The import prompt uses it so a card that was +/// already in when LightCraft started isn't taken for a new one. +pub fn devices_scanned() -> Option<Vec<Device>> { + global().get_scanned() +} + /// Scan the mounted volumes now (blocking), for explicit requests. pub fn devices_now() -> Vec<Device> { global().scan_now() @@ -285,4 +303,24 @@ mod tests { assert!(watch.scan_now().is_empty()); assert_eq!(changes.load(Ordering::SeqCst), 2); } + + #[test] + fn get_scanned_waits_for_the_first_scan() { + let gate = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let g = gate.clone(); + let watch = DeviceWatch::new( + move || { + while !g.load(Ordering::SeqCst) { + std::thread::sleep(Duration::from_millis(5)); + } + vec![card("NIKON Z 8")] + }, + Duration::from_secs(60), + ); + assert_eq!(watch.get_scanned(), None, "nothing scanned yet"); + assert_eq!(watch.get_scanned(), None); + gate.store(true, Ordering::SeqCst); + assert!(until(|| watch.get_scanned().is_some())); + assert_eq!(watch.get_scanned(), Some(vec![card("NIKON Z 8")])); + } } diff --git a/crates/engine/src/doc_path.rs b/crates/engine/src/doc_path.rs new file mode 100644 index 000000000..c2b0e9f69 --- /dev/null +++ b/crates/engine/src/doc_path.rs @@ -0,0 +1,63 @@ +//! Paths stored in a file LightCraft reads but the user did not choose path by path, such as the +//! originals a Lightroom catalog lists. +//! +//! Such a path reaches the file system only through [`resolve`]: a relative path is taken from the +//! document's folder, and only local files, or files on the network share the document itself is +//! on, are looked at (see `craft_docpath`). A path that is not followed is handled like a missing +//! file. + +use std::fmt; +use std::path::{Path, PathBuf}; + +pub use craft_docpath::Refused; + +/// Why a stored path is not followed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NotFollowed { + /// The path is on another computer (other than the document's share), names a device, is a + /// URL, or is not a usable path. + Refused(Refused), + /// The path is not an absolute path on this computer, such as a Windows drive path read on + /// macOS or Linux. + NotHere, +} + +impl fmt::Display for NotFollowed { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Refused(r) => r.fmt(f), + Self::NotHere => f.write_str("the path is not a path on this computer"), + } + } +} + +impl std::error::Error for NotFollowed {} + +/// The file to look at for `stored`, a path or `file:` URL stored in the document at `document`, +/// or why it is not followed. A relative `document` is taken from the working directory, where it +/// was opened from. +pub fn resolve(stored: &str, document: Option<&Path>) -> Result<PathBuf, NotFollowed> { + let document = document.map(|d| if d.is_relative() { std::path::absolute(d).unwrap_or_else(|_| d.to_path_buf()) } else { d.to_path_buf() }); + let path = craft_docpath::resolve(stored, document.as_deref()).map_err(NotFollowed::Refused)?; + // A path that is still relative would be taken from the working directory, which the + // document does not choose. + if path.is_relative() { + return Err(NotFollowed::NotHere); + } + Ok(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn relative_paths_are_taken_from_the_document_folder_and_other_computers_are_refused() { + let doc = std::env::temp_dir().join("lrcat").join("catalog.lrcat"); + assert_eq!(resolve("Photos/a.jpg", Some(&doc)), Ok(std::env::temp_dir().join("lrcat").join("Photos").join("a.jpg"))); + assert_eq!(resolve("a.jpg", None), Err(NotFollowed::NotHere)); + assert_eq!(resolve(r"\\192.0.2.1\share\x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve("file://192.0.2.1/s/x.jpg", Some(&doc)), Err(NotFollowed::Refused(Refused::Network))); + assert_eq!(resolve(r"\\.\COM1", Some(&doc)), Err(NotFollowed::Refused(Refused::Device))); + } +} diff --git a/crates/engine/src/export.rs b/crates/engine/src/export.rs index 403cb71f6..e9d1cbc58 100644 --- a/crates/engine/src/export.rs +++ b/crates/engine/src/export.rs @@ -162,6 +162,40 @@ pub enum Conflict { Skip, } +/// Where a batch's files go (Lightroom Classic's Export To). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum DestinationMode { + /// One folder for the whole batch ([`Destination::dir`]). + #[default] + SpecificFolder, + /// Each photo's own folder: the folder its original is in. A photo without one on disk (a + /// demo photo, a photo in the browser's storage) or whose folder is gone gets an error. + SameAsOriginal, +} + +impl DestinationMode { + /// `specificFolder` / `sameAsOriginal` (any case). + pub fn parse(s: &str) -> Option<Self> { + match s.trim().to_ascii_lowercase().as_str() { + "specificfolder" => Some(Self::SpecificFolder), + "sameasoriginal" => Some(Self::SameAsOriginal), + _ => None, + } + } +} + +impl Conflict { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "unique" => Some(Self::Unique), + "overwrite" => Some(Self::Overwrite), + "skip" => Some(Self::Skip), + _ => None, + } + } +} + /// A named set of `app.export` params. #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ExportPreset { @@ -191,6 +225,18 @@ pub enum SharpenFor { Glossy, } +impl SharpenFor { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "none" => Some(Self::None), + "screen" => Some(Self::Screen), + "matte" => Some(Self::Matte), + "glossy" => Some(Self::Glossy), + _ => None, + } + } +} + #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum SharpenAmount { @@ -200,6 +246,17 @@ pub enum SharpenAmount { High, } +impl SharpenAmount { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "low" => Some(Self::Low), + "standard" => Some(Self::Standard), + "high" => Some(Self::High), + _ => None, + } + } +} + /// Where a watermark sits. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -293,58 +350,8 @@ fn turn(turns: u8, x: f32, y: f32, w: f32, h: f32) -> (f32, f32) { } } -/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). -static WATERMARK_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); - -/// The watermark faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's -/// serif look; then any other CJK face — a watermark has no UI language, so every CJK script is -/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's -/// missing-glyph box. -fn watermark_fonts(craft: &'static [crate::fonts::CraftFont]) -> Vec<ab_glyph::FontRef<'static>> { - let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); - cjk.sort_by_key(|f| !f.is_mincho()); - std::iter::once(WATERMARK_FONT) - .chain(cjk.into_iter().map(|f| f.bytes)) - .filter_map(|bytes| ab_glyph::FontRef::try_from_slice(bytes).ok()) - .collect() -} - -/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. -/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. -fn watermark_cell_glyphs( - font: &ab_glyph::FontRef<'_>, - data: &harfrust::ShaperData, - text: &str, - vertical: bool, - px: f32, - left: f32, - top: f32, -) -> Option<Vec<ab_glyph::Glyph>> { - use ab_glyph::{Font, ScaleFont}; - let face = harfrust::FontRef::new(font.font_data()).ok()?; - let mut buf = harfrust::UnicodeBuffer::new(); - buf.push_str(text); - buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); - buf.guess_segment_properties(); - let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); - if shaped.glyph_infos().is_empty() { - return None; - } - let sf = font.as_scaled(px); - let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); - let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>(); - let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>(); - let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; - let mut glyphs = Vec::new(); - for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { - let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; - let pen = ab_glyph::point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); - glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); - x += pos.x_advance as f32 * sx; - y -= pos.y_advance as f32 * sy; - } - Some(glyphs) -} +#[cfg(test)] +use crate::text::{TEXT_FONT as WATERMARK_FONT, cell_glyphs as watermark_cell_glyphs, faces as watermark_fonts}; /// Draw `wm` onto `img` (straight alpha blending of the encoded values). pub fn draw_watermark(img: &mut Rgba8, wm: &Watermark) { @@ -369,6 +376,23 @@ fn blend_watermark(img: &mut Rgba8, wm: &Watermark, floor_scale: f32) { }); } +/// Draw `wm` into the `w × h` area of `img` whose top-left corner is (`x0`, `y0`), sized and +/// anchored as on an image of that size (a slideshow slide's photo). The area is clipped to `img`. +pub fn draw_watermark_in(img: &mut Rgba8, wm: &Watermark, x0: usize, y0: usize, w: usize, h: usize) { + let (width, height) = (img.width, img.height); + watermark_coverage(w, h, wm, crate::fonts::CRAFT_FONTS, 1.0, |x, y, k, col| { + let (px, py) = (x0.saturating_add(x), y0.saturating_add(y)); + if px >= width || py >= height { + return; + } + if let Some(p) = img.data.get_mut(py * width + px) { + for c in 0..3 { + p[c] = (p[c] as f32 + (col[c] as f32 - p[c] as f32) * k).round() as u8; + } + } + }); +} + /// Draw `wm` onto a high-bit-depth image (`wm.color` is in the image's space, 8-bit encoded; float /// images are linear and blend in linear light). pub fn draw_watermark_deep(img: &mut DeepImage, wm: &Watermark) { @@ -403,7 +427,6 @@ fn watermark_coverage( floor_scale: f32, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3]), ) { - use ab_glyph::{Font, PxScale, ScaleFont, point}; if !wm.image.trim().is_empty() { logo_coverage(width, height, wm, blend_px); return; @@ -412,97 +435,10 @@ fn watermark_coverage( if text.is_empty() || width == 0 || height == 0 { return; } - let fonts = watermark_fonts(craft); - let Some(latin) = fonts.first() else { return }; - let vertical_data: Vec<_> = if wm.vertical { - fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() - } else { - Vec::new() - }; let short = width.min(height) as f32; let px = (wm.size.clamp(0.005, 0.5) * short).max(6.0 * floor_scale); - let mut glyphs = Vec::new(); - let mut x = 0.0f32; - let mut y = 0.0f32; - let mut tw = 0.0f32; - let mut prev = None; - let columns = text.split('\n').count(); - let mut column = 0usize; - use unicode_segmentation::UnicodeSegmentation; - // Horizontal watermarks retain scalar layout and kerning; only vertical cells use graphemes. - let cells: Box<dyn Iterator<Item = &str>> = - if wm.vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; - for cell in cells { - let Some(original) = cell.chars().next() else { continue }; - if cell == "\n" || (wm.vertical && cell == "\r\n") { - if wm.vertical { - column = column.saturating_add(1); - tw = tw.max(y); - y = 0.0; - } else { - tw = tw.max(x); - x = 0.0; - y += px; - } - prev = None; - continue; - } - let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); - if wm.vertical && (cjk || cell.chars().count() > 1) { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { - let data = data.as_ref()?; - let cluster = watermark_cell_glyphs(font, data, cell, cjk, px, left, y)?; - let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); - if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') { - return None; - } - Some((face, cluster)) - }); - if let Some((face, cluster)) = shaped { - glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); - y += px; - continue; - } - } - let vertical_form = match original { - '、' => '︑', - '。' => '︒', - '(' => '︵', - ')' => '︶', - '「' => '﹁', - '」' => '﹂', - '『' => '﹃', - '』' => '﹄', - '【' => '︻', - '】' => '︼', - '…' => '︙', - other => other, - }; - let has = |font: &ab_glyph::FontRef<'_>, c: char| font.glyph_id(c).0 != 0; - let ch = if wm.vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; - // The first face with the glyph; Inter (its missing-glyph box) when none has it. - let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); - let sf = fonts.get(face).unwrap_or(latin).as_scaled(PxScale::from(px)); - let id = sf.glyph_id(ch); - if wm.vertical { - let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; - let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); - let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); - glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); - y += px; - } else { - if let Some((previous_face, previous)) = prev - && previous_face == face - { - x += sf.kern(previous, id); - } - glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); - x += sf.h_advance(id); - prev = Some((face, id)); - } - } - let (tw, th) = if wm.vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + let Some(layout) = crate::text::TextLayout::new(text, px, wm.vertical, craft) else { return }; + let (tw, th) = (layout.width, layout.height); // laid out unturned in a tw × th box, placed by the box it becomes let turns = wm.quarter_turns(); let (bw, bh) = if turns % 2 == 1 { (th, tw) } else { (tw, th) }; @@ -520,35 +456,29 @@ fn watermark_coverage( BottomLeft | Bottom | BottomRight => h - iy - bh, }; let alpha = wm.opacity.clamp(0.0, 1.0); - let mut blend = |gx: i32, gy: i32, cov: f32, col: [u8; 3], a: f32| { - if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { - return; - } - blend_px(gx as usize, gy as usize, (cov * a).clamp(0.0, 1.0), col); - }; let passes: &[(f32, [u8; 3], f32)] = if wm.shadow { &[((px * 0.05).max(floor_scale), [0, 0, 0], 0.45), (0.0, wm.color, 1.0)] } else { &[(0.0, wm.color, 1.0)] }; for &(off, col, a) in passes { - for (face, g, rotation) in &glyphs { - if let Some(o) = fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { - let b = o.px_bounds(); - o.draw(|gx, gy, cov| { - let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); - let (sx, sy) = match rotation { - Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), - None => (sx, sy), - }; - // turned with the box; the shadow still falls down and to the right - let (sx, sy) = turn(turns, sx, sy, tw, th); - blend((ox + off + sx) as i32, (oy + off + sy) as i32, cov, col, a * alpha); - }); + let mut put = |gx: i32, gy: i32, cov: f32| { + if gx < 0 || gy < 0 || gx >= width as i32 || gy >= height as i32 { + return; } + blend_px(gx as usize, gy as usize, (cov * a * alpha).clamp(0.0, 1.0), col); + }; + if turns == 0 { + layout.draw_in(ox + off, oy + off, w, h, &mut put); + } else { + // turned with the box; the shadow still falls down and to the right + layout.draw_points(|x, y, cov| { + let (x, y) = turn(turns, x, y, tw, th); + put((ox + off + x) as i32, (oy + off + y) as i32, cov); + }); } } } /// A watermark graphic, decoded once per file (and again when the file changes). -fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> { +pub(crate) fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> { use std::collections::HashMap; use std::sync::{Arc, Mutex}; static CACHE: Mutex<Option<HashMap<String, (std::time::SystemTime, Arc<Rgba8>)>>> = Mutex::new(None); @@ -560,12 +490,19 @@ fn watermark_logo(path: &str) -> Option<std::sync::Arc<Rgba8>> { { return Some(img.clone()); } - let bytes = std::fs::read(path).ok()?; - let img = Arc::new(lightcraft_codecs::decode(&bytes, Default::default()).ok()?.to_srgb8()); + let img = Arc::new(read_graphic(path).ok()?); map.insert(path.to_string(), (modified, img.clone())); Some(img) } +/// A graphic file (a PNG with transparency, typically) as 8-bit sRGB with its alpha: the +/// watermark logo, the loupe's layout image. The error says why it couldn't be read or decoded. +pub fn read_graphic(path: &str) -> Result<Rgba8, String> { + let bytes = std::fs::read(path).map_err(|e| format!("can't read {path}: {e}"))?; + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).map_err(|e| format!("can't decode {path}: {e}"))?; + Ok(decoded.to_srgb8()) +} + /// The graphic watermark: scaled (premultiplied, so transparent edges stay clean) to /// `image_width` of the photo, placed like the text, blended with its alpha × opacity. fn logo_coverage(width: usize, height: usize, wm: &Watermark, mut blend_px: impl FnMut(usize, usize, f32, [u8; 3])) { @@ -634,6 +571,18 @@ pub enum MetadataPolicy { None, } +impl MetadataPolicy { + pub fn parse(s: &str) -> Option<Self> { + match s.to_ascii_lowercase().as_str() { + "all" => Some(Self::All), + "allexceptcamera" => Some(Self::AllExceptCamera), + "copyright" => Some(Self::Copyright), + "none" => Some(Self::None), + _ => None, + } + } +} + /// Transfer curve of an HDR AVIF export (`hdrTransfer`): PQ (absolute, SDR white at 203 cd/m²) or /// HLG (relative to the display; BT.2100 with SDR white at signal 0.75). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -682,6 +631,8 @@ pub struct ExportOptions { pub naming: String, /// First `{seq}` value. pub start_number: u32, + /// A specific folder for the whole batch, or each photo's own folder. + pub destination_mode: DestinationMode, /// Created inside the destination folder (empty = none). pub subfolder: String, /// When a file of the same name exists in the destination folder. @@ -734,6 +685,7 @@ impl Default for ExportOptions { sharpen_amount: SharpenAmount::Standard, naming: "{name}".into(), start_number: 1, + destination_mode: DestinationMode::SpecificFolder, subfolder: String::new(), conflict: Conflict::Unique, tiff_compression: TiffCompression::Deflate, @@ -776,6 +728,7 @@ pub const OPTION_PARAMS: &[&str] = &[ "sharpenAmount", "naming", "startNumber", + "destinationMode", "subfolder", "conflict", "tiffCompression", @@ -907,6 +860,10 @@ impl ExportOptions { let s = string(cmd, k, v)?; if options.iter().any(|o| o.eq_ignore_ascii_case(s)) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } }; + // a choice `from_json` reads with the same `parse`, so what passes here is what gets exported + let parses = |k: &str, v: &Value, parse: fn(&str) -> bool, options: &[&str]| -> crate::Result<()> { + if parse(string(cmd, k, v)?) { Ok(()) } else { Err(bad(format!("`{k}` must be one of {}", options.join("|")))) } + }; for (k, v) in obj { if v.is_null() { continue; @@ -962,17 +919,18 @@ impl ExportOptions { "naming" | "subfolder" | "path" | "dir" | "preset" => { string(cmd, k, v)?; } - "sharpen" => one_of(k, v, &["none", "screen", "matte", "glossy"])?, - "sharpenAmount" => one_of(k, v, &["low", "standard", "high"])?, + "sharpen" => parses(k, v, |s| SharpenFor::parse(s).is_some(), &["none", "screen", "matte", "glossy"])?, + "sharpenAmount" => parses(k, v, |s| SharpenAmount::parse(s).is_some(), &["low", "standard", "high"])?, "hdrTransfer" => one_of(k, v, &["pq", "hlg"])?, - "conflict" => one_of(k, v, &["unique", "overwrite", "skip"])?, + "conflict" => parses(k, v, |s| Conflict::parse(s).is_some(), &["unique", "overwrite", "skip"])?, + "destinationMode" => one_of(k, v, &["specificFolder", "sameAsOriginal"])?, "tiffCompression" => one_of(k, v, &["none", "lzw", "zip", "deflate"])?, "jxlEffort" => one_of(k, v, &["fast", "normal"])?, "dngCompression" => { serde_json::from_value::<DngCompression>(v.clone()) - .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed")))?; + .map_err(|_| bad(format!("`{k}` must be one of lossless|deflate|uncompressed|lossy")))?; } - "metadata" => one_of(k, v, &["all", "allExceptCamera", "copyright", "none"])?, + "metadata" => parses(k, v, |s| MetadataPolicy::parse(s).is_some(), &["all", "allExceptCamera", "copyright", "none"])?, "colorSpace" => { if OutputSpace::parse(string(cmd, k, v)?).is_none() { return Err(bad(format!("`{k}` must be one of srgb|displayP3|adobeRgb|proPhoto|rec2020"))); @@ -995,7 +953,7 @@ impl ExportOptions { } /// A `watermark` value: text, or an object of [`WATERMARK_PARAMS`] (issue #183). - fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { + pub(crate) fn validate_watermark(cmd: &str, v: &serde_json::Value) -> crate::Result<()> { use serde_json::Value; let bad = |msg: String| crate::cmd::bad(cmd, msg); let obj = match v { @@ -1074,12 +1032,13 @@ impl ExportOptions { resize: Self::resize_from_json(p), ppi: u("ppi").filter(|v| *v > 0).map_or(d.ppi, |v| v.min(u16::MAX as u64) as u16), limit_kb: u("limitKb").filter(|v| *v > 0).map(|v| v.min(u32::MAX as u64) as u32), - sharpen: enm(p, "sharpen").unwrap_or(d.sharpen), - sharpen_amount: enm(p, "sharpenAmount").unwrap_or(d.sharpen_amount), + sharpen: s("sharpen").and_then(SharpenFor::parse).unwrap_or(d.sharpen), + sharpen_amount: s("sharpenAmount").and_then(SharpenAmount::parse).unwrap_or(d.sharpen_amount), naming: s("naming").map_or(d.naming, str::to_string), start_number: u("startNumber").map_or(d.start_number, |v| v.min(999_999) as u32), + destination_mode: s("destinationMode").and_then(DestinationMode::parse).unwrap_or(d.destination_mode), subfolder: s("subfolder").map_or(d.subfolder, |v| v.trim().replace(['\\', ':', '\0'], "_")), - conflict: enm(p, "conflict").unwrap_or(d.conflict), + conflict: s("conflict").and_then(Conflict::parse).unwrap_or(d.conflict), tiff_compression: match s("tiffCompression").map(str::to_ascii_lowercase).as_deref() { Some("none") => TiffCompression::None, Some("lzw") => TiffCompression::Lzw, @@ -1087,7 +1046,7 @@ impl ExportOptions { _ => d.tiff_compression, }, dng_compression: enm(p, "dngCompression").unwrap_or(d.dng_compression), - metadata: enm(p, "metadata").unwrap_or(d.metadata), + metadata: s("metadata").and_then(MetadataPolicy::parse).unwrap_or(d.metadata), remove_location: p.get("removeLocation").and_then(Value::as_bool).unwrap_or(d.remove_location), watermark: match p.get("watermark") { Some(Value::String(t)) => Some(Watermark { text: t.clone(), ..Default::default() }), @@ -1351,7 +1310,15 @@ pub fn encode_with_metadata(img: &Rgba8, o: &ExportOptions, meta: Option<&Metada let exif = meta.map(lightcraft_meta::write_exif); let xmp = meta.map(|m| lightcraft_meta::write_xmp(m, None)); let meta = EncodeMeta { icc: Some(&profile), exif: exif.as_deref(), xmp: xmp.as_deref(), ppi: Some(o.ppi) }; - let e = EncodeImage::rgba8(&img); + // Opaque photo exports need no alpha channel (#1670). Keep real transparency for + // callers of this encoder, and leave unrelated formats on their existing path. + let rgb: Option<Vec<u8>> = (matches!(o.format, ExportFormat::Png | ExportFormat::Tiff | ExportFormat::Webp) + && img.data.iter().all(|p| p.last() == Some(&255))) + .then(|| img.data.iter().flat_map(|p| p.iter().take(3).copied()).collect()); + let e = match &rgb { + Some(data) => EncodeImage::new(img.width as u32, img.height as u32, 3, Samples::U8(data)), + None => EncodeImage::rgba8(&img), + }; let r = match o.format { ExportFormat::Jpeg => { // 4:4:4 normally; 4:2:0 when targeting a file size (much smaller at equal visual quality). @@ -1608,6 +1575,9 @@ pub fn export_photo(session: &mut crate::Session, id: lightcraft_catalog::PhotoI pub struct PreparedExport { pub photo: lightcraft_catalog::PhotoId, pub file_name: String, + /// The folder of the photo's original on disk ([`DestinationMode::SameAsOriginal`]); `None` + /// for a demo photo or one in the browser's storage. + pub source_dir: Option<String>, work: Work, /// The library's originals, which [`run_batch`] never writes over (shared by a batch). guard: std::sync::Arc<crate::originals::OriginalGuard>, @@ -1633,6 +1603,9 @@ enum Work { read: Option<crate::merge::ByteReader>, packet: String, dng: Option<DngCompression>, + /// Lossy DNG: JPEG quality and the long edge to scale to (`None`: full size). + quality: u8, + lossy_edge: Option<u32>, /// The original must not carry a location ([`removes_location`]). remove_location: bool, label: String, @@ -1667,6 +1640,7 @@ fn prepare_guarded( ) -> Result<PreparedExport, String> { let p = session.catalog.photo(id).ok_or("no such photo")?; let file_name = o.file_name_for(p, seq); + let source_dir = original_folder(&p.source); // HDR output only for photos edited in HDR; the rest of the batch exports as usual let sdr_opts; let o = if o.hdr && !p.develop.hdr.enabled { @@ -1702,13 +1676,29 @@ fn prepare_guarded( read: session.media.file_bytes.clone(), packet: crate::sidecar::sidecar_packet_with(p, &session.catalog, removes_location(p, &session.catalog, o)), dng, + quality: o.quality, + lossy_edge: o.resize.is_some().then(|| { + let (w, h) = output_size(p, o); + u32::try_from(w.max(h)).unwrap_or(u32::MAX) + }), remove_location: removes_location(p, &session.catalog, o), label: p.file_name.clone(), size: (p.width as usize, p.height as usize), }; (work, weight) }; - Ok(PreparedExport { photo: id, file_name, work, guard, weight }) + Ok(PreparedExport { photo: id, file_name, source_dir, work, guard, weight }) +} + +/// The folder a photo's original is in, for [`DestinationMode::SameAsOriginal`]. None for a demo +/// photo, and on the web, where an original lives in the browser's storage, not in a folder. +fn original_folder(source: &lightcraft_catalog::Source) -> Option<String> { + let lightcraft_catalog::Source::File { path } = source else { return None }; + if cfg!(target_arch = "wasm32") { + return None; + } + let dir = std::path::Path::new(path).parent()?.to_string_lossy().to_string(); + (!dir.is_empty()).then_some(dir) } /// Why an export of `out` pixels (`w`, `h`), from a photo of `native_px` pixels at its own size, @@ -1761,7 +1751,7 @@ impl PreparedExport { }; Ok(Exported { file_name, bytes, width: r.image.width, height: r.image.height, sidecars: Vec::new(), gpu_fallback }) } - Work::File { path, read, packet, dng, label, size, remove_location } => { + Work::File { path, read, packet, dng, quality, lossy_edge, label, size, remove_location } => { let bytes = match &read { Some(r) => r(&path)?, None => std::fs::read(&path).map_err(|e| format!("{path}: {e}"))?, @@ -1785,12 +1775,16 @@ impl PreparedExport { if lightcraft_raw::probe(&bytes).is_none() { return Err(format!("{label}: DNG export needs a raw photo")); } - let raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + let mut raw = lightcraft_raw::decode(&bytes).map_err(|e| format!("{label}: {e}"))?; + crate::camera_preview::with_starting_colour(&mut raw, &bytes); drop(bytes); let compression = match dng { DngCompression::Lossless => lightcraft_raw::DngCompression::Lj92 { tile: 256 }, DngCompression::Deflate => lightcraft_raw::DngCompression::Deflate { tile: 256, half: false }, DngCompression::Uncompressed => lightcraft_raw::DngCompression::Uncompressed, + DngCompression::Lossy => { + lightcraft_raw::DngCompression::LossyJpeg { tile: 256, quality: quality.clamp(1, 100), long_edge: lossy_edge } + } }; let dng = lightcraft_raw::write_dng(&raw, &lightcraft_raw::DngWriteOptions { xmp: Some(packet), compression, ..Default::default() }) .map_err(|e| e.to_string())?; @@ -1810,11 +1804,15 @@ pub enum DngCompression { /// Deflate (zip) tiles with prediction. Deflate, Uncompressed, + /// Lossy DNG: demosaiced linear data as JPEG tiles at the export quality, scaled to the resize + /// setting when there is one (Lightroom's "Use Lossy Compression"). + Lossy, } /// The destination of a batch: a folder (with `ExportOptions::subfolder` and the conflict policy -/// applied to each file together with its sidecars), or one exact file path (single photo; an -/// ordinary file already there is replaced). Either way a catalogued original (or its sidecar) +/// applied to each file together with its sidecars; with [`DestinationMode::SameAsOriginal`] each +/// photo's own folder instead of `dir`), or one exact file path (single photo; an ordinary file +/// already there is replaced). Either way a catalogued original (or its sidecar) /// is never written over: see [`crate::originals`]. #[derive(Clone, Debug, Default)] pub struct Destination { @@ -2011,12 +2009,12 @@ fn run_batch_with( if !progress(i, &item.file_name) { break; } - let (photo, name, guard) = (item.photo, item.file_name.clone(), item.guard.clone()); - placer.place(photo, name, &guard, run_gated(item))?; + let (photo, name, guard, source_dir) = (item.photo, item.file_name.clone(), item.guard.clone(), item.source_dir.clone()); + placer.place(photo, name, source_dir.as_deref(), &guard, run_gated(item))?; } return Ok(std::mem::take(&mut placer.out)); } - let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone())).collect(); + let heads: Vec<_> = items.iter().map(|i| (i.photo, i.file_name.clone(), i.guard.clone(), i.source_dir.clone())).collect(); let queue = Queue { lane: std::sync::Mutex::new(Lane { items: items.into_iter().map(Some).collect(), next: 0, placed: 0, stop: false, done: Default::default() }), cv: std::sync::Condvar::new(), @@ -2031,12 +2029,12 @@ fn run_batch_with( } } let placed = || { - for (i, (photo, name, guard)) in heads.into_iter().enumerate() { + for (i, (photo, name, guard, source_dir)) in heads.into_iter().enumerate() { if !progress(i, &name) { break; } let e = queue.result(i); - placer.place(photo, name, &guard, e)?; + placer.place(photo, name, source_dir.as_deref(), &guard, e)?; } Ok::<_, String>(()) }; @@ -2147,12 +2145,11 @@ impl Queue { } } -/// Where a batch's files go: each photo's path (subfolder, conflict policy, sidecars, never an -/// original) and its write, in batch order. +/// Where a batch's files go: each photo's path (destination folder, subfolder, conflict policy, +/// sidecars, never an original) and its write, in batch order. struct Placer<'a> { o: &'a ExportOptions, to: &'a Destination, - dir: String, single: bool, taken: std::collections::HashSet<String>, out: Vec<serde_json::Value>, @@ -2173,29 +2170,53 @@ impl<'a> Placer<'a> { exists: &'a dyn Fn(&str) -> bool, stop_on_error: bool, ) -> Placer<'a> { - let dir = if o.subfolder.is_empty() { to.dir.clone() } else { join_path(&to.dir, &o.subfolder) }; - Placer { o, to, dir, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } + Placer { o, to, single: false, taken: Default::default(), out: Vec::new(), write, exists, stop_on_error } } - /// Place photo `photo`'s export result (file `name`): write it and record the outcome. `Err` - /// only when it failed and `stop_on_error` is set. + /// The folder photo `name` (its original in `source_dir`) is written to: the batch's folder or + /// the photo's own ([`DestinationMode`]), plus the subfolder. An error for a photo with no + /// folder of its own, or whose folder is gone: never a file written somewhere else instead. + fn folder_for(&self, name: &str, source_dir: Option<&str>) -> Result<String, String> { + let base = match self.o.destination_mode { + DestinationMode::SpecificFolder => self.to.dir.clone(), + DestinationMode::SameAsOriginal => { + let Some(d) = source_dir else { + return Err(format!( + "{name}: the photo has no folder on disk (a demo photo or one in the browser's storage), so it can't be exported to the same folder as its original; choose a specific folder" + )); + }; + if !(self.exists)(d) { + return Err(format!("{name}: the original's folder is missing ({d}); reconnect it or choose a specific folder")); + } + d.to_string() + } + }; + Ok(if self.o.subfolder.is_empty() { base } else { join_path(&base, &self.o.subfolder) }) + } + + /// Place photo `photo`'s export result (file `name`, its original in `source_dir`): write it + /// and record the outcome. `Err` only when it failed and `stop_on_error` is set. fn place( &mut self, photo: lightcraft_catalog::PhotoId, name: String, + source_dir: Option<&str>, guard: &crate::originals::OriginalGuard, e: Result<Exported, String>, ) -> Result<(), String> { use serde_json::json; - let (o, dir, exists) = (self.o, &self.dir, self.exists); - let e = match e { - Ok(e) => e, + // an exact file path needs no folder + let dir = if self.to.exact.is_some() && self.single { Ok(String::new()) } else { self.folder_for(&name, source_dir) }; + let (o, exists) = (self.o, self.exists); + let (dir, e) = match dir.and_then(|dir| e.map(|e| (dir, e))) { + Ok(v) => v, Err(err) if self.stop_on_error => return Err(err), Err(err) => { self.out.push(json!({"photo": photo.0, "file": name, "error": err})); return Ok(()); } }; + let dir = dir.as_str(); // the exported file and its sidecars let group = |main: &str| std::iter::once(main.to_string()).chain(e.sidecars.iter().map(|(x, _)| sidecar_path(main, x))).collect::<Vec<_>>(); let path = match self.to.exact.as_deref().filter(|_| self.single) { @@ -2268,6 +2289,43 @@ mod tests { img } + /// Issue #1670: removing a redundant alpha channel preserves samples and metadata. + #[test] + fn opaque_eight_bit_exports_are_rgb() { + let img = test_image(); + let metadata = Metadata { copyright: Some("Synthetic export test".into()), ..Default::default() }; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + for compression in [TiffCompression::None, TiffCompression::Lzw, TiffCompression::Deflate, TiffCompression::PackBits] { + let options = ExportOptions { format, tiff_compression: compression, ..Default::default() }; + let bytes = encode_with_metadata(&img, &options, Some(&metadata)).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(!decoded.has_alpha, "{format:?}/{compression:?} must be RGB"); + assert_eq!((decoded.width, decoded.height, decoded.bit_depth), (96, 64, 8)); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?}/{compression:?} pixels"); + assert_eq!(decoded.icc.as_deref(), Some(icc::write_named(NamedSpace::Srgb).as_slice())); + assert!(decoded.xmp.as_deref().unwrap_or_default().contains("Synthetic export test")); + if format == ExportFormat::Png { + assert_eq!(bytes[25], 2, "PNG IHDR declares RGB"); + } + if format != ExportFormat::Tiff { + break; + } + } + } + } + + #[test] + fn eight_bit_exports_preserve_real_alpha() { + let mut img = test_image(); + img.data[0][3] = 128; + for format in [ExportFormat::Png, ExportFormat::Tiff, ExportFormat::Webp] { + let bytes = encode_image(&img, &ExportOptions { format, ..Default::default() }).unwrap(); + let decoded = lightcraft_codecs::decode(&bytes, Default::default()).unwrap(); + assert!(decoded.has_alpha, "{format:?} must retain real alpha"); + assert_eq!(decoded.to_srgb8().data, img.data, "{format:?} samples"); + } + } + #[test] fn formats_have_magic() { let img = test_image(); @@ -2494,7 +2552,7 @@ mod tests { fn biz_ud_vertical_watermarks_place_punctuation_at_the_top_right() { use ab_glyph::Font; let Some(entry) = crate::fonts::CRAFT_FONTS.iter().find(|f| f.family == "BIZ UDMincho") else { - eprintln!("skipped: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "built without BIZ UDMincho from craft-fonts"); return; }; let font = ab_glyph::FontRef::try_from_slice(entry.bytes).unwrap(); @@ -2566,7 +2624,7 @@ mod tests { compare(composed, decomposed, crate::fonts::CRAFT_FONTS); } } else { - eprintln!("skipped Japanese comparisons: built without BIZ UDMincho from craft-fonts"); + lightcraft_testkit::skip!(Fonts, "Japanese comparisons: built without BIZ UDMincho from craft-fonts"); } } @@ -2590,7 +2648,7 @@ mod tests { use ab_glyph::Font; let fonts = watermark_fonts(crate::fonts::CRAFT_FONTS); if fonts.len() < 2 { - eprintln!("skipped: built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR, so there is no Japanese watermark face"); return; } for c in "日本語の文字".chars() { @@ -3190,6 +3248,87 @@ mod tests { ExportOptions::from_params(&json!({"quality": 92, "longEdge": null, "watermark": null})).unwrap(); } + /// Issue #956: `destinationMode` picks a specific folder (the default) or each photo's own + /// folder; it is checked like the other params and kept in presets and Export with Previous. + #[test] + fn destination_mode_param() { + use serde_json::json; + assert_eq!(ExportOptions::default().destination_mode, DestinationMode::SpecificFolder); + for (v, want) in [ + ("sameAsOriginal", DestinationMode::SameAsOriginal), + ("SameAsOriginal", DestinationMode::SameAsOriginal), + ("specificFolder", DestinationMode::SpecificFolder), + ] { + assert_eq!(ExportOptions::from_params(&json!({"destinationMode": v})).unwrap().destination_mode, want, "{v}"); + } + let e = ExportOptions::from_params(&json!({"destinationMode": "nextToIt"})).unwrap_err().to_string(); + assert!(e.contains("`destinationMode` must be one of specificFolder|sameAsOriginal"), "{e}"); + assert!(ExportOptions::from_params(&json!({"destinationMode": 1})).is_err()); + assert!( + ExportOptions::from_params(&json!({"destinationMod": "sameAsOriginal"})) + .unwrap_err() + .to_string() + .contains("did you mean `destinationMode`") + ); + // saved (a preset, `lastExport`) and read back + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let j = o.to_json(); + assert_eq!(j["destinationMode"], "sameAsOriginal"); + assert_eq!(ExportOptions::from_params(&ExportOptions::known_keys_only(&j)).unwrap(), o); + } + + /// A choice the validation accepts is the one exported: `metadata: "NONE"` passed the + /// case-insensitive check, then failed the exact-case read and fell back to `all`, so GPS and + /// camera data went out in a file meant to carry none. `sharpen`, `sharpenAmount` and + /// `conflict` had the same gap. + #[test] + fn accepted_choices_are_the_ones_exported() { + use serde_json::json; + let read = |k: &str, v: &str| ExportOptions::from_params(&json!({ k: v })).map_err(|e| e.to_string()); + let metadata = [ + ("all", MetadataPolicy::All), + ("allExceptCamera", MetadataPolicy::AllExceptCamera), + ("copyright", MetadataPolicy::Copyright), + ("none", MetadataPolicy::None), + ]; + for (name, want) in metadata { + for v in [name.to_string(), name.to_ascii_uppercase(), name.to_ascii_lowercase()] { + assert_eq!(read("metadata", &v).unwrap().metadata, want, "metadata: {v}"); + } + } + for (name, want) in [("none", SharpenFor::None), ("screen", SharpenFor::Screen), ("matte", SharpenFor::Matte), ("glossy", SharpenFor::Glossy)] + { + assert_eq!(read("sharpen", &name.to_ascii_uppercase()).unwrap().sharpen, want); + } + for (name, want) in [("low", SharpenAmount::Low), ("standard", SharpenAmount::Standard), ("high", SharpenAmount::High)] { + assert_eq!(read("sharpenAmount", &name.to_ascii_uppercase()).unwrap().sharpen_amount, want); + } + for (name, want) in [("unique", Conflict::Unique), ("overwrite", Conflict::Overwrite), ("skip", Conflict::Skip)] { + assert_eq!(read("conflict", &name.to_ascii_uppercase()).unwrap().conflict, want); + } + // an explicit value nothing reads as is an error, never the permissive default + for v in ["", " none", "nothing", "all-except-camera"] { + let e = read("metadata", v).unwrap_err(); + assert!(e.contains("`metadata` must be one of all|allExceptCamera|copyright|none"), "{v:?}: {e}"); + } + assert!(read("sharpenAmount", "max").unwrap_err().contains("`sharpenAmount` must be one of low|standard|high")); + assert!(read("conflict", "replace").unwrap_err().contains("`conflict` must be one of unique|overwrite|skip")); + // absent (or null) keeps the defaults + let d = ExportOptions::from_params(&json!({"metadata": null})).unwrap(); + assert_eq!( + (d.metadata, d.sharpen, d.sharpen_amount, d.conflict), + (MetadataPolicy::All, SharpenFor::None, SharpenAmount::Standard, Conflict::Unique) + ); + // `removeLocation` stays independent of the policy's spelling + let o = ExportOptions::from_params(&json!({"metadata": "AllExceptCamera", "removeLocation": true})).unwrap(); + assert_eq!((o.metadata, o.remove_location), (MetadataPolicy::AllExceptCamera, true)); + // stored settings (lenient `from_json`) read the same spellings + assert_eq!(ExportOptions::from_json(&json!({"metadata": "None"})).metadata, MetadataPolicy::None); + // and `to_json` still writes what the MCP schema lists + let j = ExportOptions { metadata: MetadataPolicy::AllExceptCamera, conflict: Conflict::Skip, ..Default::default() }.to_json(); + assert_eq!((j["metadata"].as_str(), j["conflict"].as_str()), (Some("allExceptCamera"), Some("skip"))); + } + /// Issue #183: the `watermark` object's keys, kinds and ranges are checked (and documented in /// the `app.export` param string), instead of unknown keys passing and sizes clamping silently. #[test] @@ -3272,7 +3411,7 @@ mod tests { #[test] fn a_batch_reports_a_gpu_fallback_on_the_file_it_hit() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } let mut s = crate::Session::with_demo(); diff --git a/crates/engine/src/faces_index.rs b/crates/engine/src/faces_index.rs index fd4d9f2e7..17bffbc19 100644 --- a/crates/engine/src/faces_index.rs +++ b/crates/engine/src/faces_index.rs @@ -55,6 +55,8 @@ pub(crate) struct Index { skipped: std::collections::HashSet<Key>, /// The cache file on disk belongs to this model, so new records can be appended to it. file_matches: bool, + /// Records were dropped ([`Index::forget_photo`]): the cache file is written fresh at the next save. + rewrite: bool, pub path: Option<PathBuf>, } @@ -87,6 +89,16 @@ impl Index { self.skipped.insert((photo, region_key(rect))); } + /// Drop every face of `photo` (its file was replaced, so they were made from other pixels): they are embedded + /// again, and the cache file is rewritten without them at the next save. + pub fn forget_photo(&mut self, photo: u64) { + let before = self.entries.len(); + self.entries.retain(|k, _| k.0 != photo); + self.unsaved.retain(|(k, _)| k.0 != photo); + self.skipped.retain(|k| k.0 != photo); + self.rewrite |= self.entries.len() != before; + } + #[cfg(test)] pub fn insert(&mut self, photo: u64, rect: &Rect, embedding: Vec<f32>) { self.insert_with_view(photo, rect, embedding, rect); @@ -102,7 +114,7 @@ impl Index { /// Whether there are embeddings not yet written to the cache file. pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over for `tag` (`dim` numbers per face), loading what the cache file at `path` holds for it. What the @@ -116,6 +128,7 @@ impl Index { self.unsaved.clear(); self.skipped.clear(); self.file_matches = false; + self.rewrite = false; if let Some(p) = self.path.clone() { self.load(&p); } @@ -154,6 +167,9 @@ impl Index { self.entries.insert((photo, key), (emb.into(), view)); } } + // a half-written last record (a crash while appending): written fresh at the next save, so later records are + // not appended after it and shifted + self.rewrite = !rest.is_empty(); } fn header(&self) -> Vec<u8> { @@ -175,23 +191,24 @@ impl Index { r } - /// Write what was added since the last save: appended when the file already belongs to this model, else the - /// whole index is written fresh (atomically). A failure is returned, the records stay pending. + /// Write what was added since the last save: appended when the file already belongs to this model, else (or when + /// records were dropped) the whole index is written fresh (atomically). A failure is returned, the records stay + /// pending. pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; + if self.file_matches && !self.rewrite { let mut buf = Vec::new(); for (k, e) in &self.unsaved { buf.extend(self.record(k, e)); } - f.write_all(&buf)?; + append(&path, &buf)?; } else { let mut buf = self.header(); for (k, e) in &self.entries { @@ -201,6 +218,7 @@ impl Index { std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -213,6 +231,22 @@ impl Drop for Index { } } +/// Append `buf` to the file at `path`. When the write fails (a full disk), the file is cut back to its old length so +/// the part that got in does not shift what is appended later. +fn append(path: &Path, buf: &[u8]) -> std::io::Result<()> { + let mut f = std::fs::OpenOptions::new().append(true).open(path)?; + let old_len = f.metadata()?.len(); + let written = f.write_all(buf); + if written.is_err() { + drop(f); + // an append-only handle may not truncate (Windows): cut through a writable one + if let Ok(w) = std::fs::OpenOptions::new().write(true).open(path) { + let _ = w.set_len(old_len); + } + } + written +} + const SCAN_MAGIC: &[u8; 4] = b"LCFS"; const SCAN_VERSION: u8 = 1; /// The detector a search was made with: a record made by another one is ignored (a better detector may find more). @@ -222,15 +256,31 @@ const SCAN_MAX_FILE: u64 = 64 << 20; /// The photos the detector has already searched for faces, so each is searched once, however many times LightCraft /// starts. Kept beside the library (`face-scanned.bin`: a header naming the detector, then photo ids, appended to). -#[derive(Default)] +/// The focus-point backfill keeps its own record the same way ([`Scanned::new`] with its magic and decoder tag). pub(crate) struct Scanned { set: std::collections::HashSet<u64>, unsaved: Vec<u64>, path: Option<PathBuf>, file_matches: bool, + /// A photo was taken out ([`Scanned::remove`]): the record is written fresh at the next save. + rewrite: bool, + magic: [u8; 4], + /// What made the record: a file made by anything else is started afresh. + tag: String, +} + +impl Default for Scanned { + fn default() -> Self { + Scanned::new(*SCAN_MAGIC, SCAN_TAG) + } } impl Scanned { + /// An empty record (in memory until [`Scanned::reset`] gives it a path) whose file starts with `magic` and `tag`. + pub fn new(magic: [u8; 4], tag: &str) -> Scanned { + Scanned { set: Default::default(), unsaved: Vec::new(), path: None, file_matches: false, rewrite: false, magic, tag: tag.to_string() } + } + pub fn path(&self) -> Option<&Path> { self.path.as_deref() } @@ -249,8 +299,16 @@ impl Scanned { } } + /// Search `photo` again (its file was replaced). + pub fn remove(&mut self, photo: u64) { + if self.set.remove(&photo) { + self.unsaved.retain(|p| *p != photo); + self.rewrite = true; + } + } + pub fn has_unsaved(&self) -> bool { - !self.unsaved.is_empty() + !self.unsaved.is_empty() || self.rewrite } /// Start over with the record at `path` (written first: what was not yet saved is not lost). @@ -259,18 +317,20 @@ impl Scanned { self.set.clear(); self.unsaved.clear(); self.file_matches = false; + self.rewrite = false; self.path = path; if let Some(p) = self.path.clone() { self.load(&p); } } - fn header() -> Vec<u8> { + fn header(&self) -> Vec<u8> { let mut h = Vec::new(); - h.extend_from_slice(SCAN_MAGIC); + h.extend_from_slice(&self.magic); h.push(SCAN_VERSION); - h.extend_from_slice(&(SCAN_TAG.len() as u16).to_le_bytes()); - h.extend_from_slice(SCAN_TAG.as_bytes()); + let tag = self.tag.as_bytes().get(..usize::from(u16::MAX)).unwrap_or(self.tag.as_bytes()); + h.extend_from_slice(&(tag.len() as u16).to_le_bytes()); + h.extend_from_slice(tag); h } @@ -280,11 +340,14 @@ impl Scanned { return; } let Ok(bytes) = std::fs::read(path) else { return }; - let head = Self::header(); + let head = self.header(); let Some(rest) = bytes.strip_prefix(head.as_slice()) else { return }; self.file_matches = true; - // a half-written last id (a crash while appending) is ignored - self.set.extend(rest.as_chunks::<8>().0.iter().map(|c| u64::from_le_bytes(*c))); + // a half-written last id (a crash while appending) is ignored, and the record is written fresh at the next save + // so later ids are not appended after it and shifted + let (ids, torn) = rest.as_chunks::<8>(); + self.set.extend(ids.iter().map(|c| u64::from_le_bytes(*c))); + self.rewrite = !torn.is_empty(); } /// Write what was added since the last save: appended when the file is this detector's, else the whole record @@ -292,22 +355,23 @@ impl Scanned { pub fn save(&mut self) -> std::io::Result<()> { let Some(path) = self.path.clone() else { self.unsaved.clear(); + self.rewrite = false; return Ok(()); }; - if self.unsaved.is_empty() { + if self.unsaved.is_empty() && !self.rewrite { return Ok(()); } - if self.file_matches { - let mut f = std::fs::OpenOptions::new().append(true).open(&path)?; + if self.file_matches && !self.rewrite { let buf: Vec<u8> = self.unsaved.iter().flat_map(|id| id.to_le_bytes()).collect(); - f.write_all(&buf)?; + append(&path, &buf)?; } else { - let mut buf = Self::header(); + let mut buf = self.header(); buf.extend(self.set.iter().flat_map(|id| id.to_le_bytes())); let part = path.with_extension("part"); std::fs::write(&part, &buf)?; std::fs::rename(&part, &path)?; self.file_matches = true; + self.rewrite = false; } self.unsaved.clear(); Ok(()) @@ -406,6 +470,88 @@ mod tests { assert_eq!(b.len(), 0, "a non-finite vector is not loaded back"); let _ = std::fs::remove_file(path); } + + /// The faces and the search of a photo whose file was replaced are dropped from the files too, not only from memory: + /// they don't come back at the next launch (issue #1524). + #[test] + fn a_forgotten_photo_stays_forgotten_after_reloading() { + let dir = std::env::temp_dir().join(format!("lc-faces-forget-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let mut a = Index::default(); + a.reset("m@abc", 2, Some(emb.clone())); + a.insert(1, &rect(0.1), vec![1.0, 0.0]); + a.insert(2, &rect(0.1), vec![0.0, 1.0]); + a.save().unwrap(); + a.forget_photo(1); + assert!(a.has_unsaved()); + a.save().unwrap(); + a.insert(3, &rect(0.1), vec![1.0, 1.0]); + a.save().unwrap(); + let mut b = Index::default(); + b.reset("m@abc", 2, Some(emb)); + assert!(!b.contains(1, &rect(0.1)) && b.contains(2, &rect(0.1)) && b.contains(3, &rect(0.1))); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(1); + s.insert(2); + s.save().unwrap(); + s.remove(1); + assert!(s.has_unsaved()); + s.save().unwrap(); + s.insert(3); + s.save().unwrap(); + let mut t = Scanned::default(); + t.reset(Some(scan)); + assert!(!t.contains(1) && t.contains(2) && t.contains(3)); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A torn last record (a crash or a full disk while appending) does not shift the records appended after it + /// (issue #2129). + #[test] + fn records_appended_after_a_torn_tail_stay_aligned() { + let dir = std::env::temp_dir().join(format!("lc-faces-torn-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let (emb, scan) = (dir.join("face-embeddings.bin"), dir.join("face-scanned.bin")); + let junk = |p: &Path| { + let mut f = std::fs::OpenOptions::new().append(true).open(p).unwrap(); + f.write_all(&[0x3f; 5]).unwrap(); + }; + let mut a = Index::default(); + a.reset("m@abc", 3, Some(emb.clone())); + a.insert(7, &rect(0.1), vec![1.0, 0.0, 0.5]); + a.save().unwrap(); + drop(a); + junk(&emb); + let mut b = Index::default(); + b.reset("m@abc", 3, Some(emb.clone())); + b.insert(9, &rect(0.5), vec![0.5, 0.25, 0.125]); + b.save().unwrap(); + drop(b); + let mut c = Index::default(); + c.reset("m@abc", 3, Some(emb)); + assert_eq!(c.len(), 2); + assert_eq!(c.get(7, &rect(0.1)).map(|e| e.to_vec()), Some(vec![1.0, 0.0, 0.5])); + assert_eq!(c.get(9, &rect(0.5)).map(|e| e.to_vec()), Some(vec![0.5, 0.25, 0.125])); + let mut s = Scanned::default(); + s.reset(Some(scan.clone())); + s.insert(42); + s.save().unwrap(); + drop(s); + junk(&scan); + let mut t = Scanned::default(); + t.reset(Some(scan.clone())); + t.insert(43); + t.save().unwrap(); + drop(t); + let mut u = Scanned::default(); + u.reset(Some(scan)); + assert!(u.contains(42) && u.contains(43) && u.len() == 2); + let _ = std::fs::remove_dir_all(&dir); + } } /// What the recognition commands keep between calls: the loaded model and the embeddings made with it. @@ -456,4 +602,12 @@ impl FacesState { self.saved_at = None; self.epoch = self.epoch.wrapping_add(1); } + + /// Photo `id`'s file was replaced by other content: the faces made from the old pixels are dropped and the photo is + /// searched again (issue #1524). What a worker is still making from the old file is dropped when it comes back. + pub fn content_replaced(&mut self, id: lightcraft_catalog::PhotoId) { + self.index.forget_photo(id.0); + self.scanned.remove(id.0); + self.scan_failed.remove(&id.0); + } } diff --git a/crates/engine/src/faces_worker.rs b/crates/engine/src/faces_worker.rs index 81ff6adae..61dbc2544 100644 --- a/crates/engine/src/faces_worker.rs +++ b/crates/engine/src/faces_worker.rs @@ -127,6 +127,17 @@ fn pool_threads_for(cores: usize, pace: Pace, cap: Option<usize>) -> usize { cap.map_or(n, |c| n.min(c.max(1))) } +/// How much of the GPU face models may take at `pace`: face model runs at once, and the share of the time it may be +/// busy. Paused or light: one run at a time, busy a quarter of the time (the user is working, and the window renders +/// on the same GPU); normal: two at once, busy most of the time; full: as fast as it goes. +pub(crate) fn gpu_pace(pace: Pace) -> (usize, f32) { + match pace { + Pace::Pause | Pace::Light => (1, 0.25), + Pace::Normal => (2, 0.75), + Pace::Full => (4, 1.0), + } +} + /// One photo's faces to embed (and, if `detect`, to find first). pub(crate) struct Prepared { /// `<model id>@<sha256>` the embeddings are for. @@ -134,6 +145,9 @@ pub(crate) struct Prepared { /// The session's face epoch when this was prepared: another library has been opened since if it differs. pub epoch: u64, pub id: PhotoId, + /// The photo's content key ([`crate::media::content_key`]) when this was prepared: its file was replaced (Reload + /// after an outside edit) since if it differs. + pub content: String, pub job: RenderJob, /// A raw's embedded preview, tried before the render job (much faster than decoding the raw). pub preview: Option<(String, PreviewLoader)>, @@ -150,6 +164,7 @@ pub(crate) struct Done { pub tag: String, pub epoch: u64, pub id: PhotoId, + pub content: String, /// Each face's region, its embedding (`None` when it could not be made) and the box to show it by. pub results: Vec<(Rect, Option<Vec<f32>>, Rect)>, /// Faces were to be looked for in this photo. @@ -177,7 +192,7 @@ fn face_rect(f: &Face) -> Option<Rect> { /// Do the work for one photo. Never panics: whatever goes wrong, its faces come back as `None`. pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { - let Prepared { tag, epoch, id, job, preview, todo, detect, detector } = p; + let Prepared { tag, epoch, id, content, job, preview, todo, detect, detector } = p; let fallback = |todo: Vec<Rect>| todo.into_iter().map(|r| (r, None, r)).collect::<Vec<_>>(); let rects = todo.clone(); let run = catch_unwind(AssertUnwindSafe(|| { @@ -197,7 +212,8 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { let img = Rgb { data: &rgb, width: w, height: h }; let (iw, ih) = embedder.input_size(); let scaled = |f: &Face| f.landmarks.map(|(x, y)| (x * w as f32, y * h as f32)); - let mut results: Vec<(Rect, Option<Vec<f32>>, Rect)> = todo + // every face's crop first, then all of them through the model at once (one GPU dispatch for a group photo) + let mut faces: Vec<(Rect, Option<Vec<u8>>, Rect)> = todo .into_iter() .map(|rect| { let best = found.iter().map(|f| (overlap(&rect, f), f)).filter(|(o, _)| *o >= MATCH_IOU).max_by(|a, b| a.0.total_cmp(&b.0)); @@ -214,20 +230,18 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { ih, ), }; - let v = crop.and_then(|c| embedder.embed(&c).ok()); // shown by the detector's box where it found this face: every face then has the same tightness let view = best.and_then(|(_, f)| face_rect(f)).unwrap_or(rect); - (rect, v, view) + (rect, crop, view) }) .collect(); // a photo with no face regions: what the detector is sure of becomes its faces, embedded in the same pass let new_faces = if detect { - detected.map(|faces| { + detected.map(|found| { let mut rects = Vec::new(); - for f in faces.iter().filter(|f| f.score >= REGION_SCORE) { + for f in found.iter().filter(|f| f.score >= REGION_SCORE) { let Some(rect) = face_rect(f) else { continue }; - let v = align_to_template(&img, &scaled(f), iw, ih).and_then(|c| embedder.embed(&c).ok()); - results.push((rect, v, rect)); + faces.push((rect, align_to_template(&img, &scaled(f), iw, ih), rect)); rects.push(rect); } rects @@ -235,10 +249,14 @@ pub(crate) fn process(p: Prepared, embedder: &Embedder) -> Done { } else { None }; + let crops: Vec<&[u8]> = faces.iter().filter_map(|(_, c, _)| c.as_deref()).collect(); + let mut embedded = embedder.embed_batch(&crops).into_iter(); + let results: Vec<(Rect, Option<Vec<f32>>, Rect)> = + faces.into_iter().map(|(rect, crop, view)| (rect, crop.and_then(|_| embedded.next().and_then(Result::ok)), view)).collect(); (results, new_faces) })); let (results, found) = run.unwrap_or_else(|_| (fallback(rects), None)); - Done { tag, epoch, id, results, detect_wanted: detect, found } + Done { tag, epoch, id, content, results, detect_wanted: detect, found } } type Job = (Arc<Embedder>, Prepared); diff --git a/crates/engine/src/files.rs b/crates/engine/src/files.rs index e2144eb85..31bb930fb 100644 --- a/crates/engine/src/files.rs +++ b/crates/engine/src/files.rs @@ -18,7 +18,8 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) { let camera = [m.make.clone().unwrap_or_default(), m.model.clone().unwrap_or_default()].join(" ").trim().to_string(); let meta = Meta { camera, - lens: m.lens_model.clone().unwrap_or_default(), + // (a camera that writes no lens name, like Nikon's DSLRs, still says what range the lens covers) + lens: m.lens_model.clone().or_else(|| m.lens_spec.and_then(lightcraft_meta::lens_spec_name)).unwrap_or_default(), focal_mm: m.focal_length.map(|f| f as f32), aperture: m.f_number.map(|f| f as f32), shutter, @@ -40,6 +41,7 @@ fn meta_of(m: &lightcraft_meta::Metadata) -> (Meta, Option<String>) { // paths from `lr:hierarchicalSubject`, names from `dc:subject` that aren't their levels keywords: lightcraft_catalog::keywords::from_file(&m.keywords, &m.hierarchical_keywords), regions: m.regions.clone(), + focus: m.focus.clone(), }; (meta, m.capture_time.as_ref().map(|d| d.to_iso())) } @@ -158,10 +160,8 @@ pub fn probe_bytes(name: &str, bytes: &[u8]) -> Result<ProbeInfo, String> { } Err(Err(e)) => return Err(e), }; - let (mut w, mut h) = (raw.crop.width.max(1) as u32, raw.crop.height.max(1) as u32); - if w <= 1 || h <= 1 { - (w, h) = (raw.active_area.width as u32, raw.active_area.height as u32); - } + let (w, h) = raw.developed_size(); + let (mut w, mut h) = (w.max(1) as u32, h.max(1) as u32); if raw.orientation.swaps_axes() { std::mem::swap(&mut w, &mut h); } @@ -266,19 +266,43 @@ impl CameraColourModel<'_> { /// White balance, matrix, tables and baseline exposure, in place. pub fn apply(&self, img: &mut Rgb32f) { let (wb, m, gain) = (self.wb, self.matrix.to_f32(), self.gain); - img.map_in_place(|p| { - let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; - let rgb = [ - m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], - m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], - m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], - ]; - let rgb = match self.tables { - Some(tables) => tables.apply(rgb, gain), - None => rgb.map(|v| v * gain), - }; - self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) - }); + if let (None, Some(hue_sat)) = (self.tables, self.hue_sat) { + // a fitted camera look: `pixel`, `LANES` pixels at a time so the table step vectorises (same bits) + const LANES: usize = 16; + lightcraft_raster::par_rows(&mut img.data, img.width, |_, row| { + for chunk in row.chunks_mut(LANES) { + let mut rgb = [[0.0f32; LANES]; 3]; + for (l, p) in chunk.iter().enumerate() { + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + for (i, rgb) in rgb.iter_mut().enumerate() { + rgb[l] = (m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]) * gain; + } + } + let out = hue_sat.apply_lanes(rgb); + for (l, p) in chunk.iter_mut().enumerate() { + *p = [out[0][l], out[1][l], out[2][l]].map(|v| v.max(0.0)); + } + } + }); + return; + } + img.map_in_place(|p| self.pixel(&m, p)); + } + + /// [`Self::apply`] to one pixel; `m` is the matrix as `f32`. + fn pixel(&self, m: &[[f32; 3]; 3], p: [f32; 3]) -> [f32; 3] { + let (wb, gain) = (self.wb, self.gain); + let c = [p[0] * wb[0], p[1] * wb[1], p[2] * wb[2]]; + let rgb = [ + m[0][0] * c[0] + m[0][1] * c[1] + m[0][2] * c[2], + m[1][0] * c[0] + m[1][1] * c[1] + m[1][2] * c[2], + m[2][0] * c[0] + m[2][1] * c[1] + m[2][2] * c[2], + ]; + let rgb = match self.tables { + Some(tables) => tables.apply(rgb, gain), + None => rgb.map(|v| v * gain), + }; + self.hue_sat.map_or(rgb, |h| h.apply(rgb)).map(|v| v.max(0.0)) } } @@ -400,12 +424,17 @@ fn load_bytes_now( // (removed before the camera look's binned sensor proxy too, which needs an empty `OpcodeList3`). let info = raw.info(); let lens = crate::lens_profiles::for_raw(&info, || lightcraft_meta::extract(&bytes)); + // (the camera look still pairs the sensor with the camera JPEG through the warp the camera applied to it) + let lens_geometry = crate::camera_preview::lens_geometry(&raw.opcodes.list3); raw.opcodes.list3.retain(|op| !op.is_lens_correction()); // Which scale white balance is edited on, and the as-shot white on it, from the file's own colour tags or // the camera's measured matrices (`Photo::relative_wb` decides the same from the catalog's camera) let scale = WbScaleOf::new(raw.format, &raw.color, raw.wb_multipliers, &raw.metadata); // the source's segmentation mattes (DNG semantic masks), read while the starting colour is fitted - let ((xy, t, camera_look), mattes) = rayon::join(|| crate::camera_preview::starting_colour(&mut raw, &bytes), || dng_mattes(&bytes, &info)); + let ((xy, t, camera_look), mattes) = rayon::join( + || crate::camera_preview::starting_colour(&mut raw, &bytes, lens_geometry.as_ref(), lens.as_ref()), + || dng_mattes(&bytes, &info), + ); drop(bytes); // Previews and thumbnails bin the mosaic straight to (about) the size they need; only // larger levels (exports, 1:1) demosaic the whole sensor. @@ -475,10 +504,11 @@ fn load_bytes_now( // on top of it). A model's measured matrices never do: they only give the Kelvin scale its numbers, and the // picture is adapted from the as-shot white as every fitted look is (so settings from before that scale // existed render exactly as they did, `lightcraft_pipeline::local::legacy_white`). - let camera_color = (scale.own_matrix && !t.matrix_is_fallback && camera_look.is_none()).then(|| { - let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; - Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) - }); + let camera_color = (scale.own_matrix && !t.matrix_is_fallback && (camera_look.is_none() || raw.format == lightcraft_raw::RawFormat::Dng)) + .then(|| { + let tags = lightcraft_raw::ColorData { profile: Default::default(), ..raw.color.clone() }; + Arc::new(lightcraft_pipeline::CameraColor { tags, developed_for: xy }) + }); let camera_tone = camera_look.as_ref().map(|p| p.tone).or_else(|| raw.color.profile.tone_curve.as_ref().and_then(dng_tone_curve)); let local_tone = local_tone(&raw); let (temp, tint) = scale.as_shot(xy); @@ -496,6 +526,8 @@ fn load_bytes_now( camera_tone, mattes, local_tone, + // (the catalog knows the original's size: `media::source_info`) + native_long: None, }, )); } @@ -514,17 +546,28 @@ fn load_bytes_now( /// (`lightcraft_raw::spectral`), else when its camera's white-balance presets give one /// (`lightcraft_raw::kelvin_presets`): either turns its as-shot multipliers into Kelvin and tint, nothing else — the /// look stays the file's fit. Any other raw is edited relative to its as-shot look (6500 K / 0 means as shot). -struct WbScaleOf { +pub(crate) struct WbScaleOf { own_matrix: bool, relative: bool, /// Kelvin through the camera's measured matrices or presets: edited on the relative scale before those were used. legacy_relative: bool, /// The as-shot white through the measured matrices or the presets, when that is where the scale comes from. scale_white: Option<lightcraft_color::Xy>, + /// The camera's measured matrices, when they give the scale. + camera: Option<&'static lightcraft_raw::spectral::Camera>, +} + +/// The white balance a DNG of a raw's camera RGB (Photo Merge) declares so that it shows as its source does +/// ([`WbScaleOf::dng_white`]). +pub(crate) enum DngWhite { + /// Colour tags with the camera's measured matrices: the DNG reads every white through them, as the source does. + Matrices(Box<lightcraft_raw::ColorData>), + /// The white the source's scale shows at As Shot. + At(lightcraft_color::Xy), } impl WbScaleOf { - fn new( + pub(crate) fn new( format: lightcraft_raw::RawFormat, color: &lightcraft_raw::ColorData, wb_multipliers: Option<[f32; 3]>, @@ -541,7 +584,23 @@ impl WbScaleOf { None => None, }; let covered = camera.is_some() || presets; - WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white } + WbScaleOf { own_matrix, relative: fitted && !covered, legacy_relative: fitted && covered, scale_white, camera } + } + + /// What a DNG of this raw's camera RGB declares so that it shows the white balance as this scale does: the + /// camera's measured matrices when they give the scale, else the white the scale shows at As Shot (the presets' + /// reading; 6500 K / 0 on the relative scale; else `own`, the white the file's tags give). + pub(crate) fn dng_white(&self, color: &lightcraft_raw::ColorData, own: lightcraft_color::Xy) -> DngWhite { + if let Some(camera) = self.camera { + let mut c = lightcraft_raw::ColorData { profile: Default::default(), kelvin_presets: None, ..color.clone() }; + if camera.fill(&mut c) { + return DngWhite::Matrices(Box::new(c)); + } + } + if self.relative { + return DngWhite::At(lightcraft_color::cct::temp_tint_to_xy(6500.0, 0.0)); + } + DngWhite::At(self.scale_white.unwrap_or(own)) } /// The as-shot temperature and tint on the file's scale: the relative scale's reference, the white through /// the measured matrices or the presets, else `own` (the white the file's own tags give). Whole numbers, as the @@ -648,8 +707,9 @@ pub(crate) fn decode_raw_preview( covering: Option<usize>, ) -> Option<lightcraft_codecs::Decoded> { let jpeg = stand_in_image(bytes, covering)?; - // the colour space is a property of the JPEG; a TIFF stand-in is read by the TIFF reader - let is_jpeg = matches!(jpeg, std::borrow::Cow::Owned(_)); + // the colour space is a property of the JPEG; a TIFF stand-in (the file itself, or a Hasselblad RGB preview + // wrapped as a TIFF) and a JPEG XL preview are read by their own decoders + let is_jpeg = jpeg.starts_with(&[0xff, 0xd8]); match lightcraft_raw::embedded_preview_color_space(bytes).filter(|_| is_jpeg) { Some(space) => { let space = match space { @@ -1048,10 +1108,24 @@ impl crate::Session { } #[cfg(test)] -mod tests { +pub(crate) mod tests { /// Keywords from a file keep their hierarchy: the paths of `lr:hierarchicalSubject`, not the /// flat names of `dc:subject` that LightCraft's exports (and Lightroom Classic) write besides /// them; an exported `travel|Italy|Rome` comes back as itself. + #[test] + fn a_lens_without_a_name_is_shown_by_its_range() { + // a Nikon DSLR raw: no LensModel, the range from the maker note + let m = lightcraft_meta::Metadata { lens_spec: Some([18.0, 135.0, 3.5, 5.6]), ..Default::default() }; + assert_eq!(super::meta_of(&m).0.lens, "18.0-135.0 mm f/3.5-5.6"); + let named = lightcraft_meta::Metadata { + lens_model: Some("NIKKOR Z 24-70mm f/4 S".into()), + lens_spec: Some([24.0, 70.0, 4.0, 4.0]), + ..Default::default() + }; + assert_eq!(super::meta_of(&named).0.lens, "NIKKOR Z 24-70mm f/4 S"); + assert_eq!(super::meta_of(&lightcraft_meta::Metadata::default()).0.lens, ""); + } + #[test] fn imported_keywords_keep_their_hierarchy() { let m = lightcraft_meta::Metadata { @@ -1125,6 +1199,40 @@ mod tests { assert!(!lightcraft_catalog::relative_wb_camera("DNG", "SONY ILCE-7M3")); } + /// A merged DNG (Photo Merge) of a raw without colour matrices shows the As Shot temperature and tint its source + /// shows, on every scale: through the measured matrices (ILCE-7M3), through the presets (ILCA-99M2), the relative + /// scale's 6500 K / 0 (ILCE-7M2) and a file's own white (an unfitted format). Merges of real ILCE-7M3 brackets + /// opened at 6113–7307 K / +115 where the ARWs show 3932–5268 K / +7. + #[test] + fn a_merged_dng_shows_the_white_balance_its_source_shows() { + use lightcraft_raw::{ColorData, RawFormat}; + let meta = |make: &str, model: &str| lightcraft_meta::Metadata { make: Some(make.into()), model: Some(model.into()), ..Default::default() }; + let g = |r: f32, b: f32| [r / 1024.0, 1.0, b / 1024.0]; + let presets = lightcraft_raw::kelvin_presets::KelvinPresets::new([ + (2500.0, g(1336.0, 3672.0)), + (3200.0, g(1720.0, 2620.0)), + (4500.0, g(2256.0, 1880.0)), + (6000.0, g(2680.0, 1544.0)), + (8500.0, g(3136.0, 1300.0)), + ]); + let with_presets = ColorData { kelvin_presets: presets, ..Default::default() }; + let cases = [ + (RawFormat::Arw, ColorData::default(), [2.5039f32, 1.0, 1.5078], meta("SONY", "ILCE-7M3")), + (RawFormat::Arw, with_presets, g(2500.0, 1668.0), meta("SONY", "ILCA-99M2")), + (RawFormat::Arw, ColorData::default(), [2.2, 1.0, 1.6], meta("SONY", "ILCE-7M2")), + (RawFormat::Rw2, ColorData::default(), [2.0, 1.0, 1.4], meta("Panasonic", "DC-G9")), + ]; + for (format, color, mul, meta) in cases { + let own = lightcraft_raw::color::as_shot_white_of(&color, Some(mul)); + let source = WbScaleOf::new(format, &color, Some(mul), &meta); + let t = lightcraft_raw::color::camera_transform_of(&color, own); + let dng = crate::camera_preview::dng_colour(&color, &source.dng_white(&color, own), &t, None).unwrap(); + let merged = WbScaleOf::new(RawFormat::Dng, &dng, None, &meta); + let shown = source.as_shot(own); + assert_eq!(merged.as_shot(lightcraft_raw::color::as_shot_white_of(&dng, None)), shown, "{:?}", meta.model); + } + } + /// A RAF whose preview pointer is in its header, then a large raw tail the reader must not need. fn raf_with_embedded_jpeg(jpeg: &[u8], tail: usize) -> Vec<u8> { let head = 160; @@ -1562,7 +1670,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(gh1) = std::fs::read(dir.join("rw2-panasonic-gh1.rw2")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; assert_eq!(probe_bytes("gh1.rw2", &gh1).unwrap().embedded_lens, None, "correction off: nothing to apply"); @@ -1633,7 +1741,7 @@ mod tests { } /// A CR3-shaped file (not decodable yet) whose only content is a `PRVW` preview box. - fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> { + pub(crate) fn cr3_with_preview(w: u32, h: u32) -> Vec<u8> { let px: Vec<u8> = (0..w * h).flat_map(|i| [(i % 251) as u8, 128, 200]).collect(); let jpeg = encode_jpeg(&EncodeImage::new(w, h, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); let mut b = b"\0\0\0\x18ftypcrx \0\0\0\x01crx isom".to_vec(); @@ -1729,6 +1837,31 @@ mod tests { lightcraft_color::Mat3([[0.42, 0.548, 0.04], [0.246, 0.728, 0.033], [-0.247, 0.679, 0.447]]) } + /// A fitted camera look applied in lanes, on rows that aren't a whole number of them, gives the bits of the + /// pixel-by-pixel path, awkward pixels (black, negative, huge, NaN, infinite) included. + #[test] + fn camera_look_in_lanes_matches_pixel_by_pixel() { + let data = (0..72 * 5 * 5).map(|i| [((i * 37) % 61) as f32 - 30.0, 0.6 + ((i * 11) % 13) as f32 / 13.0, 1.0]).collect(); + let table = lightcraft_raw::profile::HsvTable { hue_divisions: 72, sat_divisions: 5, val_divisions: 5, data, srgb_value: true }; + let hue_sat = crate::camera_preview::HueSat::new(&table).unwrap(); + let colour = CameraColourModel { wb: [2.1, 1.0, 1.5], matrix: yellowing_look(), gain: 1.3, tables: None, hue_sat: Some(&hue_sat) }; + let special = [0.0, -0.0, -0.01, 1e-30, 0.3, 1.0, 5.0, 1e30, f32::INFINITY, f32::NAN]; + let img = Rgb32f::from_fn(37, 5, |x, y| { + let k = y * 37 + x; + if k % 5 == 0 { + [special[k % 10], special[k / 10 % 10], special[k / 100 % 10]] + } else { + [(x as f32 * 0.37).sin().abs(), ((x + y) as f32 * 0.11).cos().abs(), y as f32 / 4.0 - 0.05] + } + }); + let mut lanes = img.clone(); + colour.apply(&mut lanes); + let m = colour.matrix.to_f32(); + for (p, got) in img.data.iter().zip(&lanes.data) { + assert_eq!(got.map(f32::to_bits), colour.pixel(&m, *p).map(f32::to_bits), "{p:?}"); + } + } + #[test] fn clipped_white_renders_neutral_through_the_colour_model() { use lightcraft_color::{LUMA_2020, Mat3}; @@ -1913,7 +2046,7 @@ mod tests { .unwrap_or_else(|| std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw"); let Ok(bytes) = std::fs::read(dir.join("arw-sony-a7rm4-14bit-compressed.arw")) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let (img, _) = load_bytes(&bytes, 1600).unwrap(); @@ -2157,10 +2290,13 @@ mod tests { let p = s.catalog.photo(id).unwrap().clone(); let why = p.preview_only.clone().expect("marked preview only"); assert!(why.to_uppercase().contains("CR3"), "the decoder's reason: {why}"); + // stamped with the version that decided it, so a later version rechecks it (#662) + assert_eq!(p.preview_checked, lightcraft_catalog::APP_VERSION); + assert!(!p.preview_only_stale()); assert_eq!(p.kind, MediaKind::Raw, "still a raw file (filters, Convert to DNG…)"); assert!(!p.develops_raw()); // rendered like the JPEG it is: relative white balance, display tone curve, no raw defaults - assert_eq!(crate::media::source_info(&p), SourceInfo::default()); + assert_eq!(SourceInfo { native_long: None, ..crate::media::source_info(&p) }, SourceInfo::default()); assert_eq!(*p.develop, lightcraft_develop::DevelopSettings::default()); assert!(s.render_job(id, 48, 48, false, true).unwrap().run().rendered.is_ok()); // agents see it @@ -2174,14 +2310,17 @@ mod tests { let mut s = crate::Session::new().with_fs(); s.open_library(&lib, false).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // the file decodes now (here: replaced by a DNG): Reload clears it, undo brings it back std::fs::write(&path, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); let r = s.execute("photo.reload", &json!({"ids": [id.0]})).unwrap(); assert_eq!(r["reloaded"], json!([id.0]), "{r}"); assert!(s.catalog.photo(id).unwrap().develops_raw()); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, "", "the stamp goes with the flag"); assert!(crate::media::source_info(s.catalog.photo(id).unwrap()).raw); s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.photo(id).unwrap().preview_only.as_deref(), Some(why.as_str())); + assert_eq!(s.catalog.photo(id).unwrap().preview_checked, lightcraft_catalog::APP_VERSION); // relinking to a file that decodes clears it too let dng = files.join("DSC_0001.dng"); std::fs::write(&dng, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); @@ -2400,4 +2539,112 @@ mod tests { assert!(q.rendered.is_ok(), "{:?}", q.rendered.err()); let _ = std::fs::remove_dir_all(&dir); } + + /// A Sony ARW of `model`: a 64 × 48 uncompressed 14-bit CFA strip in a SubIFD (as `crates/raw/tests/vendor_robust.rs` + /// lays it out) whose IFD carries the full-frame 16–35 mm distortion table (tag 0x7037). + fn sony_arw(model: &str) -> Vec<u8> { + use lightcraft_tiff::tags::{self as t, photometric}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + // 3:2, the aspect Sony's lens opcodes are limited to + let (w, h) = (72u32, 48u32); + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![14])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set(0x7037, Value::SShort(vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![2000u16.to_le_bytes().repeat((w * h) as usize)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SONY".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// Sony's embedded distortion correction is on by default: an ARW of a verified body imports with "Enable Lens + /// Corrections" on and still counts as unedited; another body's tables aren't read, so its correction is off. + #[test] + fn arw_with_lens_data_imports_with_profile_corrections_on() { + let dir = std::env::temp_dir().join(format!("lc-arw-lens-default-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = crate::Session::new().with_fs(); + for (model, on) in [("ILCE-7RM6", true), ("ILCE-7M4", false)] { + let path = dir.join(format!("{model}.arw")); + std::fs::write(&path, sony_arw(model)).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + let p = s.catalog.photo(lightcraft_catalog::PhotoId(r["imported"][0].as_u64().unwrap())).unwrap(); + assert_eq!((p.format.as_str(), p.embedded_lens.is_some(), p.develop.optics.lens_profile), ("ARW", on, on), "{model}"); + assert!(!p.is_edited(), "{model}: as imported"); + } + let _ = std::fs::remove_dir_all(&dir); + } + + /// RW2 and DNG keep their default: the correction is on when the file carries one (RW2 distortion, a DNG's + /// opcodes), off when it doesn't. + #[test] + fn rw2_and_dng_defaults_unchanged() { + use lightcraft_catalog::{Photo, PhotoId, Source}; + let s = crate::Session::new(); + let lens = lightcraft_develop::EmbeddedLens { warp: Some(Default::default()), ..Default::default() }; + for format in ["RW2", "DNG"] { + for embedded in [None, Some(lens)] { + let name = format!("x.{format}"); + let mut p = Photo::new(PhotoId(1), Source::File { path: format!("/{name}") }, &name, format, 64, 48, ""); + p.kind = MediaKind::Raw; + p.embedded_lens = embedded; + crate::import::apply_import_defaults(&s, &mut p); + assert_eq!(p.develop.optics.lens_profile, embedded.is_some(), "{format} {embedded:?}"); + } + } + } + + /// Stored settings stay as they are when the library opens again: an ARW catalogued before its lens data was read + /// (none, correction off) keeps the correction off and counts as unedited, and one whose user switched the + /// correction off keeps it off. + #[test] + fn existing_photo_settings_are_not_rewritten() { + use lightcraft_catalog::{Op, PhotoId}; + let dir = std::env::temp_dir().join(format!("lc-arw-lens-existing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let import = |s: &mut crate::Session, name: &str| { + let path = files.join(name); + // trailing bytes of its own: not a duplicate of the other file + std::fs::write(&path, [sony_arw("ILCE-7RM6"), name.as_bytes().to_vec()].concat()).unwrap(); + let r = s.execute("library.import", &serde_json::json!({"paths": [path.to_string_lossy()]})).unwrap(); + PhotoId(r["imported"][0].as_u64().unwrap()) + }; + let old = import(&mut s, "old.arw"); + // as a release that didn't read the Sony tables catalogued it + let mut d = (*s.catalog.photo(old).unwrap().develop).clone(); + d.optics.lens_profile = false; + let ops = vec![ + Op::SetEmbeddedLens { id: old, lens: None }, + Op::SetDevelop { id: old, settings: std::sync::Arc::new(d), label: "x".into(), edited: None }, + ]; + s.commit("x", Op::Batch { ops }).unwrap(); + let off = import(&mut s, "off.arw"); + s.execute("develop.merge", &serde_json::json!({"settings": {"optics": {"lens_profile": false}}})).unwrap(); + let before: Vec<_> = [old, off].iter().map(|id| s.catalog.photo(*id).unwrap().clone()).collect(); + assert!(before.iter().all(|p| !p.develop.optics.lens_profile)); + s.persist().unwrap(); + drop(s); + let mut s = crate::Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + for b in &before { + let p = s.catalog.photo(b.id).unwrap(); + assert_eq!((&p.develop, &p.embedded_lens), (&b.develop, &b.embedded_lens), "{}", p.file_name); + } + assert!(!s.catalog.photo(old).unwrap().is_edited(), "still as imported"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); + } } diff --git a/crates/engine/src/focus_scan.rs b/crates/engine/src/focus_scan.rs new file mode 100644 index 000000000..6be63e7eb --- /dev/null +++ b/crates/engine/src/focus_scan.rs @@ -0,0 +1,432 @@ +//! Reading the camera's focus point for photos already in the library (issue #1480). +//! +//! Import reads a photo's focus point ([`lightcraft_meta::focus`]); photos imported before that have none. This job +//! fills them in, cheaply enough to run over a whole library on a spinning disk: it opens only the photos from a body +//! whose focus data is decoded (by the camera the catalog stores, so nothing else is opened), and reads only the few +//! kilobytes of each file that hold the focus data ([`lightcraft_meta::focus::read_header`]: about 32 KB of a 75 MB α1 +//! ARW), one file at a time on one worker thread. +//! +//! - **Commits** go in batches of [`COMMIT_EVERY`] photos (or every [`COMMIT_AFTER`]), as LightCraft's own bookkeeping +//! ([`Session::apply_system`]): no undo step, no sidecar write (the focus point is never written to XMP). Only +//! `meta.focus` changes, and only on a photo that still has none, still in the library at the same path. +//! - **"Looked, found nothing"** (manual focus, a centre-only Multi report) is kept beside the library in +//! `focus-scanned.bin` (photo ids, appended to, headed by the decoder's tag): it is cheap to append, never touches the +//! catalog, and a decoder that learns more bodies has another tag, so its record starts afresh and those photos are +//! looked at again. A photo whose file can't be read (offline drive, file gone or replaced mid-read) is not recorded: +//! it is tried again the next time the library opens. +//! - **Runs** off the UI thread with a row in the activity stack ("Reading focus points", n of N, ✕ stops it). The UI's +//! per-frame `focus.pump` gives it the pace: nothing new while the user drags, types or scrolls, a short rest between +//! files while they are around. +//! - **Starts** by itself once per library opening (from `focus.pump`) when photos are left to look at, and on demand +//! with `photo.readFocusPoints`. + +use std::collections::HashMap; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, AtomicU8, Ordering}; +use std::sync::mpsc; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use lightcraft_meta::focus::HeaderRead; +use serde_json::{Value, json}; + +use crate::activity::{Cancel, TaskGuard}; +use crate::faces_index::Scanned; +use crate::{Result, Session}; + +/// The record of photos looked at with nothing found, beside the library. +pub(crate) const RECORD_FILE: &str = "focus-scanned.bin"; +const RECORD_MAGIC: [u8; 4] = *b"LCFP"; +/// Photos committed at once. +pub(crate) const COMMIT_EVERY: usize = 200; +/// What has been read is committed at least this often, so points appear while a long run goes on. +const COMMIT_AFTER: Duration = Duration::from_secs(3); +/// The rest between files while the user is around (`light` pace): their own reads of the same disk go first. +const LIGHT_REST: Duration = Duration::from_millis(40); +/// How often a paused worker looks at its pace again. +const PAUSE_POLL: Duration = Duration::from_millis(50); +/// Results taken in per pump (the rest wait for the next frame). +const MAX_DRAIN: usize = 5000; + +/// The file extensions read (Sony bodies also shoot HEIF, which carries no maker note this decoder reads). +const EXTENSIONS: [&str; 3] = ["arw", "jpg", "jpeg"]; + +/// How hard the worker may work, from what the user is doing (`focus.pump`'s `pace`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u8)] +pub(crate) enum Pace { + Pause = 0, + Light = 1, + Normal = 2, +} + +impl Pace { + pub fn parse(name: Option<&str>) -> Pace { + match name { + Some("pause") => Pace::Pause, + Some("light") => Pace::Light, + _ => Pace::Normal, + } + } +} + +/// The focus point in the file at `path`, read from its header only. +pub fn read_focus(path: &str) -> std::result::Result<HeaderRead, String> { + use std::io::{Read, Seek, SeekFrom}; + let mut file = std::fs::File::open(path).map_err(|e| format!("{path}: {e}"))?; + let len = file.metadata().map_err(|e| format!("{path}: {e}"))?.len(); + lightcraft_meta::focus::read_header(len, &mut |off, buf| { + file.seek(SeekFrom::Start(off))?; + file.read_exact(buf) + }) + .map_err(|e| format!("{path}: {e}")) +} + +/// What the worker says about one photo. +enum Outcome { + /// The header was read: the point it holds, or none. + Read { id: PhotoId, focus: Option<FocusPoint>, bytes: u64 }, + /// The file couldn't be read (offline, gone, changed under the read): tried again another time. + Unavailable, +} + +/// What a run did (the last one is kept for `focus.status` and `photo.readFocusPoints {wait}`). +#[derive(Clone, Debug, Default, PartialEq)] +pub(crate) struct Summary { + pub total: u64, + pub done: u64, + pub found: u64, + pub none: u64, + pub unavailable: u64, + pub bytes: u64, + pub seconds: f64, + pub cancelled: bool, +} + +impl Summary { + fn json(&self) -> Value { + json!({ + "total": self.total, + "done": self.done, + "found": self.found, + "none": self.none, + "unavailable": self.unavailable, + "bytesRead": self.bytes, + "seconds": self.seconds, + "cancelled": self.cancelled, + }) + } +} + +struct Job { + identity: Arc<()>, + cancel: Arc<AtomicBool>, + pace: Arc<AtomicU8>, + rx: mpsc::Receiver<(Outcome, String)>, + /// Its row in the activity stack (dropping the job removes it). + guard: TaskGuard, + /// The path each photo was read at: a photo moved or relinked since is left for another run. + paths: HashMap<PhotoId, String>, + pending: Vec<(PhotoId, Option<FocusPoint>)>, + committed_at: Instant, + started: Instant, + summary: Summary, +} + +impl Drop for Job { + fn drop(&mut self) { + // the worker stops before its next file (and when it can't hand a result over) + self.cancel.store(true, Ordering::Relaxed); + } +} + +/// The backfill's state in the session. +pub(crate) struct FocusScan { + record: Scanned, + /// The library opening the record and the automatic start belong to. + library: Option<Arc<()>>, + auto_checked: bool, + job: Option<Job>, + last: Option<Summary>, + /// The pace the UI last gave (a job started between two pumps starts at it). + pace: Pace, +} + +impl Default for FocusScan { + fn default() -> Self { + FocusScan { + record: Scanned::new(RECORD_MAGIC, &lightcraft_meta::focus::decoder_tag()), + library: None, + auto_checked: false, + job: None, + last: None, + pace: Pace::Normal, + } + } +} + +/// Follow the session's library: another one (or none) drops a running job (its results belong to the old library and +/// are read again there next time) and loads that library's record. +fn follow_library(s: &mut Session) { + if s.focus_scan.library.as_ref().is_some_and(|l| Arc::ptr_eq(l, &s.library_identity)) { + return; + } + s.focus_scan.job = None; + s.focus_scan.auto_checked = false; + let path = s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.join(RECORD_FILE)); + s.focus_scan.record.reset(path); + s.focus_scan.library = Some(Arc::clone(&s.library_identity)); +} + +/// The photos to read: in the library, no focus point yet, from a body whose focus data is decoded, a raw or JPEG file; +/// with `ids`, those of them; without, every one not already looked at. In path order (neighbours on disk together). +fn candidates(s: &Session, ids: Option<&[PhotoId]>) -> Vec<(PhotoId, String)> { + let wanted = |ph: &lightcraft_catalog::Photo| -> Option<(PhotoId, String)> { + if !ph.in_library() || ph.meta.focus.is_some() || !lightcraft_meta::focus::camera_supported(&ph.meta.camera) { + return None; + } + let Source::File { path } = &ph.source else { return None }; + let ext = std::path::Path::new(path).extension()?.to_str()?.to_ascii_lowercase(); + EXTENSIONS.contains(&ext.as_str()).then(|| (ph.id, path.clone())) + }; + let mut out: Vec<(PhotoId, String)> = match ids { + Some(ids) => ids.iter().filter_map(|id| s.catalog.photo(*id)).filter_map(|ph| wanted(ph)).collect(), + None => s.catalog.photos().filter(|ph| !s.focus_scan.record.contains(ph.id.0)).filter_map(|ph| wanted(ph)).collect(), + }; + out.sort_by(|a, b| a.1.cmp(&b.1)); + out.dedup_by_key(|c| c.0); + out +} + +/// Start reading `list` on a worker thread. +fn start(s: &mut Session, list: Vec<(PhotoId, String)>, pace: Pace) -> std::result::Result<(), String> { + let cancel = Arc::new(AtomicBool::new(false)); + let pace = Arc::new(AtomicU8::new(pace as u8)); + let (tx, rx) = mpsc::channel(); + let paths: HashMap<PhotoId, String> = list.iter().cloned().collect(); + let total = list.len() as u64; + let (worker_cancel, worker_pace) = (cancel.clone(), pace.clone()); + std::thread::Builder::new() + .name("focus-points".into()) + .spawn(move || { + for (id, path) in list { + loop { + if worker_cancel.load(Ordering::Relaxed) { + return; + } + if worker_pace.load(Ordering::Relaxed) != Pace::Pause as u8 { + break; + } + std::thread::sleep(PAUSE_POLL); + } + if worker_pace.load(Ordering::Relaxed) == Pace::Light as u8 { + std::thread::sleep(LIGHT_REST); + } + // a decoder bug in one file is that file's problem, not the job's end + let outcome = match crate::guard::catch("reading a focus point", || read_focus(&path)) { + Ok(Ok(r)) => Outcome::Read { id, focus: r.focus, bytes: r.bytes_read }, + Ok(Err(e)) => { + log::debug!("focus point: {e}"); + Outcome::Unavailable + } + Err(_) => Outcome::Unavailable, + }; + let name = std::path::Path::new(&path).file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + if tx.send((outcome, name)).is_err() { + return; + } + } + }) + .map_err(|e| format!("could not start reading focus points: {e}"))?; + let guard = s.activity.start("focus", "Reading focus points", Cancel::Flag(cancel.clone())); + guard.progress(0, total); + let now = Instant::now(); + s.focus_scan.job = Some(Job { + identity: Arc::clone(&s.library_identity), + cancel, + pace, + rx, + guard, + paths, + pending: Vec::new(), + committed_at: now, + started: now, + summary: Summary { total, ..Default::default() }, + }); + Ok(()) +} + +/// Commit what has been read: the points found (one system op, no undo step, no sidecar write) and the photos with none +/// (to the record). +fn commit(s: &mut Session) { + let Some(job) = s.focus_scan.job.as_mut() else { return }; + let pending = std::mem::take(&mut job.pending); + job.committed_at = Instant::now(); + if !Arc::ptr_eq(&job.identity, &s.library_identity) { + return; + } + let mut ops = Vec::new(); + let mut none = Vec::new(); + for (id, focus) in pending { + let Some(ph) = s.catalog.photo(id) else { continue }; + let same_file = matches!((&ph.source, job.paths.get(&id)), (Source::File { path }, Some(read)) if path == read); + // deleted, moved, relinked or given a point meanwhile: leave it + if !same_file || !ph.in_library() || ph.meta.focus.is_some() { + continue; + } + match focus { + Some(f) => { + let mut meta = ph.meta.clone(); + meta.focus = Some(f); + ops.push(Op::SetMeta { id, meta: Box::new(meta) }); + } + None => none.push(id.0), + } + } + for id in none { + s.focus_scan.record.insert(id); + } + if let Err(e) = s.focus_scan.record.save() { + log::warn!("could not save the list of photos read for focus points: {e}"); + } + if ops.is_empty() { + return; + } + match s.apply_system(Op::Batch { ops }) { + // the focus point is never written to XMP: no sidecar is rewritten for it + Ok(()) => s.skip_auto_write = true, + Err(e) => log::warn!("could not store focus points: {e}"), + } +} + +/// Take in what the worker has read, commit when a batch is due, and end the job when it is done or cancelled. +/// Returns whether a job is still running. +fn step(s: &mut Session, pace: Pace) -> bool { + let Some(job) = s.focus_scan.job.as_mut() else { return false }; + job.pace.store(pace as u8, Ordering::Relaxed); + let mut finished = job.cancel.load(Ordering::Relaxed); + let mut last_name = None; + for _ in 0..MAX_DRAIN { + match job.rx.try_recv() { + Ok((outcome, name)) => { + job.summary.done += 1; + last_name = Some(name); + match outcome { + Outcome::Read { id, focus, bytes } => { + job.summary.bytes = job.summary.bytes.saturating_add(bytes); + if focus.is_some() { + job.summary.found += 1; + } else { + job.summary.none += 1; + } + job.pending.push((id, focus)); + } + Outcome::Unavailable => job.summary.unavailable += 1, + } + } + Err(mpsc::TryRecvError::Empty) => break, + // the worker ended (all read, cancelled, or it could not go on) + Err(mpsc::TryRecvError::Disconnected) => { + finished = true; + break; + } + } + } + finished |= job.summary.done >= job.summary.total; + job.guard.progress(job.summary.done, job.summary.total); + if let Some(name) = last_name { + job.guard.detail(&name); + } + if finished || job.pending.len() >= COMMIT_EVERY || (!job.pending.is_empty() && job.committed_at.elapsed() >= COMMIT_AFTER) { + commit(s); + } + if finished && let Some(job) = s.focus_scan.job.take() { + let mut summary = job.summary.clone(); + summary.cancelled = job.cancel.load(Ordering::Relaxed) && summary.done < summary.total; + summary.seconds = job.started.elapsed().as_secs_f64(); + log::info!( + "focus points: read {} of {} photos ({} found, {} without, {} unavailable, {} KB) in {:.1} s", + summary.done, + summary.total, + summary.found, + summary.none, + summary.unavailable, + summary.bytes / 1024, + summary.seconds + ); + s.focus_scan.last = Some(summary); + return false; + } + true +} + +fn status(s: &Session) -> Value { + let running = s.focus_scan.job.as_ref().map(|j| json!({"done": j.summary.done, "total": j.summary.total, "found": j.summary.found})); + json!({"active": running.is_some(), "running": running, "last": s.focus_scan.last.as_ref().map(Summary::json), "lookedAt": s.focus_scan.record.len()}) +} + +/// `focus.pump {pace?}`: cheap to call every frame. +pub(crate) fn pump(s: &mut Session, p: &Value) -> Result<Value> { + follow_library(s); + let pace = Pace::parse(p.get("pace").and_then(Value::as_str)); + s.focus_scan.pace = pace; + if !step(s, pace) && !s.focus_scan.auto_checked { + // once per library opening: start by itself when photos are left to look at (a few KB each) + s.focus_scan.auto_checked = true; + let list = candidates(s, None); + if !list.is_empty() + && let Err(e) = start(s, list, pace) + { + log::warn!("{e}"); + } + } + Ok(status(s)) +} + +/// `photo.readFocusPoints {ids?, wait?}`. +pub(crate) fn read_points(s: &mut Session, p: &Value) -> Result<Value> { + const C: &str = "photo.readFocusPoints"; + follow_library(s); + if s.focus_scan.job.is_some() { + return Ok(json!({"queued": 0, "running": true, "status": status(s)})); + } + let ids: Option<Vec<PhotoId>> = match p.get("ids") { + None | Some(Value::Null) => None, + Some(v) => Some( + v.as_array() + .ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))? + .iter() + .map(|v| v.as_u64().map(PhotoId).ok_or_else(|| crate::cmd::bad(C, "`ids` must be a list of photo ids"))) + .collect::<Result<Vec<_>>>()?, + ), + }; + let list = candidates(s, ids.as_deref()); + let queued = list.len(); + if queued == 0 { + return Ok(json!({"queued": 0, "running": false})); + } + let wait = p.get("wait").and_then(Value::as_bool) == Some(true); + start(s, list, if wait { Pace::Normal } else { s.focus_scan.pace }).map_err(|e| crate::cmd::bad(C, e))?; + // a run asked for counts as this library opening's automatic one + s.focus_scan.auto_checked = true; + if !wait { + return Ok(json!({"queued": queued, "running": true})); + } + // a script's call: read them all now (the batches still commit as they come) + while step(s, Pace::Normal) { + std::thread::sleep(Duration::from_millis(5)); + } + let last = s.focus_scan.last.as_ref().map(Summary::json).unwrap_or(Value::Null); + Ok(json!({"queued": queued, "running": false, "last": last})) +} + +#[cfg(test)] +pub(crate) fn record_len(s: &Session) -> usize { + s.focus_scan.record.len() +} + +#[cfg(test)] +pub(crate) fn record_contains(s: &Session, id: PhotoId) -> bool { + s.focus_scan.record.contains(id.0) +} diff --git a/crates/engine/src/import.rs b/crates/engine/src/import.rs index ecb80741e..9aa847b01 100644 --- a/crates/engine/src/import.rs +++ b/crates/engine/src/import.rs @@ -39,7 +39,7 @@ pub const EXTENSIONS: &[&str] = &[ // Decoded when built with the codecs' `heif` feature; otherwise the import reports them as // failed with the reason ("HEIC/HEIF support isn't included in this build"). "heic", "heif", "avif", // no AV1 decoder yet: reported as failed, like a HEIC without the feature - "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", + "iiq", "crw", "mrw", "x3f", "kdc", "mos", "erf", "3fr", "fff", "mef", ]; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] @@ -310,6 +310,8 @@ pub struct ImportDefaults { pub auto_folder: Option<String>, pub auto_copy: bool, pub auto_album: Option<String>, + /// Open the import review when a camera or memory card is connected. + pub show_on_card: bool, } #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] @@ -380,6 +382,17 @@ pub fn expand(paths: &[String], skip: Option<&Path>) -> Vec<String> { /// [`expand`] with the walk bounded by `limits` → (files, whether a walk stopped at a bound). pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk::Limits) -> (Vec<String>, bool) { + expand_noting(paths, skip, limits, None) +} + +/// [`expand_within`], handing the listing of every folder that is walked to `note` +/// ([`crate::walk::files_in_noting`]). +pub(crate) fn expand_noting( + paths: &[String], + skip: Option<&Path>, + limits: crate::walk::Limits, + mut note: Option<crate::walk::NoteFolder>, +) -> (Vec<String>, bool) { let mut out = Vec::new(); let mut truncated = false; for p in paths { @@ -388,7 +401,10 @@ pub fn expand_within(paths: &[String], skip: Option<&Path>, limits: crate::walk: continue; } if p.is_dir() { - let w = crate::walk::files_in(p, skip, limits, is_supported); + let w = match note.as_mut() { + Some(note) => crate::walk::files_in_noting(p, skip, limits, is_supported, &mut **note), + None => crate::walk::files_in(p, skip, limits, is_supported), + }; if w.truncated { log::warn!( "import: stopped looking in {} after {} entries (limits: {} entries, {} folder levels)", @@ -470,6 +486,12 @@ pub(crate) fn probe_paths(s: &Session, paths: &[String]) -> Vec<Result<ProbeInfo probe_all(s.media.file_probe.as_ref(), paths, &progress.done, &progress.cancel) } +/// Probe files with `probe` (a clone of the session's file probe), off the session: on a worker thread. +pub(crate) fn probe_with(probe: &crate::media::FileProbe, paths: &[String]) -> Vec<Result<ProbeInfo, String>> { + let progress = ScanProgress::default(); + probe_all(Some(probe), paths, &progress.done, &progress.cancel) +} + /// Probe `paths` (several at once), counting each in `done`. Stops before the next file once /// `cancel` is set: the files not reached come back as errors. fn probe_all( @@ -630,6 +652,11 @@ impl ScanInput { (input, paths.to_vec()) } + /// The folder a scan never enters (the library's own). + pub(crate) fn skip(&self) -> Option<&Path> { + self.skip.as_deref() + } + /// The library as the scan sees it (shared, for [`crate::sync`], which looks at the same one). pub(crate) fn library(&self) -> std::sync::Arc<lightcraft_catalog::Catalog> { std::sync::Arc::clone(&self.library) @@ -648,9 +675,14 @@ pub fn scan(s: &mut Session, paths: &[String]) -> Vec<ImportCandidate> { /// [`scan`] without the session, so it can run on a worker thread. Stops early (returning what it /// has) when `progress.cancel` is set. -pub fn scan_with(mut input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { - use std::sync::atomic::Ordering::Relaxed; +pub fn scan_with(input: ScanInput, paths: &[String], progress: &ScanProgress) -> ScanOutput { let files = expand(paths, input.skip.as_deref()); + scan_files(input, files, progress) +} + +/// [`scan_with`] for files that were found already (`paths` expanded, [`expand`]). +pub(crate) fn scan_files(mut input: ScanInput, files: Vec<String>, progress: &ScanProgress) -> ScanOutput { + use std::sync::atomic::Ordering::Relaxed; let by_path = library_files(&input.library, &files); let todo: Vec<String> = files.iter().filter(|f| !by_path.contains_key(Path::new(f))).cloned().collect(); progress.total.store(todo.len(), Relaxed); @@ -1386,7 +1418,7 @@ pub fn commit_prepared(s: &mut Session, opts: &ImportOptions, now: &str, prepare p.as_shot_wb = info.as_shot_wb; p.content_hash = info.content_hash; p.embedded_lens = info.embedded_lens; - p.preview_only = info.preview_only.clone(); + p.set_preview_only(info.preview_only.clone()); apply_import_defaults(s, &mut p); if let Some(sc) = &sidecar { crate::sidecar::merge_into(&mut p, sc, &now); @@ -1532,6 +1564,7 @@ impl Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }; let embedded = match (&self.media.preview_loader, c.kind) { diff --git a/crates/engine/src/import_move.rs b/crates/engine/src/import_move.rs index c92ee2796..06f22be40 100644 --- a/crates/engine/src/import_move.rs +++ b/crates/engine/src/import_move.rs @@ -128,7 +128,10 @@ pub(crate) fn sidecars_of(src: &Path) -> Vec<(PathBuf, bool)> { if !ext.eq_ignore_ascii_case("xmp") || !e.path().is_file() { return None; } - if base == name { + // `IMG_1.cr3.xmp` names `IMG_1.CR3` too: some apps lower-case the photo's extension + let full = base == name + || base.rsplit_once('.').is_some_and(|(s, x)| s == stem && name.rsplit_once('.').is_some_and(|(_, y)| x.eq_ignore_ascii_case(y))); + if full { Some((e.path(), false)) } else if base == stem { Some((e.path(), true)) diff --git a/crates/engine/src/lens_profiles.rs b/crates/engine/src/lens_profiles.rs index 1894f7189..6338b3b40 100644 --- a/crates/engine/src/lens_profiles.rs +++ b/crates/engine/src/lens_profiles.rs @@ -64,8 +64,7 @@ fn frame_crop_factor(camera: Option<f64>, focal: f64, focal_35mm: Option<f64>) - pub fn for_raw(raw: &lightcraft_raw::RawInfo, meta: impl FnOnce() -> lightcraft_meta::Metadata) -> Option<EmbeddedLens> { crate::files::embedded_lens(raw).or_else(|| { let db = database()?; - let c = raw.crop.clipped(raw.active_area.width, raw.active_area.height); - let (w, h) = if c.width == 0 || c.height == 0 { (raw.active_area.width, raw.active_area.height) } else { (c.width, c.height) }; + let (w, h) = raw.developed_size(); lookup(db, &meta(), w as f64, h as f64, raw.orientation) }) } diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 7e05fcffd..25e6ef52f 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -29,6 +29,7 @@ pub mod demo; pub mod denoise; pub mod devices; pub mod display; +pub mod doc_path; mod errors; pub mod export; pub mod face_download; @@ -38,6 +39,8 @@ mod faces_index; #[cfg(not(target_arch = "wasm32"))] mod faces_worker; pub mod files; +#[cfg(not(target_arch = "wasm32"))] +mod focus_scan; pub mod fonts; pub mod guard; pub mod import; @@ -54,16 +57,29 @@ pub mod media; pub mod memory; pub mod merge; mod model_download; +pub mod open_path; pub mod originals; +mod pdf; pub mod preset_import; pub mod preset_luminar; pub mod presets; +pub mod print; +pub mod print_color; +pub mod print_pdf; +pub mod profile_refs; pub mod proof; pub mod rename; pub mod segment; pub mod sidecar; +pub mod slide; +pub mod slide_overlay; +pub mod slide_template; +pub mod slide_title; +pub mod slideshow; +pub mod slideshow_export; pub mod smart; pub mod sync; +pub mod text; mod view; pub mod walk; @@ -172,6 +188,9 @@ pub struct Interaction { pub original: Arc<DevelopSettings>, } +/// The most photos an edit may touch for the view to follow it photo by photo; a larger edit is worked out whole. +const MAX_FOLLOWED: usize = 256; + /// Source of [`Session::visible_shared`] generations (process-wide, so two sessions never share one). static VISIBLE_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); static LIBRARY_GEN: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1); @@ -190,6 +209,21 @@ pub struct Session { /// Shared, so frontends can hold it across frames without copying ([`Session::visible_shared`]). visible: Arc<[PhotoId]>, visible_key: Option<(u64, String)>, + /// A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop + /// matching a smart album's rules, Picks or the filter bar leaves it where it is. Off: the photo goes at once. + pub hold_view: bool, + /// The view last worked out, the catalog's revision then, and the photos in it before stacks are arranged. + /// A small edit is followed from it: only the photos the edit touched are asked about again and put at their + /// place in the order ([`Session::visible`]). It is also what a held view holds ([`Session::hold_view`]). + /// Another source, filter or sort is another view, and so is a smart album whose rules were changed. + base: Option<(String, u64, Vec<PhotoId>)>, + /// How many times the view followed an edit from the list it had, and how many times it was worked out from + /// every photo (`ui.inspect` → `perf`): what tells a slow library from a slow edit. + pub view_followed: u64, + pub view_worked_out: u64, + /// Tests: work the view out whole every time (what following has to equal). + #[cfg(test)] + never_follow: bool, /// Identifies the current `visible` list: a new value (unique in the process) every time it /// is recomputed, so frontends can key their per-view caches on it instead of hashing the ids. visible_gen: u64, @@ -206,6 +240,9 @@ pub struct Session { /// Set by a command whose change must not rewrite the photo's XMP sidecar even with auto-write on /// (a catalog-only edit of data the sidecar writer does not emit); consumed when the command ends. pub(crate) skip_auto_write: bool, + /// Photos whose automatic XMP sidecar write failed (their folder offline, read-only or full), written again by + /// [`Session::retry_unsaved_sidecars`] once their file is reachable. + pub(crate) unsaved_sidecars: std::collections::BTreeSet<PhotoId>, /// AI denoise: the model in use, the photos that have their picture and the work in progress. pub(crate) denoise: denoise::State, /// Where the host keeps face models (one folder each); `None` where there is no file system (the web). @@ -219,6 +256,9 @@ pub struct Session { /// The loaded recognition model and the face embeddings made with it. #[cfg(not(target_arch = "wasm32"))] pub(crate) faces: faces_index::FacesState, + /// Reading focus points for photos imported before they were read (`photo.readFocusPoints`, `focus.pump`). + #[cfg(not(target_arch = "wasm32"))] + pub(crate) focus_scan: focus_scan::FocusScan, /// Copied develop settings (partial JSON) for Paste. pub clipboard: Option<Value>, /// The folder on disk the [`LibrarySource::Folder`] view browses. @@ -260,8 +300,17 @@ pub struct Session { pub xmp: sidecar::XmpPrefs, /// Parameters of the last export (`app.export` params, minus targets), persisted in prefs.json. pub last_export: Option<serde_json::Value>, + /// The options of the last Photo Merge started, per command (`merge.hdr`, `merge.panorama`, + /// `merge.hdrPanorama`): the merge dialog opens with them and "… with Last Settings" uses + /// them. Persisted in prefs.json. + pub last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets (built-ins: [`export::builtin_presets`]), persisted in prefs.json. pub export_presets: Vec<export::ExportPreset>, + /// The Print module's current layout, image settings, page, guides and job (`print.*`). Lives + /// with the session, so the app, CLI, control channel and MCP all see the same print. + pub print: print::template::PrintSettings, + /// Saved print templates (`print.template.*`), persisted in prefs.json. + pub print_templates: Vec<print::template::PrintTemplate>, /// Metadata presets (`metadata.*`), persisted in prefs.json. pub metadata_presets: Vec<cmd::metadata::MetadataPreset>, /// Saved filter-bar settings (`filter.*`), persisted in prefs.json. @@ -269,6 +318,11 @@ pub struct Session { /// The user's point-curve presets (`curve.*`; built-ins: [`cmd::curves::builtin_presets`]), /// persisted in prefs.json. pub curve_presets: Vec<cmd::curves::CurvePreset>, + /// Mask effect presets (the Masking panel's Effect menu). + pub mask_effects: Vec<cmd::mask_effects::MaskEffect>, + /// The effect a new mask of each kind (`brush`, `linear`…) starts with: the one last applied + /// to a mask of that kind. + pub mask_tool_effects: std::collections::BTreeMap<String, String>, /// Saved colour-label name sets. pub label_sets: Vec<cmd::manage::LabelSet>, /// Look-alike signatures by content key (Find Similar). @@ -296,12 +350,33 @@ pub struct Session { /// Background tasks in flight (imports, exports, preview builds…), for the activity stack and /// `activity.list` / `activity.cancel`. pub activity: activity::Activity, + /// The last (or running) slideshow export (`slideshow.export`). + pub slideshow_export: Option<std::sync::Arc<slideshow_export::ExportRun>>, /// The last (or running) Build Previews. pub preview_build: Option<std::sync::Arc<cmd::previews::PreviewBuild>>, /// Develop defaults applied on import (persisted in prefs.json). pub import_defaults: import::ImportDefaults, /// Disk budget of the library's thumbnail cache in MB (0 = default; persisted in prefs.json). pub cache_mb: u32, + /// Slideshow playback settings (`slideshow.playback`; persisted in prefs.json). + pub slideshow: slideshow::PlaybackSettings, + /// Slideshow slide layout: Layout, Options and Backdrop (`slideshow.layout`; persisted in + /// prefs.json). + pub slide_layout: slide::SlideSettings, + /// Slideshow overlays (identity plate, rating stars, text overlays, watermark). + pub slide_overlays: slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens (`slideshow.titles`; persisted in prefs.json). + pub slide_titles: slide_title::TitleSettings, + /// Where user slideshow templates are kept (`<config>/slideshow-templates`; + /// [`Session::with_default_slide_templates`]). `None`: the built-in templates only. + pub slide_templates_dir: Option<std::path::PathBuf>, + /// The slideshow settings before the last template was applied ("Previous settings"; this + /// session only: library preferences have no undo). + pub slide_templates_previous: Option<slide_template::SlideLook>, + /// The templates as last listed. + pub(crate) slide_templates_cache: slide_template::Cache, + /// The background image the slides last rendered (shared by slide jobs: rendered once). + pub slide_backdrop: std::sync::Arc<slide::BackdropCache>, /// Where smart previews are kept when not in the library folder (persisted in prefs.json). pub smart_previews_dir: Option<std::path::PathBuf>, /// Untouched Local records of folders not browsed for this many days are forgotten when the @@ -345,6 +420,12 @@ impl Session { selection: Selection::default(), visible: Vec::new().into(), visible_key: None, + hold_view: false, + base: None, + view_followed: 0, + view_worked_out: 0, + #[cfg(test)] + never_follow: false, visible_gen: 0, total: None, undo: Vec::new(), @@ -353,6 +434,7 @@ impl Session { pending_sidecar_repairs: Vec::new(), interaction: None, skip_auto_write: false, + unsaved_sidecars: Default::default(), denoise: Default::default(), face_models_dir: None, face_downloads: Default::default(), @@ -360,6 +442,8 @@ impl Session { face_catalog: Default::default(), #[cfg(not(target_arch = "wasm32"))] faces: Default::default(), + #[cfg(not(target_arch = "wasm32"))] + focus_scan: Default::default(), clipboard: None, meta_clipboard: None, browse: None, @@ -381,10 +465,15 @@ impl Session { library_identity: Arc::new(()), xmp: sidecar::XmpPrefs::default(), last_export: None, + last_merge: Default::default(), export_presets: Vec::new(), + print: Default::default(), + print_templates: Vec::new(), metadata_presets: Vec::new(), filter_presets: Vec::new(), curve_presets: Vec::new(), + mask_effects: Vec::new(), + mask_tool_effects: Default::default(), label_sets: Vec::new(), signatures: Default::default(), lut_profiles: Vec::new(), @@ -398,8 +487,17 @@ impl Session { folder_changes: None, activity: Default::default(), preview_build: None, + slideshow_export: None, import_defaults: import::ImportDefaults::default(), cache_mb: 0, + slideshow: slideshow::PlaybackSettings::default(), + slide_layout: slide::SlideSettings::default(), + slide_overlays: slide_overlay::OverlaySettings::default(), + slide_titles: slide_title::TitleSettings::default(), + slide_templates_dir: None, + slide_templates_previous: None, + slide_templates_cache: None, + slide_backdrop: Default::default(), smart_previews_dir: None, forget_local_days: lightcraft_catalog::DEFAULT_FORGET_DAYS, #[cfg(not(target_arch = "wasm32"))] @@ -427,7 +525,11 @@ impl Session { } let empty = Value::Object(Default::default()); let params = if params.is_null() { &empty } else { params }; - self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)) + self.run_command(id, spec.journal.then_some(params), |s| (spec.run)(s, params)).map_err(|e| match e { + // a shared parameter check ([`Session::targets`]) names the command it ran for + EngineError::BadParams { cmd, msg } if cmd.is_empty() => EngineError::BadParams { cmd: id.to_string(), msg }, + e => e, + }) } /// Run `f` as command `id` with what [`Session::execute`] does around every command: the @@ -506,16 +608,34 @@ impl Session { } fn apply_catalog(&mut self, op: Op) -> Result<Op> { - let inv = self.catalog.apply(op.clone())?; + // Retain only cache IDs while the catalog consumes the operation. Cloning an import + // batch here also cloned every photo, although commit already keeps the journal copy. + let mut invalidated = Vec::new(); + // photos whose file was replaced: the faces made from the old pixels go too (issue #1524) + let mut replaced = Vec::new(); let mut pending = vec![&op]; while let Some(op) = pending.pop() { match op { - Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::SetContent { id, .. } | Op::RemovePhoto { id } => self.media.forget(*id), - Op::AddPhoto { photo } => self.media.forget(photo.id), + Op::SetContent { id, .. } => { + invalidated.push(*id); + replaced.push(*id); + } + Op::SetFile { id, .. } | Op::Relink { id, .. } | Op::RemovePhoto { id } => invalidated.push(*id), + Op::AddPhoto { photo } => invalidated.push(photo.id), Op::Batch { ops } => pending.extend(ops), _ => {} } } + let inv = self.catalog.apply(op)?; + for id in invalidated { + self.media.forget(id); + } + #[cfg(not(target_arch = "wasm32"))] + for id in replaced { + self.faces.content_replaced(id); + } + #[cfg(target_arch = "wasm32")] + let _ = replaced; Ok(inv) } @@ -541,8 +661,8 @@ impl Session { } /// Apply an op that is LightCraft's own bookkeeping rather than something the user did (faces found by the background - /// scan, a raw's as-shot white read by a decode): journaled like any op, but not an undo step, and it leaves the - /// redo stack alone. + /// scan, a raw's as-shot white read by a decode, raws rechecked after an update): journaled like any op, but not + /// an undo step, and it leaves the redo stack alone. pub(crate) fn apply_system(&mut self, op: Op) -> Result<()> { let fwd = op.clone(); self.catalog.apply(op)?; @@ -853,6 +973,16 @@ impl Session { pub fn visible(&mut self) -> &[PhotoId] { let mut key = (self.catalog.revision, format!("{:?}|{:?}|{:?}|{:?}|{:?}", self.source, self.filter, self.sort, self.browse, self.library_folder)); + // What a held view is told apart by: what is shown and how, and for a smart album its rules (rules that were + // changed ask something else). Not the checks of Missing Photos below: they fill the view in, they are + // no other view. + let asks = matches!(self.source, LibrarySource::Picks) + || matches!(self.source, LibrarySource::Album(a) if self.catalog.album(a).is_some_and(|al| al.is_smart())); + let rules = match self.source { + LibrarySource::Album(a) if asks => self.catalog.album(a).map(|al| format!("{:?}", al.smart)).unwrap_or_default(), + _ => String::new(), + }; + let view = format!("{}|{rules}", key.1); if self.source == LibrarySource::Missing && self.media.availability.is_background() { // the view fills in as the background checks find files gone key.1.push_str(&format!("|{}", self.media.availability.generation())); @@ -860,18 +990,58 @@ impl Session { if self.visible_key.as_ref() != Some(&key) { // "in the last N days" rules count back from the session's clock lightcraft_catalog::rules::set_now(Some((self.clock)())); - let mut f = self.source.to_filter(&self.filter, &self.catalog); - if self.source == LibrarySource::Folder { - // no folder chosen: nothing (an empty path matches nothing) - let b = self.browse.clone().unwrap_or_default(); - f.folder = Some(b.path); - f.subfolders = b.subfolders; + let f = self.view_filter(self.source, &self.filter); + // A held view keeps the photos it shows that stopped matching what is asked of them (a smart album's + // rules, a flag, the filter bar), as long as they are still where the view looks: in the library for + // a view that is only a question, else in the album or folder shown. Taking a photo out of an album + // or the library takes it out of the view at once, held or not. + // (only a view that asks something can lose a photo to an edit: the others are queried as ever, at no + // cost for the hold) + let holds = self.hold_view && (asks || self.filter != Filter::default()); + let within = holds.then(|| { + if asks { + self.view_filter(LibrarySource::All, &Filter::default()) + } else if self.source == LibrarySource::RecentlyAdded { + // (how far back it reaches was worked out for `f`: not a second pass over the library) + Filter { imported_from: f.imported_from.clone(), ..Default::default() } + } else { + self.view_filter(self.source, &Filter::default()) + } + }); + // Views whose order or photos are not the query's alone are worked out whole each time: an album in + // its own order, Recently Added (how far back it reaches moves with every import), Missing Photos. + let own_order = matches!(self.source, LibrarySource::Album(a) if self.sort.key == lightcraft_catalog::SortKey::CaptureDate + && self.filter == Filter::default() + && self.catalog.album(a).is_some_and(|al| !al.is_smart())); + let whole = own_order || matches!(self.source, LibrarySource::RecentlyAdded | LibrarySource::Missing); + // A view that asks about "the last N days" (itself, or through a smart album it looks into) changes + // with the clock as well as with edits: a photo nobody touched can stop matching. It is asked whole. + let clocked = f.depends_on_now() + || (!f.albums_tested().is_empty() && self.catalog.albums().any(|al| al.smart.as_deref().is_some_and(Filter::depends_on_now))); + let base = self.base.take().filter(|(of, _, _)| *of == view); + // A small edit to the same view: the photos it touched are taken out, asked about again, and put back + // at their place in the order. The others neither stopped matching nor moved. + #[cfg(test)] + let clocked = clocked || self.never_follow; + let followed = base.as_ref().filter(|_| !whole && !clocked).and_then(|(_, revision, ids)| { + let touched = self.catalog.touched_since(*revision).filter(|touched| touched.len() <= MAX_FOLLOWED)?; + Some(self.catalog.follow(ids, &touched, &f, &self.sort, within.as_ref())) + }); + match followed { + Some(_) => self.view_followed += 1, + None => self.view_worked_out += 1, } - if self.source == LibrarySource::LibraryFolder { - // no folder chosen: nothing (`.` names no folder) - f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); - } - let mut visible = self.catalog.query(&f, &self.sort); + let mut visible = match (followed, &base, &within) { + (Some(ids), _, _) => ids, + (None, Some((_, _, held)), Some(within)) => { + let keep: std::collections::HashSet<PhotoId> = held.iter().copied().collect(); + self.catalog.query_keeping(&f, &self.sort, &keep, within) + } + _ => self.catalog.query(&f, &self.sort), + }; + // what the view shows now is what the next edit is followed from, and what it holds until it is + // refreshed (taken before stacks are arranged, so a photo folded into a stack is held with it) + self.base = (!whole || holds).then(|| (view, self.catalog.revision, visible.clone())); if visible.is_empty() && self.source == LibrarySource::LibraryFolder && !self.folder_holds_photos() { // the shown folder lost its last photo (deleted, moved, removed): everything, not // an empty grid under the name of a folder that is gone from the sidebar @@ -886,8 +1056,12 @@ impl Session { && !al.is_smart() && self.filter == Filter::default() { - let order = al.photos.clone(); - visible.sort_by_key(|id| order.iter().position(|x| x == id).unwrap_or(usize::MAX)); + // each photo's place looked up, not searched for (a big album sorted in O(n log n)) + let mut place: std::collections::HashMap<PhotoId, usize> = std::collections::HashMap::with_capacity(al.photos.len()); + for (i, id) in al.photos.iter().enumerate() { + place.entry(*id).or_insert(i); + } + visible.sort_by_key(|id| place.get(id).copied().unwrap_or(usize::MAX)); if !self.sort.ascending { visible.reverse(); } @@ -921,6 +1095,43 @@ impl Session { &self.visible } + /// `source` as a filter over `base` (the filter bar's, or none), with the folder it shows. + fn view_filter(&self, source: LibrarySource, base: &Filter) -> Filter { + let mut f = source.to_filter(base, &self.catalog); + if source == LibrarySource::Folder { + // no folder chosen: nothing (an empty path matches nothing) + let b = self.browse.clone().unwrap_or_default(); + f.folder = Some(b.path); + f.subfolders = b.subfolders; + } + if source == LibrarySource::LibraryFolder { + // no folder chosen: nothing (`.` names no folder) + f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); + } + f + } + + /// Let go of what a held view kept: the view shows what matches now ([`Session::hold_view`]). + pub fn refresh_view(&mut self) { + self.base = None; + self.visible_key = None; + } + + /// [`Session::visible`] worked out from every photo, whatever was followed before: what following an edit + /// has to equal. + /// Nothing the session keeps is changed by asking: the next edit is followed from where the last one left the + /// view, so a list followed many times over is compared, not only one followed once. + #[cfg(test)] + pub(crate) fn visible_in_full(&mut self) -> Vec<PhotoId> { + let kept = (self.base.clone(), self.visible_key.clone(), self.visible.clone(), self.visible_gen, self.view_followed, self.view_worked_out); + self.never_follow = true; + self.visible_key = None; + let whole = self.visible().to_vec(); + self.never_follow = false; + (self.base, self.visible_key, self.visible, self.visible_gen, self.view_followed, self.view_worked_out) = kept; + whole + } + /// Whether the library still holds a photo imported from the folder a `LibraryFolder` source /// shows. fn folder_holds_photos(&self) -> bool { @@ -934,6 +1145,16 @@ impl Session { if self.source == LibrarySource::Missing { return None; } + if self.filter == Filter::default() && !self.hold_view { + // nothing narrows the source: what the view lists is all of it, and is counted already (not a view + // that holds photos that stopped matching: those are not of the source any more) + let _ = self.visible(); + if let Some((_, revision, ids)) = &self.base + && *revision == self.catalog.revision + { + return Some(ids.len()); + } + } let key = (self.catalog.revision, format!("{:?}|{:?}|{:?}", self.source, self.browse, self.library_folder)); if self.total.as_ref().map(|t| &t.0) != Some(&key) { let mut f = self.source.to_filter(&Filter::default(), &self.catalog); @@ -945,7 +1166,8 @@ impl Session { if self.source == LibrarySource::LibraryFolder { f.library_folder = Some(self.library_folder.clone().unwrap_or_else(|| ".".into())); } - let n = self.catalog.query(&f, &self.sort).len(); + // (how many, not which: nothing to put in order) + let n = self.catalog.count(&f); self.total = Some((key, n)); } self.total.as_ref().map(|t| t.1) @@ -955,6 +1177,21 @@ impl Session { self.visible().to_vec() } + /// After the source changes, the active photo follows the rule every source shares: it stays + /// when the view shows it, else the view's first photo becomes the active (and only selected) + /// one, or nothing when the view is empty. A view never keeps an active photo it doesn't show + /// (issue #1447: a Local folder kept a library photo active, so Detail and the info showed a + /// photo the grid didn't). + pub fn keep_active_in_view(&mut self) { + let active = self.selection.active; + let vis = self.visible(); + if active.is_some_and(|a| vis.contains(&a)) { + return; + } + let first = vis.first().copied(); + self.selection = first.map(Selection::single).unwrap_or_default(); + } + /// The visible photos without copying them, with their generation: equal generations mean /// the very same list (same catalog revision, source, filter, sort), so a frontend can cache /// whatever it derives from the list under that number. @@ -963,14 +1200,24 @@ impl Session { (self.visible_gen, self.visible.clone()) } - /// Targets of photo commands: explicit `ids`/`id` param, else the selection. - pub fn targets(&self, p: &Value) -> Vec<PhotoId> { - if let Some(a) = p.get("ids").and_then(Value::as_array) { - return a.iter().filter_map(Value::as_u64).map(PhotoId).collect(); + /// Targets of photo commands: the explicit `ids` (photo ids) or `id` (a photo id) param, else [`Self::selected`]. + /// An `ids` or `id` that is there but isn't photo ids is an error, never the selection or part of the list + /// (issue #1506): a mistyped target mustn't turn a command meant for one photo into one for every selected photo. + pub fn targets(&self, p: &Value) -> Result<Vec<PhotoId>> { + let invalid = |what: &str| EngineError::BadParams { cmd: String::new(), msg: format!("`{what}` must be photo ids (whole numbers)") }; + match p.get("ids") { + None | Some(Value::Null) => {} + Some(Value::Array(a)) => return a.iter().map(|v| v.as_u64().map(PhotoId).ok_or_else(|| invalid("ids"))).collect(), + Some(_) => return Err(invalid("ids")), } - if let Some(id) = p.get("id").and_then(Value::as_u64) { - return vec![PhotoId(id)]; + match p.get("id") { + None | Some(Value::Null) => Ok(self.selected()), + Some(v) => v.as_u64().map(|id| vec![PhotoId(id)]).ok_or_else(|| invalid("id")), } + } + + /// The photos commands act on when a call names none: the selection, else the active photo. + pub fn selected(&self) -> Vec<PhotoId> { if self.selection.ids.is_empty() { self.selection.active.into_iter().collect() } else { self.selection.ids.clone() } } } @@ -996,6 +1243,8 @@ mod tests; #[cfg(test)] mod tests_album_order; #[cfg(test)] +mod tests_album_remove; +#[cfg(test)] mod tests_albums_local; #[cfg(test)] mod tests_color; @@ -1015,6 +1264,8 @@ mod tests_face_recognize; mod tests_face_tiles; #[cfg(test)] mod tests_filesystem_history; +#[cfg(all(test, not(target_arch = "wasm32")))] +mod tests_focus_scan; #[cfg(test)] mod tests_folders; #[cfg(test)] @@ -1022,6 +1273,8 @@ mod tests_forget_local; #[cfg(test)] mod tests_history_cursor; #[cfg(test)] +mod tests_hold_view; +#[cfg(test)] mod tests_import; #[cfg(test)] mod tests_import_move; @@ -1034,8 +1287,12 @@ mod tests_library; #[cfg(test)] mod tests_lightroom_folder; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_merge; #[cfg(test)] +mod tests_move_to_share; +#[cfg(test)] mod tests_organize; #[cfg(test)] mod tests_persist; @@ -1044,14 +1301,24 @@ mod tests_prefs; #[cfg(test)] mod tests_presets_scale; #[cfg(test)] +mod tests_print; +#[cfg(test)] mod tests_process; #[cfg(test)] +mod tests_recheck; +#[cfg(test)] mod tests_segment; #[cfg(test)] mod tests_settings_files; #[cfg(test)] +mod tests_slide; +#[cfg(test)] +mod tests_slideshow_export; +#[cfg(test)] mod tests_spots; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_view_follow; +#[cfg(test)] mod tests_xmp; diff --git a/crates/engine/src/library.rs b/crates/engine/src/library.rs index 0305623f2..e91f0bfff 100644 --- a/crates/engine/src/library.rs +++ b/crates/engine/src/library.rs @@ -207,14 +207,22 @@ struct PrefsFile { /// Parameters of the last export (for Export with Previous). #[serde(alias = "last_export")] last_export: Option<serde_json::Value>, + /// Options of the last Photo Merge, per command. + last_merge: std::collections::BTreeMap<String, serde_json::Value>, /// The user's export presets. export_presets: Vec<crate::export::ExportPreset>, + /// Print templates. + print_templates: Vec<crate::print::template::PrintTemplate>, /// Metadata presets. metadata_presets: Vec<crate::cmd::metadata::MetadataPreset>, /// Filter presets. filter_presets: Vec<crate::cmd::filters::FilterPreset>, /// Point-curve presets. curve_presets: Vec<crate::cmd::curves::CurvePreset>, + /// Mask effect presets. + mask_effects: Vec<crate::cmd::mask_effects::MaskEffect>, + /// The effect new masks of each kind start with. + mask_tool_effects: std::collections::BTreeMap<String, String>, /// Colour-label name sets. label_sets: Vec<crate::cmd::manage::LabelSet>, /// Imported LUT profiles. @@ -232,6 +240,16 @@ struct PrefsFile { /// Days after which untouched Local records of unbrowsed folders are forgotten (missing = /// the default, 0 = never). forget_local_days: Option<u32>, + /// Slideshow playback settings. + slideshow: crate::slideshow::PlaybackSettings, + /// Slideshow slide layout (margins, options, backdrop). + slide_layout: crate::slide::SlideSettings, + /// Slideshow overlays. + slide_overlays: crate::slide_overlay::OverlaySettings, + /// Slideshow intro and ending screens. + slide_titles: crate::slide_title::TitleSettings, + /// A view holds the photos it shows until it is refreshed (`library.holdView`). + hold_view: bool, } fn presets_json(s: &Session) -> String { @@ -322,6 +340,38 @@ impl SettingsLoad { } impl Session { + /// Take over what the thumbnail cache wrote under its name of before (`.jpg` files, which + /// photo galleries took for the person's photos: issues #610, #1066), once for a cache + /// folder that was not made or tidied by this build yet. On a thread of its own, with a row + /// in the activity stack that can stop it: a large cache is some 100 000 files to rename. + fn tidy_thumbnail_cache(&mut self) { + #[cfg(not(target_arch = "wasm32"))] + { + use std::sync::atomic::{AtomicBool, Ordering}; + let cache = self.media.rendered.clone(); + if !cache.disk().is_some_and(|d| d.needs_tidying()) { + return; + } + let stop = std::sync::Arc::new(AtomicBool::new(false)); + let task = self.activity.start("cache", "Tidying the thumbnail cache", crate::activity::Cancel::Flag(stop.clone())); + let spawned = std::thread::Builder::new().name("lc-thumbs-tidy".into()).spawn(move || { + // the row stays while this runs (it is dropped with the closure, however that ends) + let _row = task; + if let Some(disk) = cache.disk() { + // (also given up when the library is closed or another is opened: the cache + // is retired then, and it is gone on with when this library opens again) + let stopped = || stop.load(Ordering::Relaxed) || cache.generation() == u64::MAX; + let taken = disk.take_over_old(&stopped, &|done, of| _row.progress(done as u64, of as u64)); + log::info!("thumbnail cache: {taken} thumbnails taken over under their new name in {}", disk.dir().display()); + } + }); + // (not started: the thumbnails are still found one by one, and it is tried at the next open) + if let Err(e) = spawned { + log::warn!("thumbnail cache: could not be tidied: {e}"); + } + } + } + /// Check, without closing the library open now, that [`Session::open_library`] can be expected /// to open `dir`: not a Lightroom catalog, not some other file, a readable folder (or one not /// made yet), not locked by another program. Returns the library folder ([`resolve_dir`]). @@ -422,6 +472,8 @@ impl Session { static LOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); catalog.revision = LOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed).wrapping_add(1) << 32; self.catalog = catalog; + // (nothing of the list the last library showed is this one's) + self.refresh_view(); self.library_generation = crate::LIBRARY_GEN.fetch_add(1, std::sync::atomic::Ordering::Relaxed); for entry in self.undo.iter_mut().chain(self.redo.iter_mut()) { self.pending_sidecar_repairs.append(&mut entry.retry_sidecars); @@ -442,10 +494,14 @@ impl Session { let prefs = settings.read::<PrefsFile>(files.as_mut(), "prefs.json").unwrap_or_default(); self.xmp = prefs.xmp; self.last_export = prefs.last_export; + self.last_merge = prefs.last_merge; self.export_presets = prefs.export_presets; + self.print_templates = prefs.print_templates; self.metadata_presets = prefs.metadata_presets; self.filter_presets = prefs.filter_presets; self.curve_presets = prefs.curve_presets; + self.mask_effects = prefs.mask_effects; + self.mask_tool_effects = prefs.mask_tool_effects; self.label_sets = prefs.label_sets; self.lut_profiles = prefs.lut_profiles; crate::cmd::lut_profiles::register_all(self); @@ -454,7 +510,13 @@ impl Session { self.recent_keywords = prefs.recent_keywords; self.import_defaults = prefs.import; self.cache_mb = prefs.cache_mb; + self.slideshow = prefs.slideshow.sanitized(); + self.slide_layout = prefs.slide_layout.sanitized(); + self.slide_overlays = prefs.slide_overlays.sanitized(); + self.slide_titles = prefs.slide_titles.sanitized(); self.forget_local_days = prefs.forget_local_days.unwrap_or(lightcraft_catalog::DEFAULT_FORGET_DAYS); + self.hold_view = prefs.hold_view; + self.refresh_view(); self.smart_previews_dir = prefs.smart_previews_dir.filter(|_| on_disk).map(PathBuf::from); if let Some(d) = &self.smart_previews_dir { self.media.smart_dir = Some(d.clone()); @@ -504,6 +566,7 @@ impl Session { self.media.clear_sources(); if on_disk { self.media.attach_disk_cache(&dir.join("thumbs"), self.cache_bytes()); + self.tidy_thumbnail_cache(); } let view_written = self.view_json(); self.library = Some(Library { @@ -686,10 +749,14 @@ impl Session { let v = serde_json::to_vec_pretty(&PrefsFile { xmp: self.xmp, last_export: self.last_export.clone(), + last_merge: self.last_merge.clone(), export_presets: self.export_presets.clone(), + print_templates: self.print_templates.clone(), metadata_presets: self.metadata_presets.clone(), filter_presets: self.filter_presets.clone(), curve_presets: self.curve_presets.clone(), + mask_effects: self.mask_effects.clone(), + mask_tool_effects: self.mask_tool_effects.clone(), label_sets: self.label_sets.clone(), lut_profiles: self.lut_profiles.clone(), keyword_sets: self.keyword_sets.clone(), @@ -699,6 +766,11 @@ impl Session { cache_mb: self.cache_mb, smart_previews_dir: self.smart_previews_dir.as_ref().map(|d| d.to_string_lossy().to_string()), forget_local_days: Some(self.forget_local_days), + slideshow: self.slideshow.clone(), + slide_layout: self.slide_layout.clone(), + slide_overlays: self.slide_overlays.clone(), + slide_titles: self.slide_titles.clone(), + hold_view: self.hold_view, }) .unwrap_or_default(); let Some(lib) = self.library.as_mut() else { return Ok(()) }; diff --git a/crates/engine/src/lightroom_archive.rs b/crates/engine/src/lightroom_archive.rs index 0dceb421d..5133fc80c 100644 --- a/crates/engine/src/lightroom_archive.rs +++ b/crates/engine/src/lightroom_archive.rs @@ -538,6 +538,7 @@ mod tests { keywords: vec!["Trips|Goa".into()], history: vec![BTreeMap::from([("text".into(), serde_json::json!("step")), ("curve".into(), serde_json::json!([0, 128, 255]))])], snapshots: vec![BTreeMap::from([("name".into(), serde_json::json!("before"))])], + original: crate::lightroom_catalog::Original::Local, }; CatalogImport { source: "source.lrcat".into(), @@ -621,6 +622,7 @@ mod tests { keywords: Vec::new(), history: vec![BTreeMap::from([("text".into(), serde_json::json!(format!("history-{index}-{}", "x".repeat(8 * 1024))))])], snapshots: Vec::new(), + original: crate::lightroom_catalog::Original::Local, }) .collect(); assert_eq!(store_best_effort(&path, &input).unwrap(), None); diff --git a/crates/engine/src/lightroom_catalog.rs b/crates/engine/src/lightroom_catalog.rs index 2519057a3..5f39188a1 100644 --- a/crates/engine/src/lightroom_catalog.rs +++ b/crates/engine/src/lightroom_catalog.rs @@ -2,7 +2,7 @@ //! Lightroom, Python and a C SQLite runtime are not needed. Original paths stay in place. //! Raw settings are archived before mutation; mapped edits are approximations, not Adobe renders. use std::collections::{BTreeMap, HashMap, HashSet}; -use std::path::{Component, Path, PathBuf}; +use std::path::{Path, PathBuf}; use std::sync::{ Arc, atomic::{AtomicBool, AtomicUsize, Ordering}, @@ -16,7 +16,8 @@ use serde_json::{Value, json}; type Row = BTreeMap<String, Value>; const COMMAND: &str = "library.importLightroom"; -const MAX_FILE: u64 = 1 << 30; +/// Largest catalog database or WAL read into memory (4 GiB). The whole file is held in RAM, so the cap stays finite. +const MAX_FILE: u64 = 1 << 32; fn error(why: impl Into<String>) -> crate::EngineError { crate::EngineError::BadParams { cmd: COMMAND.into(), msg: why.into() } @@ -145,6 +146,31 @@ pub struct CatalogPhoto { pub keywords: Vec<String>, pub history: Vec<Row>, pub snapshots: Vec<Row>, + /// Whether `path` may be looked at (derived when the catalog is read; not archived). + #[serde(skip)] + pub original: Original, +} + +/// Whether the original a catalog lists may be looked at. Its path is resolved with the catalog +/// as the document ([`crate::doc_path::resolve`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub enum Original { + /// `path` is a local file path, or one on the network share the catalog is on. + Local, + /// `path` is not a path on this computer (a catalog made on another system): the photo is + /// kept as an offline record for later relinking, and the path is not looked at. + #[default] + NotHere, + /// `path` is refused (why): it is on another computer or a device, or its folder or file name + /// is an absolute path. The photo is reported missing and not added to the library. + Refused(String), +} + +impl CatalogPhoto { + /// Whether the original is a file here; only a [`Original::Local`] path is looked at. + pub fn original_is_file(&self) -> bool { + self.original == Original::Local && Path::new(&self.path).is_file() + } } #[derive(Clone, Debug, Serialize, Deserialize)] @@ -234,10 +260,14 @@ pub(crate) fn validate_for_job(data: &CatalogImport) -> Result<(), String> { fn bytes(path: &Path) -> Result<Vec<u8>, String> { use std::io::Read; let f = std::fs::File::open(path).map_err(|e| format!("{}: {e}", path.display()))?; - if f.metadata().map_err(|e| e.to_string())?.len() > MAX_FILE { - return Err("catalog or WAL exceeds 1 GiB import limit".into()); + let len = f.metadata().map_err(|e| e.to_string())?.len(); + if len > MAX_FILE { + return Err("catalog or WAL exceeds 4 GiB import limit".into()); } let mut out = Vec::new(); + // Reserve the exact size up front: growing by doubling would briefly need about twice the catalog's size. + let wanted = usize::try_from(len).map_err(|_| "catalog is too large for this platform".to_string())?; + out.try_reserve_exact(wanted).map_err(|_| "not enough memory to read the catalog".to_string())?; f.take(MAX_FILE + 1).read_to_end(&mut out).map_err(|e| e.to_string())?; if out.len() as u64 > MAX_FILE { return Err("catalog grew beyond import limit".into()); @@ -338,25 +368,36 @@ fn committed_wal_len(main: &[u8], wal: &[u8]) -> Result<usize, String> { /// Where a root folder is when its `absolutePath` isn't a folder here (the catalog came from /// another computer): `relativePathFromCatalog`, which Lightroom records for roots on the -/// catalog's volume, resolved against the catalog's folder, if that is a folder. +/// catalog's volume, resolved against the catalog's folder, if that is a folder. Both paths are +/// looked at only when [`crate::doc_path::resolve`] follows them. fn relocated_root(absolute: &str, relative: &str, catalog: &Path) -> Option<PathBuf> { - if relative.is_empty() || !Path::new(relative).is_relative() || Path::new(absolute).is_dir() { + if relative.is_empty() || !Path::new(relative).is_relative() || crate::doc_path::resolve(absolute, Some(catalog)).is_ok_and(|p| p.is_dir()) { return None; } - let dir = std::path::absolute(catalog).ok()?.parent()?.join(relative); - let mut out = PathBuf::new(); - for part in dir.components() { - match part { - Component::ParentDir => { - if !out.pop() { - return None; - } - } - Component::CurDir => {} - part => out.push(part), - } + crate::doc_path::resolve(relative, Some(catalog)).ok().filter(|dir| dir.is_dir()) +} + +/// Whether a folder path or file name from the catalog is absolute: `Path::join` would put it in +/// place of the root folder. +fn is_absolute_part(part: &str) -> bool { + let bytes = part.as_bytes(); + part.starts_with(['/', '\\']) + || (bytes.first().is_some_and(u8::is_ascii_alphabetic) && bytes.get(1) == Some(&b':')) + || Path::new(part).is_absolute() +} + +/// The path of an original under `root` and whether it may be looked at, resolved with the +/// catalog at `catalog` as the document. +fn original_path(root: &str, folder: &str, name: &str, catalog: &Path) -> (String, Original) { + let stored = Path::new(root).join(folder).join(name).to_string_lossy().to_string(); + if is_absolute_part(folder) || is_absolute_part(name) { + return (stored, Original::Refused("its folder or file name is an absolute path".into())); + } + match crate::doc_path::resolve(&stored, Some(catalog)) { + Ok(found) => (found.to_string_lossy().to_string(), Original::Local), + Err(crate::doc_path::NotFollowed::NotHere) => (stored, Original::NotHere), + Err(crate::doc_path::NotFollowed::Refused(why)) => (stored, Original::Refused(why.to_string())), } - out.is_dir().then_some(out) } /// Inspect `.lrcat` plus committed WAL pages without modifying either source file. @@ -452,6 +493,7 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out }; let mut root_paths = HashMap::new(); + let mut refused_roots = HashSet::new(); let mut root_ids: Vec<_> = roots.keys().copied().collect(); root_ids.sort_unstable(); for (id, root) in root_ids.iter().filter_map(|id| Some((*id, roots.get(id)?))) { @@ -464,6 +506,11 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, } None => absolute.to_string(), }; + if let Err(crate::doc_path::NotFollowed::Refused(why)) = crate::doc_path::resolve(&resolved, Some(path)) { + warnings + .push(format!("root folder {}: {resolved} is not opened: {why}; its photos are listed as missing and not added", text(root, "name"))); + refused_roots.insert(id); + } root_paths.insert(id, resolved); } let mut history = group(optional_table("Adobe_libraryImageDevelopHistoryStep", &mut warnings)?); @@ -482,13 +529,19 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, let file = files.get(&number(&image, "rootFile")).ok_or_else(|| format!("image {id}: missing file record"))?; let folder = folders.get(&number(file, "folder")).ok_or_else(|| format!("image {id}: missing folder record"))?; let root = roots.get(&number(folder, "rootFolder")).ok_or_else(|| format!("image {id}: missing root record"))?; - let root_path = root_paths.get(&number(root, "id_local")).map_or("", String::as_str); + let root_id = number(root, "id_local"); + let root_path = root_paths.get(&root_id).map_or("", String::as_str); let folder_path = text(folder, "pathFromRoot"); let name = text(file, "idx_filename"); if root_path.is_empty() || name.is_empty() { return Err(format!("image {id}: incomplete original path")); } - let path = Path::new(root_path).join(folder_path).join(name).to_string_lossy().to_string(); + let (photo_path, original) = original_path(root_path, folder_path, name, path); + if let Original::Refused(why) = &original + && !refused_roots.contains(&root_id) + { + out.warnings.push(format!("image {id}: {photo_path} is not opened: {why}; listed as missing and not added")); + } let packet = match xmp(metadata.get(&id).and_then(|r| r.get("xmp"))) { Ok(s) => s, Err(e) => { @@ -499,19 +552,64 @@ pub fn read_with_progress(path: &Path, cancel: &AtomicBool, total: &AtomicUsize, out.photos.push(CatalogPhoto { source_id: id, uuid: text(&image, "id_global").into(), - path, + path: photo_path, image, settings: develops.get(&id).cloned().unwrap_or_default(), xmp: packet, keywords: keywords.remove(&id).unwrap_or_default(), history: history.remove(&id).unwrap_or_default(), snapshots: snapshots.remove(&id).unwrap_or_default(), + original, }); } + refuse_non_media(&mut out); ensure_read_active(cancel)?; Ok(out) } +/// Drop the catalog's images whose original is not a file type LightCraft reads, saying so in the +/// import's warnings. +/// +/// The catalog decides these paths, and a record it leaves behind is one click from the system +/// opener (Photo ▸ Edit In ▸ Open Original in Other Editor). A record for `payload.exe`, +/// `start.lnk` or `Evil.app` would make a `.lrcat` someone was sent a way to have a program run +/// (issue #2135), so such an image never becomes a photo: not imported, not relinkable, not +/// openable. A virtual copy of a refused original goes with it — it has no original of its own. +/// +/// Called where the catalog is read and again where its photos become records, so neither route +/// can keep one; the second pass finds nothing left to say. +pub(crate) fn refuse_non_media(data: &mut CatalogImport) { + // masters first: a virtual copy may come before its master in the table + let refused: HashSet<i64> = data + .photos + .iter() + .filter(|p| number(&p.image, "masterImage") == 0 && !crate::open_path::is_media(Path::new(&p.path))) + .map(|p| p.source_id) + .collect(); + if refused.is_empty() { + return; + } + let mut warnings = Vec::new(); + data.photos.retain(|p| { + let master = number(&p.image, "masterImage"); + // `masterImage` is 0 for a master, so a refused id of 0 (a malformed catalog) must not + // take every master of the catalog with it + if master != 0 { + if !refused.contains(&master) { + return true; + } + warnings.push(format!("image {}: not imported; its original was not imported", p.source_id)); + return false; + } + if !refused.contains(&p.source_id) { + return true; + } + warnings.push(format!("image {}: not imported; {} isn't one of the file types LightCraft reads", p.source_id, p.path)); + false + }); + data.warnings.extend(warnings); +} + fn ensure_read_active(cancel: &AtomicBool) -> Result<(), String> { if cancel.load(Ordering::Relaxed) { Err("Lightroom import cancelled".into()) } else { Ok(()) } } @@ -660,11 +758,11 @@ fn apply(s: &mut crate::Session, data: CatalogImport, update_existing: bool) -> let paths: Vec<_> = data .photos .iter() - .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && Path::new(&p.path).is_file()) + .filter(|p| number(&p.image, "masterImage") == 0 && !existing.contains_key(&key(&p.path)) && p.original_is_file()) .map(|p| p.path.clone()) .collect(); let missing_sources: HashSet<String> = - data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !Path::new(&p.path).is_file()).map(|p| key(&p.path)).collect(); + data.photos.iter().filter(|p| number(&p.image, "masterImage") == 0).filter(|p| !p.original_is_file()).map(|p| key(&p.path)).collect(); let files = crate::import::import(s, &paths, crate::import::ImportMode::Add)?; let applied = apply_prepared(s, data, ApplyPrepared { update_existing, files, index: &mut index, archive_path, missing_sources: &missing_sources, undo0 })?; @@ -687,8 +785,10 @@ pub(crate) struct ApplyPrepared<'a> { pub(crate) undo0: usize, } -pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> { +pub(crate) fn apply_prepared(s: &mut crate::Session, mut data: CatalogImport, context: ApplyPrepared<'_>) -> crate::Result<AppliedImport> { let ApplyPrepared { update_existing, files, index, archive_path, missing_sources, undo0 } = context; + // nothing but a file type LightCraft reads becomes a record, whichever route got here (#2135) + refuse_non_media(&mut data); validate(&data).map_err(error)?; let mut warnings = data.warnings.clone(); let duplicates: HashMap<_, _> = files.duplicates.iter().filter_map(|d| d.existing.map(|id| (key(&d.path), PhotoId(id)))).collect(); @@ -698,14 +798,28 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex let mut ops = Vec::new(); let now = (s.clock)(); let mut missing = Vec::new(); + // Normal import counts probed originals; catalog import also adds offline records, + // distinct originals with identical bytes, and virtual copies. + let mut imported = files.imported.len(); + let mut promoted = HashSet::new(); let mut unmapped = BTreeMap::new(); let mut preserved = 0; // Masters precede their virtual copies. let mut photos: Vec<_> = data.photos.iter().collect(); photos.sort_by_key(|p| number(&p.image, "masterImage") != 0); + let copy_masters: HashSet<_> = data.photos.iter().map(|p| number(&p.image, "masterImage")).filter(|id| *id != 0).collect(); + let refused: HashSet<_> = data.photos.iter().filter(|p| matches!(p.original, Original::Refused(_))).map(|p| p.source_id).collect(); let mut planned: HashMap<PhotoId, Photo> = HashMap::new(); for src in photos { let master = number(&src.image, "masterImage"); + if master == 0 && missing_sources.contains(&key(&src.path)) { + missing.push(src.path.clone()); + } + // A refused path must not become a library record the app would look at later; its + // virtual copies go with it. + if refused.contains(&src.source_id) || refused.contains(&master) { + continue; + } let indexed = index .photos .get(&identity(&data, src)) @@ -736,9 +850,6 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } else { let id = s.catalog.alloc_photo_id(); let name = Path::new(&src.path).file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| src.path.clone()); - if missing_sources.contains(&key(&src.path)) { - missing.push(src.path.clone()); - } let mut p = Photo::new( id, Source::File { path: src.path.clone() }, @@ -807,7 +918,21 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex if keep_develop { (p.develop, p.edited) = previous_develop; } + // Missing files bypass normal-file preparation, including its Local promotion. + // Import still joins the library, and a virtual copy must inherit that membership. + let was_local = p.local; + p.local = false; + let photo_id = p.id; + // Only virtual-copy masters need a second owned photo while operations are staged. + // Keeping every photo here doubled the new-record working set on large catalogs. + if copy_masters.contains(&src.source_id) { + planned.insert(photo_id, p.clone()); + } if s.catalog.photo(p.id).is_some() { + if was_local && promoted.insert(p.id) { + imported += 1; + ops.push(Op::SetLocal { id: p.id, local: false }); + } ops.extend([ Op::SetRating { id: p.id, rating: p.rating }, Op::SetFlag { id: p.id, flag: p.flag }, @@ -817,10 +942,10 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex ]); ops.push(Op::SetDevelop { id: p.id, settings: p.develop.clone(), label: "Lightroom catalog".into(), edited: p.edited.clone() }); } else { - ops.push(Op::AddPhoto { photo: Box::new(p.clone()) }); + imported += 1; + ops.push(Op::AddPhoto { photo: Box::new(p) }); } - by_path.entry(key(&src.path)).or_insert(p.id); - planned.insert(p.id, p); + by_path.entry(key(&src.path)).or_insert(photo_id); } let mut albums = HashMap::new(); for collection in &data.collections { @@ -893,7 +1018,7 @@ pub(crate) fn apply_prepared(s: &mut crate::Session, data: CatalogImport, contex } s.merge_undo(s.undo.len().saturating_sub(undo0), "Import Lightroom Catalog"); Ok(AppliedImport { - report: json!({"source":data.source,"photos":ids.len(),"imported":files.imported.len(),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), + report: json!({"source":data.source,"photos":ids.len(),"imported":imported,"library":s.library.as_ref().filter(|l| l.on_disk).map(|l| l.dir.to_string_lossy()),"collections":albums.len(),"preservedExistingEdits":preserved,"missing":missing,"failed":files.failed,"warnings":warnings,"unmapped":unmapped,"archive":archive_path,"mapping":ids}), index: index.clone(), }) } @@ -916,6 +1041,7 @@ mod tests { keywords: vec!["Travel|Goa".into()], history: vec![row(json!({"text":"s = { Exposure2012 = 0.5 }"}))], snapshots: Vec::new(), + original: Original::Local, }; let mut copy = photo.clone(); copy.source_id = 2; @@ -938,12 +1064,104 @@ mod tests { warnings: Vec::new(), } } + /// A crafted `.lrcat` that names a program as a photo's original gets no record for it, so + /// Photo ▸ Edit In ▸ Open Original has nothing to hand the system opener (#2135). Its virtual + /// copies go with it; the catalog's real photos still import. + #[test] + fn a_catalog_original_that_is_not_a_photo_file_is_not_imported() { + let mut data = sample(); + let mut exe = data.photos[1].clone(); + exe.source_id = 3; + exe.uuid = "payload".into(); + exe.path = "/Users/sky/Downloads/payload.exe".into(); + exe.image = row(json!({"id_local":3,"fileFormat":"ARW","fileWidth":20,"fileHeight":10})); + let mut exe_copy = exe.clone(); + exe_copy.source_id = 4; + exe_copy.uuid = "payload-copy".into(); + exe_copy.path = "/Users/sky/Downloads/payload.exe".into(); + exe_copy.image = row(json!({"id_local":4,"masterImage":3,"copyName":"Mono"})); + // the copy comes before its master, as a catalog's table order may have it + data.photos.insert(0, exe_copy); + data.photos.push(exe); + for bad in [r"C:\Users\sky\start.lnk", "/Applications/Evil.app", "/Users/sky/go.command", "/Users/sky/run.bat"] { + let mut one = data.clone(); + one.photos.retain(|p| p.source_id < 3); + let mut planted = one.photos[1].clone(); + planted.source_id = 5; + planted.uuid = "planted".into(); + planted.path = bad.into(); + planted.image = row(json!({"id_local":5,"fileFormat":"ARW"})); + one.photos.push(planted); + let mut s = crate::Session::new(); + let r = apply(&mut s, one, false).unwrap(); + assert_eq!(r["photos"], 2, "{bad} is not catalogued: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path == bad)), "{bad} became a library record"); + } + let mut s = crate::Session::new(); + let r = apply(&mut s, data, false).unwrap(); + assert_eq!(r["photos"], 2, "only the real master and its copy: {r}"); + assert!(s.catalog.photos().all(|p| !matches!(&p.source, Source::File { path } if path.ends_with(".exe"))), "the program was catalogued"); + let warnings = r["warnings"].to_string(); + assert!(warnings.contains("payload.exe") && warnings.contains("file types LightCraft reads"), "{warnings}"); + assert!(warnings.contains("image 4: not imported"), "the virtual copy of the refused original goes with it: {warnings}"); + } + + #[test] + fn offline_local_master_and_copy_join_library_with_one_undo_step() { + let mut s = crate::Session::new(); + let data = sample(); + let master_id = s.catalog.alloc_photo_id(); + let mut master = Photo::new(master_id, Source::File { path: "/missing/test.ARW".into() }, "test.ARW", "ARW", 20, 10, "before"); + master.local = true; + s.commit("Browse folder", Op::AddPhoto { photo: Box::new(master) }).unwrap(); + s.undo.clear(); + let before = s.catalog.photo(master_id).unwrap().clone(); + let mut index = ImportIndex::default(); + let missing_sources = [key("/missing/test.ARW")].into_iter().collect(); + let mut import = |s: &mut crate::Session| { + let undo0 = s.undo.len(); + apply_prepared( + s, + data.clone(), + ApplyPrepared { + update_existing: false, + files: Default::default(), + index: &mut index, + archive_path: None, + missing_sources: &missing_sources, + undo0, + }, + ) + .unwrap() + .report + }; + let first = import(&mut s); + assert_eq!(first["mapping"]["1"], master_id.0); + assert_eq!(first["imported"], 2, "the promoted master and new copy both join All Photos"); + assert_eq!(first["missing"], json!(["/missing/test.ARW"])); + assert_eq!(s.catalog.len(), 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + assert_eq!(s.undo.len(), 1); + let second = import(&mut s); + assert_eq!(second["imported"], 0); + assert_eq!(second["mapping"], first["mapping"]); + s.undo_step().unwrap(); + s.undo_step().unwrap(); + assert_eq!(s.catalog.len(), 1); + assert_eq!(s.catalog.photo(master_id), Some(&before)); + assert!(s.catalog.albums().next().is_none()); + let third = import(&mut s); + assert_eq!(third["imported"], 2); + assert!(s.catalog.photos().all(|photo| !photo.local)); + } + #[test] fn imports_copies_collections_ratings_and_mapped_edits_in_one_undo_step() { let mut s = crate::Session::new(); let data = sample(); let r = apply(&mut s, data.clone(), false).unwrap(); assert_eq!(r["photos"], 2); + assert_eq!(r["imported"], 2, "the offline master and virtual copy both count as added"); assert_eq!(r["missing"].as_array().unwrap().len(), 1); let master = s.catalog.photo(PhotoId(r["mapping"]["1"].as_u64().unwrap())).unwrap(); assert_eq!(master.rating, 5); @@ -1241,6 +1459,10 @@ mod tests { s.commit("Changed time", Op::SetCaptured { id, captured: None }).unwrap(); let second = apply(&mut s, data, true).unwrap(); assert_eq!(first["mapping"], second["mapping"]); + assert_eq!(first["imported"], 2); + assert_eq!(second["imported"], 0, "reimport updates records instead of adding them"); + assert_eq!(second["missing"].as_array().unwrap().len(), 1, "offline originals are reported on reimport too"); + assert_eq!(second["library"], dir.to_string_lossy().as_ref()); assert_eq!(s.catalog.len(), 2); assert_eq!(s.catalog.albums().count(), 2); assert_eq!(s.catalog.photo(id).unwrap().captured.as_deref(), Some("2024-01-16T12:34:56")); @@ -1359,6 +1581,63 @@ mod tests { std::fs::remove_file(path).unwrap(); } + #[test] + fn preparation_skips_missing_original_probes_but_keeps_catalog_records() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-offline-probes-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let source = dir.join("source.lrcat"); + let root = dir.join("offline"); + let tables = photo_tables((root.to_str().unwrap(), "")); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a known-missing original must not be probed".into()) + })); + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source, false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0); + let result = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(result.report["imported"], 1); + assert_eq!(result.report["failed"], json!([])); + assert_eq!(result.report["missing"].as_array().unwrap().len(), 1); + let photo = s.catalog.photos().next().unwrap(); + assert_eq!(photo.rating, 5); + assert_eq!((photo.width, photo.height), (20, 10)); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(s.catalog.is_empty()); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn probed_original_is_counted_once_and_reimport_adds_nothing() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-present-probes-{}", std::process::id())); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + let source = dir.join("source.lrcat"); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Ok(crate::media::ProbeInfo { format: "ARW".into(), width: 20, height: 10, ..Default::default() }) + })); + for expected in [1, 0] { + let mut job = crate::lightroom_job::LightroomJob::new(&mut s, source.clone(), false).unwrap(); + let prepared = job.prepare(&AtomicBool::new(false)).unwrap(); + let completion = crate::lightroom_job::commit_prepared(&mut s, prepared).unwrap(); + assert_eq!(completion.report["imported"], expected); + assert_eq!(completion.report["missing"], json!([])); + assert_eq!(completion.report["failed"], json!([])); + assert_eq!(s.catalog.len(), 1); + } + assert_eq!(probes.load(Ordering::Relaxed), 1); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn a_root_missing_here_is_found_relative_to_the_catalog() { let dir = std::env::temp_dir().join(format!("lightcraft-relocated-catalog-{}", std::process::id())); @@ -1373,11 +1652,57 @@ mod tests { // a relative path that leads nowhere keeps the recorded one std::fs::write(&path, native_catalog(&photo_tables(("C:/Users/me/Project/RAW/", "../Elsewhere/")))).unwrap(); let catalog = read(&path).unwrap(); - assert!(catalog.photos[0].path.starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); + assert!(Path::new(&catalog.photos[0].path).starts_with("C:/Users/me/Project/RAW/"), "{}", catalog.photos[0].path); assert!(catalog.warnings.is_empty(), "{:?}", catalog.warnings); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn originals_on_other_computers_are_never_looked_at_or_added() { + let dir = std::env::temp_dir().join(format!("lightcraft-lrcat-remote-roots-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("Photos")).unwrap(); + std::fs::write(dir.join("Photos/test.ARW"), b"original").unwrap(); + let source = dir.join("source.lrcat"); + let mut roots = vec![r"\\192.0.2.1\share\".to_string(), "file://192.0.2.1/s/".to_string()]; + if cfg!(unix) { + // `//<local folder>/` is a local folder to Linux, but a network path to the resolver. + roots.push(format!("/{}/", dir.display())); + } + for root in &roots { + std::fs::write(&source, native_catalog(&photo_tables((root, "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(matches!(catalog.photos[0].original, Original::Refused(_)), "{root}: {:?}", catalog.photos[0]); + assert!(catalog.warnings.iter().any(|w| w.contains("not opened")), "{root}: {:?}", catalog.warnings); + let mut s = crate::Session::new(); + let probes = Arc::new(AtomicUsize::new(0)); + let calls = probes.clone(); + s.media.file_probe = Some(Arc::new(move |_| { + calls.fetch_add(1, Ordering::Relaxed); + Err("a refused original must not be probed".into()) + })); + let report = import(&mut s, &source, false).unwrap(); + assert_eq!(probes.load(Ordering::Relaxed), 0, "{root}"); + assert_eq!(report["missing"].as_array().map(Vec::len), Some(1), "{root}: {report}"); + assert!(s.catalog.is_empty(), "{root}"); + } + // An absolute folder path would replace the root. + let mut tables = photo_tables((dir.to_str().unwrap(), "")); + tables[1].2[2] = SqlValue::Text(format!("{}/Photos/", dir.display())); + std::fs::write(&source, native_catalog(&tables)).unwrap(); + assert!(matches!(read(&source).unwrap().photos[0].original, Original::Refused(_))); + // A root beside the catalog is followed. + std::fs::write(&source, native_catalog(&photo_tables((dir.to_str().unwrap(), "")))).unwrap(); + let catalog = read(&source).unwrap(); + assert_eq!(catalog.photos[0].original, Original::Local); + assert!(catalog.photos[0].original_is_file()); + // A root that moved is found through its path relative to the catalog. + std::fs::write(&source, native_catalog(&photo_tables((r"\\192.0.2.1\share\", "./")))).unwrap(); + let catalog = read(&source).unwrap(); + assert!(catalog.photos[0].original_is_file(), "{:?}", catalog.photos[0]); + std::fs::remove_dir_all(dir).unwrap(); + } + #[test] fn oversized_optional_table_warns_and_is_skipped() { let mut main = vec![0u8; 2 * 512]; diff --git a/crates/engine/src/lightroom_job.rs b/crates/engine/src/lightroom_job.rs index 5e7cb02d4..52a51b30f 100644 --- a/crates/engine/src/lightroom_job.rs +++ b/crates/engine/src/lightroom_job.rs @@ -247,23 +247,14 @@ impl LightroomJob { if cancel.load(Ordering::Relaxed) { return Err(cancelled()); } - let paths: Vec<String> = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .map(|photo| photo.path.clone()) - .collect(); - let missing_sources = data - .photos - .iter() - .filter(|photo| lightroom_catalog::number_for_job(&photo.image, "masterImage") == 0) - .filter(|photo| !Path::new(&photo.path).is_file()) - .map(|photo| normalize(&photo.path)) - .collect(); - // the catalog step's last unit (validation, and looking for each original), then the photos + let (paths, missing_sources) = original_paths(&data, cancel)?; + // Catalog entries name files, never folders. Missing originals already have enough + // catalog metadata for placeholders; probing them again only adds failed I/O and errors. + // Complete the catalog step before the separate read/add photo progress starts. self.progress.catalog_done.fetch_add(1, Ordering::Relaxed); self.progress.photos.store(true, Ordering::Relaxed); - let normal = self.import.prepare(&paths, cancel); + self.import.total.fetch_add(paths.len(), Ordering::Relaxed); + let normal = self.import.prepare_files(paths, cancel); if cancel.load(Ordering::Relaxed) { normal.rollback(); return Err(cancelled()); @@ -323,6 +314,30 @@ pub fn commit_prepared(s: &mut crate::Session, mut prepared: PreparedLightroom) Ok(LightroomCompletion { report: applied.report, finalization: LightroomArchiveFinalization { path, bytes } }) } +fn original_paths(data: &CatalogImport, cancel: &AtomicBool) -> crate::Result<(Vec<String>, HashSet<String>)> { + let mut paths = Vec::new(); + let mut missing = HashSet::new(); + let mut seen = HashSet::new(); + for photo in &data.photos { + if cancel.load(Ordering::Relaxed) { + return Err(cancelled()); + } + if lightroom_catalog::number_for_job(&photo.image, "masterImage") != 0 { + continue; + } + let key = normalize(&photo.path); + if !seen.insert(key.clone()) { + continue; + } + if photo.original_is_file() { + paths.push(photo.path.clone()); + } else { + missing.insert(key); + } + } + Ok((paths, missing)) +} + fn normalize(path: &str) -> String { let path = path.replace('\\', "/"); if cfg!(windows) { path.to_lowercase() } else { path } @@ -378,6 +393,7 @@ mod tests { keywords: Vec::new(), history: Vec::new(), snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, }], collections: Vec::new(), members: Vec::new(), @@ -516,6 +532,7 @@ mod tests { serde_json::json!(format!("history-{source_id}-{}", "x".repeat(8 * 1024))), )])], snapshots: Vec::new(), + original: lightroom_catalog::Original::Local, } }) .collect(); diff --git a/crates/engine/src/media.rs b/crates/engine/src/media.rs index 52b65eac5..22d76528d 100644 --- a/crates/engine/src/media.rs +++ b/crates/engine/src/media.rs @@ -62,7 +62,7 @@ impl SettingsHashes { } /// Bump when the pipeline's output changes, to invalidate cached thumbnails. -pub const RENDER_CACHE_VERSION: u64 = 62; +pub const RENDER_CACHE_VERSION: u64 = 64; /// Thumbnails render at one of these long edges (so window/cell size changes reuse the cache). pub const THUMB_SIZES: [usize; 4] = [128, 256, 384, 512]; @@ -225,9 +225,13 @@ impl DecodedSource { } /// What to render these pixels against: the decoder's facts, else `header` (the catalog's) - /// with any stored camera tone curve. + /// with any stored camera tone curve. The original's size comes from `header` either way: the + /// decoder only sees the level it decoded. pub fn info_or(&self, header: SourceInfo) -> SourceInfo { - self.info.clone().unwrap_or_else(|| SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }) + match &self.info { + Some(info) => SourceInfo { native_long: info.native_long.or(header.native_long), ..info.clone() }, + None => SourceInfo { camera_tone: self.camera_tone.or(header.camera_tone), ..header }, + } } } @@ -752,6 +756,8 @@ pub struct RenderJob { pub view_cache: Option<(Arc<PreviewCache>, Hash128)>, /// Render for this monitor profile ([`Self::with_display`]). pub display: Option<Arc<crate::display::Display>>, + /// Compose the render into a slideshow slide ([`Self::with_slide`]). + pub slide: Option<Arc<crate::slide::SlideCompose>>, /// A face tile that can be cut from the raw's embedded preview, tried before `source` /// ([`crate::Session::face_job`]; `None` for every other job). pub preview_cut: Option<crate::face_tiles::PreviewCut>, @@ -849,7 +855,8 @@ impl RenderJob { /// Gets its own result key. Thumbnail jobs (rendered-thumbnail cache) are left as they are: /// [`crate::display::present`] converts their sRGB results. pub fn with_display(mut self, display: Option<Arc<crate::display::Display>>) -> Self { - if self.cache.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { + // (a slide is composed in sRGB, backdrop colours included; the frontend presents it) + if self.cache.is_some() || self.slide.is_some() || self.request.depth != lightcraft_pipeline::OutputDepth::U8 { return self; } let k = |d: &Option<Arc<crate::display::Display>>| d.as_ref().map_or(0, |d| d.id()).wrapping_mul(0xd6e8_feb8_6659_fd93); @@ -883,7 +890,30 @@ impl RenderJob { } } + /// Compose the render into a slideshow slide (layout, options, backdrop): the result is the + /// slide, not the photo. Gets its own result key; the photo's cached view preview stays the + /// photo. The slide stays sRGB ([`Self::with_display`] leaves it alone). + pub fn with_slide(mut self, slide: Arc<crate::slide::SlideCompose>) -> Self { + let k = |s: &Option<Arc<crate::slide::SlideCompose>>| s.as_ref().map_or(0, |s| s.key()).wrapping_mul(0xa24b_aed4_963e_e407); + self.key ^= k(&self.slide); + self.key ^= k(&Some(slide.clone())); + self.slide = Some(slide); + self + } + + /// [`Self::run_photo`], composed into its slide when this is a slide job (inside [`Self::run`]'s guard). fn run_unguarded(self) -> RenderResult { + let slide = self.slide.clone(); + let mut r = self.run_photo(); + if let (Some(slide), Ok(rendered)) = (slide, r.rendered.as_mut()) { + rendered.image = slide.compose(&rendered.image); + rendered.deep = None; + } + r + } + + /// The photo's render (without slide composition). + fn run_photo(self) -> RenderResult { if let Some((cache, key)) = &self.cache && let Some(img) = cache.get_at(self.cache_generation, *key) { @@ -1131,9 +1161,16 @@ pub fn content_key(p: &Photo) -> String { pub fn source_info(p: &Photo) -> SourceInfo { // Procedural demo scenes are scene-referred HDR (like raw files): use the filmic tone map. + // (They are drawn afresh at every size, so they have no original size.) if matches!(p.source, Source::Demo { .. }) { return SourceInfo { raw: true, ..Default::default() }; } + // the thumbnail and preview levels are smaller copies: noise reduction works in the original's pixels (#960) + let native_long = Some(p.width.max(p.height) as usize).filter(|&n| n > 0); + SourceInfo { native_long, ..header_info(p) } +} + +fn header_info(p: &Photo) -> SourceInfo { // A raw shown from its embedded preview is a rendered (display-referred) JPEG: relative white // balance and the display tone curve, like any other rendered file. if p.develops_raw() { @@ -1227,6 +1264,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }) } @@ -1279,6 +1317,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut: None, }) } @@ -1332,6 +1371,7 @@ impl crate::Session { stages: None, view_cache: None, display: None, + slide: None, preview_cut, }) } @@ -1525,7 +1565,7 @@ impl crate::Session { /// says and what a smart preview renders with. Only for raws edited in Kelvin. fn learn_as_shot_wb(&mut self, id: PhotoId, info: &SourceInfo) { let Some(p) = self.catalog.photo(id) else { return }; - if !(info.raw && !info.relative_wb && p.develops_raw() && !p.relative_wb()) { + if !info.raw || info.relative_wb || !p.develops_raw() || p.relative_wb() { return; } let read = (info.as_shot_temp, info.as_shot_tint); @@ -1720,6 +1760,7 @@ mod thumbnail_hash_tests { file_size: 99, content_hash: Some("replacement".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); s.media.forget(PhotoId(1)); @@ -1757,6 +1798,21 @@ mod thumbnail_hash_tests { mod tests { use super::*; + #[test] + fn renders_know_the_original_size_whichever_level_they_come_from() { + // issue #960: noise reduction works in the original's pixels, so a grid cell drawn from the + // 512-px thumbnail level denoises like a scaled-down export + let p = Photo::new(PhotoId(1), Source::File { path: "/photos/a.ARW".into() }, "a.ARW", "ARW", 6000, 4000, ""); + assert_eq!(source_info(&p).native_long, Some(6000)); + let decoded = SourceInfo { raw: true, as_shot_temp: 4800.0, ..Default::default() }; + let thumb = DecodedSource::new(Arc::new(Rgb32f::new(8, 8)), Some(decoded.clone())); + let info = thumb.info_or(source_info(&p)); + assert_eq!(info, SourceInfo { native_long: Some(6000), ..decoded }, "the decoder's facts, the catalog's size"); + let unknown = Photo::new(PhotoId(2), Source::File { path: "/photos/b.jpg".into() }, "b.jpg", "JPEG", 0, 0, ""); + assert_eq!(source_info(&unknown).native_long, None); + assert_eq!(source_info(&Photo::new(PhotoId(3), Source::Demo { scene: 0 }, "d", "ARW", 600, 400, "")).native_long, None); + } + #[test] fn denoise_mix_is_reserved_before_the_source_enters_the_cache() { let mut plain = Rgb32f::new(8, 8); diff --git a/crates/engine/src/merge.rs b/crates/engine/src/merge.rs index ef9fd00f9..26c69c63d 100644 --- a/crates/engine/src/merge.rs +++ b/crates/engine/src/merge.rs @@ -12,6 +12,7 @@ use std::sync::Arc; use lightcraft_catalog::{PhotoId, Source}; use lightcraft_develop::DevelopSettings; +use lightcraft_merge::pano::MAX_MEGAPIXELS; use lightcraft_merge::{Deghost, Frame, HdrOptions, PanoOptions, Projection}; use lightcraft_pipeline::{Quality, RenderRequest, SourceInfo}; use lightcraft_raster::{Rgb32f, Rgba8}; @@ -20,6 +21,10 @@ use serde_json::{Value, json}; use crate::{EngineError, Result, Session}; +/// Why `merge.hdrBatch` found nothing to merge. +pub const NO_BRACKET_SETS: &str = + "no bracket sets among these photos: select stacks, or photos taken less than a second apart at different exposures"; + /// Long edge of the frames of a preview merge. pub const PREVIEW_EDGE: usize = 1024; @@ -105,20 +110,18 @@ impl MergeJob { use rayon::prelude::*; let done = std::sync::atomic::AtomicUsize::new(0); let n = self.sources.len(); - let frames: std::result::Result<Vec<Frame>, String> = self - .sources - .par_iter() - .map(|(_, path)| { - let bytes = (self.read)(path)?; - // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) - let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) - .map_err(|e| format!("{path}: {e}")); - let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; - progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); - f - }) - .collect(); - frames + let one = |(_, path): &(PhotoId, String)| { + let bytes = (self.read)(path)?; + // a raw without colour matrices of its own starts from the colour it has when opened alone (#965) + let f = lightcraft_merge::load_frame_with(&bytes, self.preview, orient, &crate::camera_preview::starting_colour_tags) + .map_err(|e| format!("{path}: {e}")); + let k = done.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1; + progress(span.0 + (span.1 - span.0) * k as f32 / n as f32, "Reading photos"); + f + }; + // Full-size photos are decoded one at a time (the demosaic is parallel inside): decoding several 60 MP raws at + // once multiplied the decoders' scratch memory, and a 7-frame bracket ran out of memory. + if self.preview.is_some() { self.sources.par_iter().map(one).collect() } else { self.sources.iter().map(one).collect() } } /// Run the merge. `progress(fraction, stage)` returns false to cancel. @@ -149,8 +152,9 @@ impl MergeJob { MergeKind::Panorama { pano } => { let frames = self.load(true, progress, (0.0, 0.25))?; let p = sub(0.25, 0.9); - let r = lightcraft_merge::stitch(frames, pano, &p).map_err(err)?; - let info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&frames), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(frames, &fit, &p).map_err(err)?; + let info = pano_info(&r, &self.sources, pano, &fit); ( r.image, r.color, @@ -187,13 +191,15 @@ impl MergeJob { metadata: r.metadata, baseline_exposure: r.baseline_exposure, lens: Vec::new(), + wb_multipliers: r.wb_multipliers, }; f.orient(); merged.push(f); } let p = sub(0.6, 0.9); - let r = lightcraft_merge::stitch(merged, pano, &p).map_err(err)?; - let mut info = pano_info(&r, &self.sources); + let fit = fit_in_memory(pano, pixels(&merged), crate::memory::total_ram()); + let r = lightcraft_merge::stitch(merged, &fit, &p).map_err(err)?; + let mut info = pano_info(&r, &self.sources, pano, &fit); info["bracket"] = json!(b); ( r.image, @@ -230,7 +236,20 @@ impl MergeJob { } } -fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> Value { +/// `asked` is what the command asked for, `fit` what ran (held to memory): when the panorama came +/// out smaller than the photos' own resolution, `limit` says why (`memory`, `size`: Lightroom's +/// 512 MP / 65,000 px, or `asked`). +fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)], asked: &PanoOptions, fit: &PanoOptions) -> Value { + let megapixels = (r.image.width * r.image.height) as f64 / 1e6; + let native = r.native_megapixels; + let why = if fit.max_megapixels < asked.max_megapixels && fit.max_megapixels < native { + "memory" + } else if asked.max_megapixels < MAX_MEGAPIXELS && asked.max_megapixels < native { + "asked" + } else { + "size" + }; + let limit = (megapixels < native * 0.98).then_some(why); json!({ "projection": r.projection, "used": r.used.iter().filter_map(|&i| sources.get(i).map(|s| s.0.0)).collect::<Vec<_>>(), @@ -238,9 +257,46 @@ fn pano_info(r: &lightcraft_merge::PanoResult, sources: &[(PhotoId, String)]) -> "rmsPx": r.rms, "gains": r.gains, "crop": r.crop, + "megapixels": megapixels, + "nativeMegapixels": native, + "limit": limit, }) } +/// Peak memory of a panorama per pixel of its output (bytes), measured as the peak resident size +/// of synthetic stitches of 20–81 MP from 38–154 MP of photos, rounded up from the smaller ones +/// (the larger need less): while the photos are projected they are still held, beside the tiles +/// ([`PANO_TILE_BYTES`] on top of the photos' own 12 bytes a pixel); the blend that follows, the +/// photos freed, peaks at [`PANO_BLEND_BYTES`]. Writing the DNG takes less than either. +const PANO_TILE_BYTES: f64 = 36.0; +const PANO_BLEND_BYTES: f64 = 68.0; + +/// What a merge may use of the computer's memory: three quarters of it (the rest is the system's +/// and the other apps'), less the app's own memory budget (its caches). +fn merge_memory(ram: Option<usize>) -> f64 { + // a computer whose memory we can't read (Windows) is taken to have 8 GB + let ram = ram.unwrap_or(8 << 30) as f64; + (ram * 0.75 - crate::memory::budget() as f64).max(0.0) +} + +/// `pano` with its size held to what fits in memory beside its photos (`frame_px` pixels, 12 +/// bytes each, loaded already): a panorama of five 51 MP photos may have up to 166 MP on a 16 GB +/// computer, 355 MP on 32 GB. Never below 40 megapixels, the size every panorama had before it +/// followed the photos' resolution, and never above what was asked. +pub(crate) fn fit_in_memory(pano: &PanoOptions, frame_px: f64, ram: Option<usize>) -> PanoOptions { + let room = merge_memory(ram); + let fits = ((room - frame_px * 12.0) / PANO_TILE_BYTES).min(room / PANO_BLEND_BYTES) / 1e6; + PanoOptions { max_megapixels: pano.max_megapixels.min(fits.max(MIN_FITTED_MEGAPIXELS)), ..pano.clone() } +} + +/// The pixels of `frames`. +fn pixels(frames: &[Frame]) -> f64 { + frames.iter().map(|f| (f.width() * f.height()) as f64).sum() +} + +/// The smallest size [`fit_in_memory`] holds a panorama to (megapixels). +pub(crate) const MIN_FITTED_MEGAPIXELS: f64 = 40.0; + /// Bracket size from EXIF: the smallest period of the exposure sequence (≥ 2). pub fn detect_bracket(frames: &[Frame]) -> Option<usize> { let ev: Vec<f64> = frames.iter().map(|f| f.exposure).collect::<Option<_>>()?; @@ -331,7 +387,8 @@ pub fn parse(id: &str, p: &Value) -> Result<(MergeKind, MergeFinish)> { boundary_warp: p.get("boundaryWarp").and_then(Value::as_f64).unwrap_or(0.0).clamp(0.0, 100.0), auto_crop: p.get("autoCrop").and_then(Value::as_bool).unwrap_or(false), fill_edges: p.get("fillEdges").and_then(Value::as_bool).unwrap_or(false), - max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).unwrap_or(40.0), + // the photos' own resolution unless asked (held to what fits in memory when it runs) + max_megapixels: p.get("maxMegapixels").and_then(Value::as_f64).filter(|v| *v > 0.0).unwrap_or(MAX_MEGAPIXELS).min(MAX_MEGAPIXELS), }; let kind = match id { "merge.hdr" => MergeKind::Hdr { hdr }, diff --git a/crates/engine/src/open_path.rs b/crates/engine/src/open_path.rs new file mode 100644 index 000000000..d80039729 --- /dev/null +++ b/crates/engine/src/open_path.rs @@ -0,0 +1,117 @@ +//! Whether a path the library holds may be handed to the operating system. +//! +//! A photo's `Source::File` path is not always something a user typed: it can come from an +//! imported Lightroom catalog, an `.lrcat` someone was sent, a relink or a synchronised folder. +//! Photo ▸ Edit In ▸ *Open Original in Other Editor* and the system default opener run whatever +//! the path names, so a record pointing at `payload.exe`, `thing.lnk`, `run.bat`, `page.hta` +//! (Windows) or `Evil.app`, `go.command` (macOS) would be executed on one click (issue #2135). +//! +//! [`openable`] is the one gate every such hand-over goes through: the path must be a regular +//! file whose extension is one LightCraft itself reads ([`crate::import::EXTENSIONS`], which +//! already covers the Edit In copy formats `tif`/`tiff`/`psd`), and what a link resolves to must +//! be one too, so a `photo.jpg` pointing at `/bin/sh` is refused as well. What comes back is the +//! absolute path to hand over; what comes back on refusal is the sentence to show the user. +//! +//! Nothing here opens, stats-for-content or reads the file: it only decides whether the path may +//! leave LightCraft. Catalog import applies the same test ([`crate::lightroom_catalog`]), so a +//! refused original never becomes a library record in the first place. + +use std::path::Path; + +/// The absolute path to hand to an opener or external editor, or the reason it is refused. +/// +/// `label` names the thing in the error (a file name, a photo's name); an empty `label` uses +/// the path's own file name. +pub fn openable(path: &str, label: &str) -> Result<String, String> { + let p = Path::new(path); + let name = match label.trim() { + "" => p.file_name().map_or_else(|| path.to_string(), |n| n.to_string_lossy().into_owned()), + l => l.to_string(), + }; + if !is_media(p) { + return Err(format!("{name} isn't one of the file types LightCraft reads, so it isn't handed to another application")); + } + match std::fs::metadata(p) { + Ok(m) if m.is_file() => {} + Ok(_) => return Err(format!("{name} isn't a file, so it isn't handed to another application")), + Err(e) => return Err(format!("the original is missing: {path} ({e})")), + } + // a link may name a photo and point at a program; what runs is what it points at + if let Ok(target) = p.canonicalize() + && !is_media(&target) + { + return Err(format!("{name} points at {}, which isn't a file type LightCraft reads", target.display())); + } + let absolute = std::path::absolute(p).map_err(|e| format!("{name}: {e}"))?; + Ok(absolute.to_string_lossy().into_owned()) +} + +/// Whether `path`'s extension is one LightCraft reads (the Edit In copy formats included). +pub fn is_media(path: &Path) -> bool { + crate::import::is_supported(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn write(dir: &Path, name: &str) -> String { + let p = dir.join(name); + std::fs::write(&p, b"x").unwrap(); + p.to_string_lossy().into_owned() + } + + #[test] + fn only_file_types_lightcraft_reads_are_handed_over() { + let dir = std::env::temp_dir().join(format!("lc-openable-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + for good in ["DSC_1.NEF", "a.jpg", "copy-Edit.tif", "copy-Edit.psd"] { + let path = write(&dir, good); + assert_eq!(openable(&path, "").unwrap(), path, "{good} is a photo LightCraft reads"); + } + for bad in ["payload.exe", "shortcut.lnk", "run.bat", "page.hta", "go.command", "no-extension"] { + let path = write(&dir, bad); + let e = openable(&path, "").unwrap_err(); + assert!(e.contains(bad) && e.contains("file types LightCraft reads"), "{bad}: {e}"); + } + // a bundle (macOS `.app`) and any other folder are not files + let bundle = dir.join("Evil.app"); + std::fs::create_dir_all(&bundle).unwrap(); + let e = openable(&bundle.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("isn't one of the file types") || e.contains("isn't a file"), "{e}"); + let e = openable(&dir.join("gone.jpg").to_string_lossy(), "").unwrap_err(); + assert!(e.contains("missing"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A relative path is made absolute: the opener runs with its own working directory. + #[test] + fn the_path_handed_over_is_absolute() { + let dir = std::env::temp_dir().join(format!("lc-openable-abs-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let path = write(&dir, "a.jpg"); + let dotted = Path::new(&path).parent().unwrap().join(".").join("a.jpg"); + let out = openable(&dotted.to_string_lossy(), "").unwrap(); + assert!(Path::new(&out).is_absolute(), "{out}"); + assert!(out.ends_with("a.jpg"), "{out}"); + let _ = std::fs::remove_dir_all(&dir); + } + + /// A link named like a photo that points at a program is refused: what the opener runs is + /// the target, not the name. + #[cfg(unix)] + #[test] + fn a_link_to_a_program_is_refused() { + let dir = std::env::temp_dir().join(format!("lc-openable-link-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let target = write(&dir, "payload.command"); + let link = dir.join("holiday.jpg"); + std::os::unix::fs::symlink(&target, &link).unwrap(); + let e = openable(&link.to_string_lossy(), "").unwrap_err(); + assert!(e.contains("points at") && e.contains("payload.command"), "{e}"); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/engine/src/pdf.rs b/crates/engine/src/pdf.rs new file mode 100644 index 000000000..963535f5a --- /dev/null +++ b/crates/engine/src/pdf.rs @@ -0,0 +1,110 @@ +//! A minimal PDF 1.4 writer for image pages: one JPEG XObject per page, explicit byte lengths and +//! a classic cross-reference table. Used by the contact sheet ([`crate::contact_sheet`]) and the +//! slideshow PDF export ([`crate::slideshow_export`]). It streams into any writer, so a long +//! slideshow is never held in memory; the page tree and catalog are written last, once the +//! number of pages is known. + +use std::io::Write; + +/// Objects 1 and 2 are the catalog and the page tree; pages take three objects each from 3. +const FIRST_PAGE: usize = 3; + +pub(crate) struct Pdf<W: Write> { + w: W, + /// Bytes written so far. + pos: usize, + /// Each object's offset, by object number (index 0 unused). + offsets: Vec<usize>, + pages: Vec<usize>, +} + +fn io(e: std::io::Error) -> String { + format!("PDF: {e}") +} + +impl<W: Write> Pdf<W> { + pub(crate) fn new(w: W) -> Result<Self, String> { + let mut p = Pdf { w, pos: 0, offsets: vec![0; FIRST_PAGE], pages: Vec::new() }; + p.put(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")?; + Ok(p) + } + + /// Bytes written so far. + pub(crate) fn len(&self) -> usize { + self.pos + } + + /// Pages added so far. + pub(crate) fn pages(&self) -> usize { + self.pages.len() + } + + fn put(&mut self, b: &[u8]) -> Result<(), String> { + self.w.write_all(b).map_err(io)?; + self.pos = self.pos.saturating_add(b.len()); + Ok(()) + } + + fn object(&mut self, id: usize, body: &[u8]) -> Result<(), String> { + if self.offsets.len() <= id { + self.offsets.resize(id + 1, 0); + } + if let Some(o) = self.offsets.get_mut(id) { + *o = self.pos; + } + self.put(format!("{id} 0 obj\n").as_bytes())?; + self.put(body)?; + self.put(b"\nendobj\n") + } + + fn stream(&mut self, id: usize, dictionary: &str, bytes: &[u8]) -> Result<(), String> { + let mut body = format!("<< {dictionary} /Length {} >>\nstream\n", bytes.len()).into_bytes(); + body.extend_from_slice(bytes); + body.extend_from_slice(b"\nendstream"); + self.object(id, &body) + } + + /// A page of `points` (width, height) filled by `jpg` (`width × height` pixels, RGB). + /// `extra` goes into the page dictionary (e.g. a display duration and transition). + pub(crate) fn image_page(&mut self, jpg: &[u8], width: usize, height: usize, points: (f64, f64), extra: &str) -> Result<(), String> { + let id = FIRST_PAGE + self.pages.len() * 3; + let (w, h) = points; + self.object( + id, + format!( + "<< /Type /Page /Parent 2 0 R /MediaBox [0 0 {w:.2} {h:.2}] /Resources << /XObject << /Im {} 0 R >> >> /Contents {} 0 R{extra} >>", + id + 2, + id + 1 + ) + .as_bytes(), + )?; + self.stream(id + 1, "", format!("q {w:.2} 0 0 {h:.2} 0 0 cm /Im Do Q").as_bytes())?; + self.stream( + id + 2, + &format!("/Type /XObject /Subtype /Image /Width {width} /Height {height} /ColorSpace /DeviceRGB /BitsPerComponent 8 /Filter /DCTDecode"), + jpg, + )?; + self.pages.push(id); + Ok(()) + } + + /// Write the page tree, the catalog (with `catalog_extra` in its dictionary, e.g. a page + /// mode), the cross-reference table and the trailer. + pub(crate) fn finish(mut self, catalog_extra: &str) -> Result<W, String> { + if self.pages.is_empty() { + return Err("PDF: no pages".into()); + } + let kids = self.pages.iter().map(|i| format!("{i} 0 R")).collect::<Vec<_>>().join(" "); + self.object(2, format!("<< /Type /Pages /Count {} /Kids [{kids}] >>", self.pages.len()).as_bytes())?; + self.object(1, format!("<< /Type /Catalog /Pages 2 0 R{catalog_extra} >>").as_bytes())?; + let start = self.pos; + let mut xref = format!("xref\n0 {}\n0000000000 65535 f \n", self.offsets.len()).into_bytes(); + for offset in self.offsets.iter().skip(1) { + xref.extend_from_slice(format!("{offset:010} 00000 n \n").as_bytes()); + } + xref.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{start}\n%%EOF\n", self.offsets.len()).as_bytes()); + self.put(&xref)?; + self.w.flush().map_err(io)?; + Ok(self.w) + } +} diff --git a/crates/engine/src/preset_import.rs b/crates/engine/src/preset_import.rs index 3b36316fb..f1d787ae0 100644 --- a/crates/engine/src/preset_import.rs +++ b/crates/engine/src/preset_import.rs @@ -395,6 +395,20 @@ pub(crate) fn lua_settings_props(settings: &Lua) -> Result<(Props, crate::crs_ma let mut values = crate::crs_masks::Values::new(); for (k, v) in fields { let key = format!("crs:{k}"); + // the profile a preset names: its fields as XMP's struct fields (`crs:Look/crs:Name`…) + if k == "Look" + && let Lua::Table(_, look) = v + { + for (f, fv) in look { + let val = match fv { + Lua::Num(n) => format!("{n}"), + Lua::Str(s) => delocalize(s), + _ => continue, + }; + props.insert(format!("crs:Look/crs:{f}"), vec![val]); + } + continue; + } if crate::crs_masks::CONTAINERS.contains(&key.as_str()) { values.insert(key.clone(), crate::crs_masks::from_lua(v)); } @@ -497,6 +511,12 @@ pub(crate) fn build(props: &Props, values: &crate::crs_masks::Values, name: Stri o.remove("wb"); } } + // the profile it names, resolved against the user's imported profiles by `preset.import` + if let Some(look) = crate::profile_refs::look_ref(props) + && let Some(o) = settings.as_object_mut() + { + o.insert("profile".into(), serde_json::json!({"look": look})); + } if settings.as_object().is_none_or(Map::is_empty) { return None; } @@ -684,6 +704,17 @@ text]], z = ZSTR "loc" }"#, assert_eq!(d.light.exposure, 0.35); } + #[test] + fn lrtemplate_keeps_the_profile_it_names() { + let t = r#"s = { title = "Film", value = { settings = { Exposure2012 = 0.2, + Look = { Name = "Portra 400", Amount = 0.5, UUID = "ABCD", Group = "$$$/Pack=Film Pack", Parameters = { Version = "14.0" } } } } }"#; + let v = read_presets("Film.lrtemplate", t.as_bytes(), None).unwrap(); + assert_eq!( + v[0].preset.settings["profile"], + serde_json::json!({"look": {"name": "Portra 400", "amount": 50.0, "group": "Film Pack", "uuid": "abcd"}}) + ); + } + #[test] fn old_process_version_fields_are_approximated() { let t = "s = { title = \"Old\", value = { settings = { Exposure = 0.5, Contrast = 50, FillLight = 20, HighlightRecovery = 30, Clarity = 10, ToneCurve = { 0, 0, 64, 50, 255, 255 } } } }"; diff --git a/crates/engine/src/presets.rs b/crates/engine/src/presets.rs index c3675e227..0dee84f3e 100644 --- a/crates/engine/src/presets.rs +++ b/crates/engine/src/presets.rs @@ -155,7 +155,7 @@ pub fn parse_preset_file(name: &str, bytes: &[u8]) -> Result<Vec<Preset>, String pub fn expand_preset_paths(paths: &[String]) -> Vec<String> { let preset_file = |p: &Path| { let ext = p.extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack"].contains(&ext.as_str()) + ext == LCPRESET_EXT || ["xmp", "lrtemplate", "zip", "lmp", "mplumpack", crate::cmd::mask_effect_files::EFFECT_EXT].contains(&ext.as_str()) }; let mut out = Vec::new(); for p in paths { diff --git a/crates/engine/src/print/compose.rs b/crates/engine/src/print/compose.rs new file mode 100644 index 000000000..f4e50703b --- /dev/null +++ b/crates/engine/src/print/compose.rs @@ -0,0 +1,1096 @@ +//! Composes print pages: places each cell's photo (fit or Zoom to Fill, optionally Rotate to Fit), +//! draws Photo Border and Inner Stroke, fills the page background, adds the Page Options (crop +//! marks, page number, page info) and returns [`PageRaster`]s. +//! +//! Work is split like an export: [`prepare`] reads the session (which photos go in which cell, one +//! render job per cell) and [`Prepared::run`] does the heavy part without it, so it can run on a +//! background thread with progress and cancellation. +//! +//! Photos are rendered with the normal export pipeline into Rec. 2020 at 16 bits, at exactly the +//! pixel size the cell needs at the job's resolution; colours given in sRGB (background, border, +//! stroke) are converted to the same encoding. +use super::layout::{Layout, Rect, Style}; +use super::template::PrintSettings; +use super::{MAX_JOB_PAGES, MAX_JOB_PIXELS, MAX_PAGE_PIXELS, PageRaster}; +use crate::Session; +use crate::export::{ExportOptions, output_size}; +use crate::media::RenderJob; +use lightcraft_catalog::PhotoId; +use lightcraft_color::transfer::srgb_to_linear; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::{DeepSamples, OutputDepth, OutputSpace}; +use serde::{Deserialize, Serialize}; + +/// Most photos one print job takes. +pub const MAX_PHOTOS: usize = 1000; +/// Widest Photo Border, inches. +pub const MAX_BORDER_IN: f64 = 2.0; +/// Widest Inner Stroke, points. +pub const MAX_STROKE_PT: f64 = 36.0; +/// Renders kept for cells that print the same photo at the same size. +const RENDER_CACHE: usize = 4; +/// A crop mark's length and its gap from the photo's corner, inches. +pub const CROP_MARK_IN: f64 = 0.25; +pub const CROP_MARK_GAP_IN: f64 = 0.0625; +/// A crop mark's weight, points. +pub const CROP_MARK_PT: f64 = 0.5; +/// Size of the page number and page info text, points. +pub const PAGE_TEXT_PT: f64 = 9.0; + +/// Lightroom's Image Settings panel. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct ImageSettings { + /// Fill each cell with the photo and crop what overflows (centred); off: fit the whole photo. + pub zoom_to_fill: bool, + /// Turn a photo a quarter when that makes it print larger (fit) or crop less (fill). + pub rotate_to_fit: bool, + /// A border around the photo inside its cell; Picture Package and Custom Package only. + pub border_enabled: bool, + /// Border width, inches. + pub border_width: f64, + /// Border colour, sRGB. + pub border_color: [u8; 3], + /// A stroke drawn inside the photo's edge, every layout style. + pub stroke_enabled: bool, + /// Stroke width, points. + pub stroke_width_pt: f64, + /// Stroke colour, sRGB. + pub stroke_color: [u8; 3], +} + +impl Default for ImageSettings { + fn default() -> Self { + Self { + zoom_to_fill: false, + rotate_to_fit: false, + border_enabled: false, + border_width: 0.25, + border_color: [255; 3], + stroke_enabled: false, + stroke_width_pt: 1.0, + stroke_color: [0; 3], + } + } +} + +impl ImageSettings { + pub fn validate(&self) -> Result<(), String> { + let ok = |v: f64, max: f64| v.is_finite() && (0.0..=max).contains(&v); + if !ok(self.border_width, MAX_BORDER_IN) { + return Err(format!("border width must be 0 to {MAX_BORDER_IN} in")); + } + if !ok(self.stroke_width_pt, MAX_STROKE_PT) { + return Err(format!("stroke width must be 0 to {MAX_STROKE_PT} pt")); + } + Ok(()) + } +} + +/// Lightroom's Page panel (the parts that are not margins or overlays). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageStyle { + /// Page background colour, sRGB. + pub background: [u8; 3], + /// Page Options (Single Image and Contact Sheet, as in Lightroom; the packages ignore them): + /// the page number at the bottom right of each page. + pub page_numbers: bool, + /// The print settings (sharpening, profile, resolution) at the bottom left of each page. + pub page_info: bool, + /// Marks at the corners of each photo to cut along. + pub crop_marks: bool, +} + +impl Default for PageStyle { + fn default() -> Self { + Self { background: [255; 3], page_numbers: false, page_info: false, crop_marks: false } + } +} + +/// An sRGB colour as 16-bit Rec. 2020 samples (the encoding of [`PageRaster`]). +pub fn rec2020_from_srgb8(c: [u8; 3]) -> [u16; 3] { + let lin = SRGB.to_space(&REC2020).apply_f32(c.map(|v| srgb_to_linear(f32::from(v) / 255.0))); + let trc = OutputSpace::Rec2020.trc(); + lin.map(|v| (trc.encode(v) * 65535.0 + 0.5) as u16) +} + +/// Whether a layout style prints the Page Options (page numbers, page info, crop marks): +/// Lightroom offers them for Single Image and Contact Sheet; its packages have Cut Guides instead. +pub fn has_page_options(style: Style) -> bool { + !style.is_package() +} + +/// The colour of crop marks and page text on `background`: black, or white on a dark page. +pub fn mark_ink(background: [u8; 3]) -> [u8; 3] { + let [r, g, b] = background.map(f64::from); + if 0.2126 * r + 0.7152 * g + 0.0722 * b < 128.0 { [255; 3] } else { [0; 3] } +} + +/// The Page Info line: print sharpening, profile and resolution (Lightroom names the printer +/// where the resolution is; this print goes to a PDF). +pub fn page_info_text(settings: &PrintSettings) -> String { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let c = &settings.job.color; + let media = match c.media { + PrintMedia::Matte => "Matte", + PrintMedia::Glossy => "Glossy", + }; + let sharpen = match c.sharpen { + PrintSharpen::Off => "Off".to_string(), + PrintSharpen::Low => format!("Low ({media})"), + PrintSharpen::Standard => format!("Standard ({media})"), + PrintSharpen::High => format!("High ({media})"), + }; + let profile = match &c.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => "Managed by Printer (sRGB)".to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => "Managed by Printer (Adobe RGB)".to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + format!("Print Sharpening: {sharpen} · Profile: {profile} · {:.0} ppi", settings.job.effective_ppi()) +} + +// ---- placement ------------------------------------------------------------------------------ + +/// How a photo of `iw` x `ih` goes into an `aw` x `ah` area. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + /// Turned a quarter (clockwise). + pub rotated: bool, + /// Scaled size after turning: at most the area when fitting, at least the area when filling. + pub w: usize, + pub h: usize, +} + +impl Placement { + /// The scaled size before turning (the size to render at). + pub fn unturned(&self) -> (usize, usize) { + if self.rotated { (self.h, self.w) } else { (self.w, self.h) } + } +} + +/// Decides turning and scale. `None` for an empty area or a photo without size. +pub fn place(iw: f64, ih: f64, aw: usize, ah: usize, fill: bool, rotate: bool) -> Option<Placement> { + if !(iw.is_finite() && ih.is_finite() && iw >= 1.0 && ih >= 1.0) || aw == 0 || ah == 0 { + return None; + } + let (af, bf) = (aw as f64, ah as f64); + let scale = |w: f64, h: f64| if fill { (af / w).max(bf / h) } else { (af / w).min(bf / h) }; + let (s0, s1) = (scale(iw, ih), scale(ih, iw)); + let rotated = rotate && if fill { s1 < s0 * (1.0 - 1e-9) } else { s1 > s0 * (1.0 + 1e-9) }; + let (w, h) = if rotated { (ih * s1, iw * s1) } else { (iw * s0, ih * s0) }; + let (w, h) = if fill { + (((w - 1e-6).ceil() as usize).max(aw), ((h - 1e-6).ceil() as usize).max(ah)) + } else { + ((w.round() as usize).clamp(1, aw), (h.round() as usize).clamp(1, ah)) + }; + Some(Placement { rotated, w, h }) +} + +/// A rectangle in page pixels. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct PxRect { + pub x: usize, + pub y: usize, + pub w: usize, + pub h: usize, +} + +/// The pixel rectangle of an inch rectangle on a `pw` x `ph` page at `ppi`, clipped to the page. +pub fn px_rect(r: &Rect, ppi: f64, pw: usize, ph: usize) -> Option<PxRect> { + if !r.is_finite() { + return None; + } + let edge = |v: f64, max: usize| (v * ppi).round().clamp(0.0, max as f64) as usize; + let (x0, y0, x1, y1) = (edge(r.x, pw), edge(r.y, ph), edge(r.right(), pw), edge(r.bottom(), ph)); + (x1 > x0 && y1 > y0).then_some(PxRect { x: x0, y: y0, w: x1 - x0, h: y1 - y0 }) +} + +/// The cells of every page with the photo each prints, by layout style: Single Image and Contact +/// Sheet lay `photos` out in sequence over as many pages as needed; Picture Package prints the +/// first photo in every cell; Custom Package prints each cell's own photo (else the first). +pub fn plan_cells(layout: &Layout, photos: &[PhotoId]) -> Result<Vec<Vec<(Rect, PhotoId)>>, String> { + let first = photos.first().copied().ok_or("select at least one photo to print")?; + if photos.len() > MAX_PHOTOS { + return Err(format!("a print takes at most {MAX_PHOTOS} photos")); + } + match layout.style { + Style::SingleImage | Style::ContactSheet => { + let grid = layout.grid_rects()?; + let per_page = grid.len().max(1); + let pages = photos.len().div_ceil(per_page); + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages; use a bigger grid or fewer photos")); + } + Ok(photos.chunks(per_page).map(|chunk| chunk.iter().zip(&grid).map(|(id, r)| (*r, *id)).collect()).collect()) + } + Style::PicturePackage | Style::CustomPackage => { + let pages = layout.pages.max(1) as usize; + if pages > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if layout.cells.is_empty() { + return Err("the layout has no cells; add some (print.cell.add) or use Auto Layout".into()); + } + let mut out: Vec<Vec<(Rect, PhotoId)>> = vec![Vec::new(); pages]; + for c in &layout.cells { + let photo = match layout.style { + Style::CustomPackage => c.photo.map_or(first, PhotoId), + _ => first, + }; + out.get_mut(c.page as usize).ok_or("a cell is on a page the layout does not have")?.push((c.rect, photo)); + } + Ok(out) + } + } +} + +// ---- pixels ---------------------------------------------------------------------------------- + +/// 16-bit interleaved RGB. +#[derive(Clone, Debug, PartialEq)] +pub struct Image16 { + pub w: usize, + pub h: usize, + pub rgb: Vec<u16>, +} + +impl Image16 { + pub fn filled(w: usize, h: usize, c: [u16; 3]) -> Result<Self, String> { + let n = w.checked_mul(h).and_then(|n| n.checked_mul(3)).ok_or("image too large")?; + let mut rgb = Vec::new(); + rgb.try_reserve_exact(n).map_err(|_| "not enough memory for the page")?; + for _ in 0..w * h { + rgb.extend_from_slice(&c); + } + Ok(Self { w, h, rgb }) + } + + fn px(&self, x: usize, y: usize) -> Option<[u16; 3]> { + let i = y.checked_mul(self.w)?.checked_add(x)?.checked_mul(3)?; + let s = self.rgb.get(i..i.checked_add(3)?)?; + Some([*s.first()?, *s.get(1)?, *s.get(2)?]) + } + + /// Fills a rectangle (clipped to the image). + pub fn fill_rect(&mut self, x: usize, y: usize, w: usize, h: usize, c: [u16; 3]) { + let x1 = x.saturating_add(w).min(self.w); + for row in y..y.saturating_add(h).min(self.h) { + let Some(line) = self.rgb.get_mut((row * self.w + x.min(x1)) * 3..(row * self.w + x1) * 3) else { continue }; + for px in line.as_chunks_mut::<3>().0 { + *px = c; + } + } + } + + /// Copies the `w` x `h` block of `src` at (`sx`, `sy`) to (`dx`, `dy`), clipped to both images. + pub fn blit(&mut self, src: &Image16, sx: usize, sy: usize, dx: usize, dy: usize, w: usize, h: usize) { + let w = w.min(src.w.saturating_sub(sx)).min(self.w.saturating_sub(dx)); + let h = h.min(src.h.saturating_sub(sy)).min(self.h.saturating_sub(dy)); + for row in 0..h { + let from = src.rgb.get(((sy + row) * src.w + sx) * 3..((sy + row) * src.w + sx + w) * 3); + let to = self.rgb.get_mut(((dy + row) * self.w + dx) * 3..((dy + row) * self.w + dx + w) * 3); + if let (Some(from), Some(to)) = (from, to) { + to.copy_from_slice(from); + } + } + } + + /// A quarter turn clockwise. + pub fn rotated_cw(&self) -> Result<Image16, String> { + let mut out = Image16::filled(self.h, self.w, [0; 3])?; + for y in 0..self.h { + for x in 0..self.w { + let Some(p) = self.px(x, y) else { continue }; + // (x, y) lands at (h - 1 - y, x). + let i = (x * out.w + (self.h - 1 - y)) * 3; + if let Some(d) = out.rgb.get_mut(i..i + 3) { + d.copy_from_slice(&p); + } + } + } + Ok(out) + } + + /// Scales to `nw` x `nh`: area-weighted when shrinking an axis, linear when growing it. + pub fn resized(&self, nw: usize, nh: usize) -> Result<Image16, String> { + if nw == 0 || nh == 0 || self.w == 0 || self.h == 0 { + return Err("empty image".into()); + } + if (nw, nh) == (self.w, self.h) { + return Ok(self.clone()); + } + let wx = axis_weights(self.w, nw); + let wy = axis_weights(self.h, nh); + // Horizontal pass into 16-bit rows, then the vertical pass. + let mut mid = Image16::filled(nw, self.h, [0; 3])?; + for y in 0..self.h { + let Some(src) = self.rgb.get(y * self.w * 3..(y + 1) * self.w * 3) else { continue }; + let Some(dst) = mid.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (x, taps) in wx.iter().enumerate() { + for c in 0..3 { + let v: f32 = taps.iter().map(|&(i, k)| src.get(i * 3 + c).map_or(0.0, |s| f32::from(*s) * k)).sum(); + if let Some(d) = dst.get_mut(x * 3 + c) { + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + } + } + let mut out = Image16::filled(nw, nh, [0; 3])?; + for (y, taps) in wy.iter().enumerate() { + let Some(dst) = out.rgb.get_mut(y * nw * 3..(y + 1) * nw * 3) else { continue }; + for (i, d) in dst.iter_mut().enumerate() { + let v: f32 = taps.iter().map(|&(row, k)| mid.rgb.get(row * nw * 3 + i).map_or(0.0, |s| f32::from(*s) * k)).sum(); + *d = v.round().clamp(0.0, 65535.0) as u16; + } + } + Ok(out) + } +} + +/// For each of `new` output samples, the `(source index, weight)` taps that make it (weights sum to 1). +fn axis_weights(old: usize, new: usize) -> Vec<Vec<(usize, f32)>> { + let (o, n) = (old as f64, new as f64); + (0..new) + .map(|i| { + if new < old { + let step = o / n; + let (a, b) = (i as f64 * step, (i as f64 + 1.0) * step); + let first = a.floor() as usize; + let last = (b.ceil() as usize).min(old); + (first..last) + .filter_map(|j| { + let overlap = (b.min(j as f64 + 1.0) - a.max(j as f64)).max(0.0); + (overlap > 0.0).then_some((j, (overlap / step) as f32)) + }) + .collect() + } else { + let pos = (i as f64 + 0.5) * o / n - 0.5; + let j0 = pos.floor(); + let f = (pos - j0) as f32; + let at = |j: f64| j.clamp(0.0, o - 1.0) as usize; + vec![(at(j0), 1.0 - f), (at(j0 + 1.0), f)] + } + }) + .collect() +} + +/// Everything about a cell's look that does not depend on the photo. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct CellStyle { + pub fill: bool, + pub rotate: bool, + pub border_px: usize, + pub border: [u16; 3], + pub stroke_px: usize, + pub stroke: [u16; 3], +} + +impl CellStyle { + /// The style for `settings` at `ppi`; the border applies to the package styles only. + pub fn of(settings: &PrintSettings, ppi: f64) -> Self { + let img = &settings.image; + let border_px = if img.border_enabled && settings.layout.style.is_package() { (img.border_width * ppi).round() as usize } else { 0 }; + let stroke_px = + if img.stroke_enabled && img.stroke_width_pt > 0.0 { ((img.stroke_width_pt / 72.0 * ppi).round() as usize).max(1) } else { 0 }; + Self { + fill: img.zoom_to_fill, + rotate: img.rotate_to_fit, + border_px, + border: rec2020_from_srgb8(img.border_color), + stroke_px, + stroke: rec2020_from_srgb8(img.stroke_color), + } + } +} + +/// Draws `photo` into `cell` of `page`: border frame, the photo fitted or filled (and turned when +/// asked), then the inner stroke on top of it. Returns where the photo landed. +pub fn draw_cell(page: &mut Image16, photo: &Image16, cell: PxRect, style: &CellStyle) -> Result<PxRect, String> { + // The border may not eat the cell: at least one pixel of photo area stays. + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (ax, ay) = (cell.x + border, cell.y + border); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(photo.w as f64, photo.h as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (uw, uh) = p.unturned(); + let mut img = photo.resized(uw, uh)?; + if p.rotated { + img = img.rotated_cw()?; + } + // Where the photo lands: centred in the area, cropped to it when it overflows. + let (sx, sy) = (img.w.saturating_sub(aw) / 2, img.h.saturating_sub(ah) / 2); + let (w, h) = (img.w.min(aw), img.h.min(ah)); + let (dx, dy) = (ax + (aw - w) / 2, ay + (ah - h) / 2); + if border > 0 { + page.fill_rect(dx - border, dy - border, w + 2 * border, h + 2 * border, style.border); + } + page.blit(&img, sx, sy, dx, dy, w, h); + let s = style.stroke_px.min(w.min(h) / 2); + if s > 0 { + page.fill_rect(dx, dy, w, s, style.stroke); + page.fill_rect(dx, dy + h - s, w, s, style.stroke); + page.fill_rect(dx, dy + s, s, h - 2 * s, style.stroke); + page.fill_rect(dx + w - s, dy + s, s, h - 2 * s, style.stroke); + } + Ok(PxRect { x: dx, y: dy, w, h }) +} + +/// What the Page Options draw on every page, in page pixels. +struct Marks { + /// Crop mark length, gap from the corner and weight. + crop: Option<(usize, usize, usize)>, + numbers: bool, + info: Option<String>, + ink: [u16; 3], + text_px: f32, + /// The printable area: the text sits in the margin below it, aligned with its sides. + area: Option<PxRect>, +} + +impl Marks { + fn of(settings: &PrintSettings, ppi: f64, pw: usize, ph: usize) -> Self { + let on = has_page_options(settings.layout.style); + let page = &settings.page; + let px = |inches: f64| (inches * ppi).round() as usize; + Marks { + crop: (on && page.crop_marks).then(|| (px(CROP_MARK_IN), px(CROP_MARK_GAP_IN), px(CROP_MARK_PT / 72.0).max(1))), + numbers: on && page.page_numbers, + info: (on && page.page_info).then(|| page_info_text(settings)), + ink: rec2020_from_srgb8(mark_ink(page.background)), + text_px: (PAGE_TEXT_PT / 72.0 * ppi) as f32, + area: settings.layout.printable().ok().and_then(|a| px_rect(&a, ppi, pw, ph)), + } + } + + /// Draws the marks of page `number` (one-based) around `photos`, the photos on it. + fn draw(&self, page: &mut Image16, photos: &[PxRect], number: usize) { + if let Some((len, gap, weight)) = self.crop { + for r in photos { + crop_marks(page, r, len, gap, weight, photos, self.ink); + } + } + let area = self.area.unwrap_or(PxRect { x: 0, y: 0, w: page.w, h: page.h }); + let (area_bottom, page_h) = ((area.y + area.h) as f32, page.h as f32); + // centred in the bottom margin, and on the paper when the margin is thinner than the text + let top = |h: f32| (area_bottom + (page_h - area_bottom - h) / 2.0).clamp(0.0, (page_h - h).max(0.0)); + if self.numbers + && let Some(t) = crate::text::TextLayout::new(&number.to_string(), self.text_px, false, crate::fonts::CRAFT_FONTS) + { + let x = ((area.x + area.w) as f32 - t.width).max(0.0); + draw_text(page, &t, x, top(t.height), self.ink); + } + if let Some(info) = &self.info + && let Some(t) = crate::text::TextLayout::new(info, self.text_px, false, crate::fonts::CRAFT_FONTS) + { + draw_text(page, &t, area.x as f32, top(t.height), self.ink); + } + } +} + +/// Crop marks for the photo at `r`: at each corner, a line along each of its edges that starts +/// `gap` past the corner and runs `len` outward, `weight` thick. Marks never cover a photo. +fn crop_marks(page: &mut Image16, r: &PxRect, len: usize, gap: usize, weight: usize, photos: &[PxRect], ink: [u16; 3]) { + let (x0, y0, x1, y1) = (r.x as i64, r.y as i64, (r.x + r.w) as i64, (r.y + r.h) as i64); + let (l, g, t) = (len as i64, gap as i64, weight.min(r.w).min(r.h) as i64); + let segments = [ + (x0 - g - l, y0, l, t), + (x1 + g, y0, l, t), + (x0 - g - l, y1 - t, l, t), + (x1 + g, y1 - t, l, t), + (x0, y0 - g - l, t, l), + (x0, y1 + g, t, l), + (x1 - t, y0 - g - l, t, l), + (x1 - t, y1 + g, t, l), + ]; + let on_photo = |x: usize, y: usize| photos.iter().any(|p| x >= p.x && x < p.x + p.w && y >= p.y && y < p.y + p.h); + for (sx, sy, sw, sh) in segments { + for y in sy.max(0)..(sy + sh).min(page.h as i64) { + for x in sx.max(0)..(sx + sw).min(page.w as i64) { + let (x, y) = (x as usize, y as usize); + if !on_photo(x, y) { + page.fill_rect(x, y, 1, 1, ink); + } + } + } + } +} + +/// Blends text in `ink` into `page` with its top-left corner at (`x`, `y`). +fn draw_text(page: &mut Image16, text: &crate::text::TextLayout, x: f32, y: f32, ink: [u16; 3]) { + let (w, h) = (page.w, page.h); + text.draw_in(x, y, w as f32, h as f32, |gx, gy, cov| { + let (Ok(gx), Ok(gy)) = (usize::try_from(gx), usize::try_from(gy)) else { return }; + if gx >= w || gy >= h { + return; + } + let i = (gy * w + gx) * 3; + if let Some(px) = page.rgb.get_mut(i..i + 3) { + let k = cov.clamp(0.0, 1.0); + for (p, c) in px.iter_mut().zip(ink) { + *p = (f32::from(*p) + (f32::from(c) - f32::from(*p)) * k).round().clamp(0.0, 65535.0) as u16; + } + } + }); +} + +// ---- the job --------------------------------------------------------------------------------- + +struct Item { + page: usize, + name: String, + cell: PxRect, + key: (u64, usize, usize), + job: RenderJob, +} + +/// A print job set up from the session: one render per cell, ready to run on another thread. +pub struct Prepared { + pages: Vec<(usize, usize)>, + items: Vec<Item>, + ppi: f32, + background: [u16; 3], + style: CellStyle, + marks: Marks, + /// Print sharpening, applied to each photo once it's rendered at its print size. + color: crate::print_color::PrintColorSettings, +} + +impl Prepared { + /// Renders (progress steps) the job will run. + pub fn len(&self) -> usize { + self.items.len() + } + + pub fn is_empty(&self) -> bool { + self.items.is_empty() + } + + /// Pages the job will produce. + pub fn pages(&self) -> usize { + self.pages.len() + } + + /// Renders every photo and composes the pages. `progress(done, name)` is called before each + /// render with the photo's file name; returning `false` cancels (no partial result). + pub fn run(self, progress: &mut dyn FnMut(usize, &str) -> bool) -> Result<Vec<PageRaster>, String> { + let mut cache: Vec<((u64, usize, usize), Image16)> = Vec::new(); + let mut done = 0usize; + let mut items = self.items.into_iter().peekable(); + let mut out = Vec::with_capacity(self.pages.len()); + for (index, &(w, h)) in self.pages.iter().enumerate() { + let mut page = Image16::filled(w, h, self.background)?; + let mut placed = Vec::new(); + while let Some(item) = items.next_if(|i| i.page == index) { + if !progress(done, &item.name) { + return Err("Print cancelled".into()); + } + done += 1; + let at = cache.iter().position(|(k, _)| *k == item.key); + let at = match at { + Some(at) => at, + None => { + let img = render(item.job, &self.color).map_err(|e| format!("{}: {e}", item.name))?; + if cache.len() >= RENDER_CACHE { + cache.remove(0); + } + cache.push((item.key, img)); + cache.len() - 1 + } + }; + let photo = &cache.get(at).ok_or("render cache lost an entry")?.1; + placed.push(draw_cell(&mut page, photo, item.cell, &self.style).map_err(|e| format!("{}: {e}", item.name))?); + } + self.marks.draw(&mut page, &placed, index + 1); + out.push(PageRaster { width: w as u32, height: h as u32, ppi: self.ppi, rgb: page.rgb }); + } + if !progress(done, "") { + return Err("Print cancelled".into()); + } + Ok(out) + } +} + +fn render(job: RenderJob, color: &crate::print_color::PrintColorSettings) -> Result<Image16, String> { + let rendered = job.run().rendered?; + let mut deep = rendered.deep.ok_or("the render has no 16-bit samples")?; + // print sharpening for the paper, at the size the photo prints + color.sharpen_photo(&mut deep); + let DeepSamples::U16(rgb) = deep.samples else { return Err("the render is not 16-bit".into()) }; + if deep.space != OutputSpace::Rec2020 || rgb.len() != deep.width.saturating_mul(deep.height).saturating_mul(3) { + return Err("the render is not Rec. 2020 RGB".into()); + } + Ok(Image16 { w: deep.width, h: deep.height, rgb }) +} + +/// Sets a print job up: the cells for `photos` under `settings.layout`, one render job per cell. +pub fn prepare(session: &mut Session, settings: &PrintSettings, photos: &[PhotoId]) -> Result<Prepared, String> { + let mut settings = settings.clone(); + settings.validate()?; + let ppi = f64::from(settings.job.effective_ppi()); + let (pw_in, ph_in) = settings.layout.page_size()?; + let (pw, ph) = ((pw_in * ppi).round() as usize, (ph_in * ppi).round() as usize); + let page_px = (pw as u64).saturating_mul(ph as u64); + if pw == 0 || ph == 0 || page_px > MAX_PAGE_PIXELS { + return Err(format!("a page of {pw} x {ph} pixels is too large; lower the print resolution")); + } + let plan = plan_cells(&settings.layout, photos)?; + if page_px.saturating_mul(plan.len() as u64) > MAX_JOB_PIXELS { + return Err("the print is too large for memory; lower the print resolution or print fewer pages".into()); + } + let style = CellStyle::of(&settings, ppi); + let mut items = Vec::new(); + for (page, cells) in plan.iter().enumerate() { + for (rect, id) in cells { + let Some(cell) = px_rect(rect, ppi, pw, ph) else { continue }; + let photo = session.catalog.photo(*id).ok_or_else(|| format!("no photo {}", id.0))?; + let name = photo.file_name.clone(); + let (nw, nh) = output_size(photo, &ExportOptions::default()); + let border = style.border_px.min(cell.w.min(cell.h).saturating_sub(1) / 2); + let (aw, ah) = (cell.w - 2 * border, cell.h - 2 * border); + let p = place(nw as f64, nh as f64, aw, ah, style.fill, style.rotate).ok_or("a photo has no size")?; + let (rw, rh) = p.unturned(); + let job = session.export_job(*id, rw, rh, OutputSpace::Rec2020, OutputDepth::U16)?; + items.push(Item { page, name, cell, key: (id.0, rw, rh), job }); + } + } + Ok(Prepared { + pages: vec![(pw, ph); plan.len()], + items, + ppi: ppi as f32, + background: rec2020_from_srgb8(settings.page.background), + style, + marks: Marks::of(&settings, ppi, pw, ph), + color: settings.job.color.clone(), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::{GridSpec, Margins}; + + const RED: [u16; 3] = [60000, 1000, 1000]; + const BLUE: [u16; 3] = [1000, 1000, 60000]; + const WHITE: [u16; 3] = [65535; 3]; + + /// A `w` x `h` image whose left half is red and right half blue, with a green top row. + fn halves(w: usize, h: usize) -> Image16 { + let mut img = Image16::filled(w, h, RED).unwrap(); + img.fill_rect(w / 2, 0, w - w / 2, h, BLUE); + img.fill_rect(0, 0, w, 1, [1000, 60000, 1000]); + img + } + + fn style() -> CellStyle { + CellStyle { fill: false, rotate: false, border_px: 0, border: [0; 3], stroke_px: 0, stroke: [0; 3] } + } + + fn at(img: &Image16, x: usize, y: usize) -> [u16; 3] { + img.px(x, y).unwrap() + } + + fn page() -> Image16 { + Image16::filled(100, 100, WHITE).unwrap() + } + + #[test] + fn placement_fit_fill_and_rotation() { + // 4:3 photo into a 2:1 area. + let fit = place(400.0, 300.0, 200, 100, false, false).unwrap(); + assert_eq!((fit.w, fit.h, fit.rotated), (133, 100, false)); + let fill = place(400.0, 300.0, 200, 100, true, false).unwrap(); + assert_eq!((fill.w, fill.h), (200, 150)); + // A portrait photo in a landscape area: turning prints it larger. + assert_eq!(place(300.0, 400.0, 400, 300, false, false).unwrap(), Placement { rotated: false, w: 225, h: 300 }); + let turned = place(300.0, 400.0, 400, 300, false, true).unwrap(); + assert_eq!((turned.rotated, turned.w, turned.h), (true, 400, 300)); + assert_eq!(turned.unturned(), (300, 400)); + // Filling a landscape cell with a portrait photo crops less when turned. + let f = place(300.0, 400.0, 400, 300, true, true).unwrap(); + assert!(f.rotated && f.w >= 400 && f.h >= 300); + // Matching orientation never turns. + assert!(!place(400.0, 300.0, 400, 300, false, true).unwrap().rotated); + assert!(!place(300.0, 300.0, 100, 100, false, true).unwrap().rotated); + // Hostile input. + for bad in [f64::NAN, f64::INFINITY, 0.0, -5.0] { + assert!(place(bad, 10.0, 10, 10, false, false).is_none()); + assert!(place(10.0, bad, 10, 10, true, true).is_none()); + } + assert!(place(10.0, 10.0, 0, 10, false, false).is_none()); + } + + #[test] + fn fit_centres_the_photo() { + let mut p = page(); + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &style()).unwrap(); + // 40x20 scaled to 80x40, centred vertically at y 30..70. + assert_eq!(at(&p, 10, 50), RED); + assert_eq!(at(&p, 89, 50), BLUE); + assert_eq!(at(&p, 50, 20), WHITE, "above the photo"); + assert_eq!(at(&p, 50, 75), WHITE, "below the photo"); + assert_eq!(at(&p, 5, 50), WHITE, "outside the cell"); + } + + #[test] + fn zoom_to_fill_crops_the_centre() { + let mut p = page(); + let s = CellStyle { fill: true, ..style() }; + draw_cell(&mut p, &halves(40, 20), PxRect { x: 10, y: 10, w: 80, h: 80 }, &s).unwrap(); + // The photo scales to 160x80 and the centre 80 columns remain: the colour edge is mid-cell. + for y in [10, 50, 89] { + assert_ne!(at(&p, 10, y), WHITE); + assert_ne!(at(&p, 89, y), WHITE); + } + assert_eq!(at(&p, 20, 50), RED); + assert_eq!(at(&p, 80, 50), BLUE); + // Nothing leaks outside the cell. + assert_eq!(at(&p, 9, 50), WHITE); + assert_eq!(at(&p, 90, 50), WHITE); + assert_eq!(at(&p, 50, 9), WHITE); + assert_eq!(at(&p, 50, 90), WHITE); + } + + #[test] + fn rotate_to_fit_turns_clockwise() { + // A portrait 20x40 photo (red left, blue right) in a landscape 80x40 cell. + let mut p = page(); + let cell = PxRect { x: 10, y: 10, w: 80, h: 40 }; + draw_cell(&mut p, &halves(20, 40), cell, &style()).unwrap(); + assert_eq!(at(&p, 20, 30), WHITE, "unturned the photo is a narrow strip in the middle"); + assert_eq!(at(&p, 45, 30), RED); + let mut q = page(); + draw_cell(&mut q, &halves(20, 40), cell, &CellStyle { rotate: true, ..style() }).unwrap(); + // Turned clockwise the left half (red) becomes the top half and the photo spans the cell. + assert_eq!(at(&q, 12, 12), RED, "{:?}", at(&q, 12, 12)); + assert_eq!(at(&q, 12, 48), BLUE); + assert_eq!(at(&q, 50, 20), RED); + assert_eq!(at(&q, 50, 40), BLUE); + assert_eq!(at(&q, 11, 30), at(&q, 11, 30)); + } + + #[test] + fn border_frames_the_photo_inside_the_cell() { + let mut p = page(); + let green = [100, 60000, 100]; + let s = CellStyle { border_px: 5, border: green, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 10, y: 10, w: 60, h: 60 }, &s).unwrap(); + assert_eq!(at(&p, 10, 40), green); + assert_eq!(at(&p, 14, 40), green); + assert_eq!(at(&p, 15, 40), RED); + assert_eq!(at(&p, 69, 40), green); + assert_eq!(at(&p, 64, 40), BLUE); + assert_eq!(at(&p, 40, 10), green); + assert_eq!(at(&p, 40, 69), green); + // A fitted photo gets the border hugging it, not the whole cell. + let mut q = page(); + let s = CellStyle { border_px: 5, border: green, ..style() }; + draw_cell(&mut q, &halves(60, 30), PxRect { x: 0, y: 0, w: 100, h: 100 }, &s).unwrap(); + // The 90x90 area fits a 90x45 photo at y 27..72 inside a frame 5 px thick. + assert_eq!(at(&q, 50, 23), green); + assert_eq!(at(&q, 50, 22), green); + assert_eq!(at(&q, 50, 21), WHITE); + // A border wider than the cell leaves the photo visible. + let mut r = page(); + let s = CellStyle { border_px: 500, border: green, ..style() }; + draw_cell(&mut r, &halves(10, 10), PxRect { x: 10, y: 10, w: 20, h: 20 }, &s).unwrap(); + assert_ne!(at(&r, 20, 20), WHITE); + } + + #[test] + fn stroke_is_drawn_inside_the_photo_edge() { + let mut p = page(); + let black = [0, 0, 0]; + let s = CellStyle { stroke_px: 3, stroke: black, fill: true, ..style() }; + draw_cell(&mut p, &halves(60, 60), PxRect { x: 20, y: 20, w: 60, h: 60 }, &s).unwrap(); + for (x, y) in [(20, 40), (22, 40), (77, 40), (79, 40), (40, 20), (40, 22), (40, 77), (40, 79)] { + assert_eq!(at(&p, x, y), black, "({x}, {y})"); + } + assert_eq!(at(&p, 23, 40), RED); + assert_eq!(at(&p, 76, 40), BLUE); + assert_eq!(at(&p, 19, 40), WHITE, "the stroke does not grow the photo"); + // Corners are covered exactly once and a huge stroke stays within the photo. + let mut q = page(); + let s = CellStyle { stroke_px: 1000, stroke: black, ..style() }; + draw_cell(&mut q, &halves(10, 10), PxRect { x: 20, y: 20, w: 40, h: 40 }, &s).unwrap(); + assert_eq!(at(&q, 40, 40), black); + assert_eq!(at(&q, 19, 40), WHITE); + assert_eq!(at(&q, 60, 40), WHITE); + } + + #[test] + fn colours_are_rec2020_encoded() { + assert_eq!(rec2020_from_srgb8([255; 3]), [65535; 3]); + assert_eq!(rec2020_from_srgb8([0; 3]), [0; 3]); + let grey = rec2020_from_srgb8([128; 3]); + assert!(grey[0].abs_diff(grey[1]) < 3 && grey[1].abs_diff(grey[2]) < 3); + // sRGB red sits inside the Rec. 2020 gamut: its green and blue are not zero. + let red = rec2020_from_srgb8([255, 0, 0]); + assert!(red[0] < 65535 && red[1] > 0); + } + + #[test] + fn resize_preserves_flat_colour_and_edges() { + let img = halves(64, 32); + for (w, h) in [(32, 16), (128, 64), (50, 70), (1, 1), (64, 31)] { + let r = img.resized(w, h).unwrap(); + assert_eq!((r.w, r.h), (w, h)); + assert_eq!(r.rgb.len(), w * h * 3); + } + let flat = Image16::filled(40, 30, [1234, 5678, 9999]).unwrap().resized(17, 61).unwrap(); + assert!(flat.rgb.chunks(3).all(|p| p == [1234, 5678, 9999])); + assert!(img.resized(0, 5).is_err()); + let r = img.resized(32, 16).unwrap(); + assert_eq!(at(&r, 4, 8), RED); + assert_eq!(at(&r, 28, 8), BLUE); + } + + #[test] + fn rotate_and_blit_are_clipped() { + let img = halves(3, 2); + let r = img.rotated_cw().unwrap(); + assert_eq!((r.w, r.h), (2, 3)); + let mut p = page(); + p.blit(&img, 0, 0, 99, 99, 10, 10); + assert_eq!(at(&p, 99, 99), at(&img, 0, 0)); + p.blit(&img, 5, 5, 0, 0, 3, 3); + p.fill_rect(95, 95, 50, 50, RED); + assert_eq!(at(&p, 99, 99), RED); + p.fill_rect(usize::MAX, usize::MAX, usize::MAX, usize::MAX, RED); + } + + fn settings(style: Style) -> PrintSettings { + let mut s = PrintSettings::default(); + s.layout.style = style; + s.layout.page.paper = "4x6".into(); + s.layout.page.margins = Margins::uniform(0.5); + s.job.ppi = 100.0; + s + } + + #[test] + fn plans_follow_the_layout_style() { + let ids: Vec<PhotoId> = (1..=5).map(PhotoId).collect(); + let mut s = settings(Style::ContactSheet); + s.layout.grid = GridSpec { columns: 2, rows: 2, ..Default::default() }; + let plan = plan_cells(&s.layout, &ids).unwrap(); + assert_eq!(plan.iter().map(Vec::len).collect::<Vec<_>>(), vec![4, 1]); + assert_eq!(plan[1][0].1, PhotoId(5)); + let one = plan_cells(&settings(Style::SingleImage).layout, &ids).unwrap(); + assert_eq!(one.len(), 5); + assert!(plan_cells(&s.layout, &[]).is_err()); + assert!(plan_cells(&s.layout, &vec![PhotoId(1); MAX_PHOTOS + 1]).is_err()); + + let mut pkg = settings(Style::PicturePackage); + assert!(plan_cells(&pkg.layout, &ids).is_err(), "no cells yet"); + pkg.layout.add_cell(1.0, 1.0).unwrap(); + pkg.layout.add_cell(2.0, 2.0).unwrap(); + let plan = plan_cells(&pkg.layout, &ids).unwrap(); + assert_eq!(plan.len(), 1); + assert!(plan[0].iter().all(|(_, p)| *p == PhotoId(1)), "picture package repeats the first photo"); + + let mut custom = settings(Style::CustomPackage); + let a = custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.add_cell(1.0, 1.0).unwrap(); + custom.layout.set_cell_photo(a, Some(3)).unwrap(); + let plan = plan_cells(&custom.layout, &ids).unwrap(); + assert_eq!((plan[0][0].1, plan[0][1].1), (PhotoId(3), PhotoId(1))); + let blank_page = { + custom.layout.new_page().unwrap(); + plan_cells(&custom.layout, &ids).unwrap() + }; + assert_eq!(blank_page.iter().map(Vec::len).collect::<Vec<_>>(), vec![2, 0]); + } + + fn first_ids(s: &Session, n: usize) -> Vec<PhotoId> { + s.catalog.photos().take(n).map(|p| p.id).collect() + } + + #[test] + fn composes_pages_from_real_photos() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 3); + let mut set = settings(Style::ContactSheet); + set.layout.grid = GridSpec { columns: 1, rows: 2, spacing_x: 0.0, spacing_y: 0.0, keep_square: false }; + set.page.background = [200, 30, 30]; + set.image.zoom_to_fill = true; + set.image.stroke_enabled = true; + set.image.stroke_width_pt = 2.0; + set.image.stroke_color = [0, 0, 0]; + let work = prepare(&mut s, &set, &ids).unwrap(); + assert_eq!((work.len(), work.pages()), (3, 2)); + let mut steps = Vec::new(); + let pages = work + .run(&mut |i, name| { + steps.push((i, name.to_string())); + true + }) + .unwrap(); + assert_eq!(steps.iter().map(|s| s.0).collect::<Vec<_>>(), vec![0, 1, 2, 3]); + assert_eq!(pages.len(), 2); + let first = &pages[0]; + assert_eq!((first.width, first.height, first.ppi), (400, 600, 100.0)); + assert_eq!(first.rgb.len(), 400 * 600 * 3); + let bg = rec2020_from_srgb8([200, 30, 30]); + let px = |p: &PageRaster, x: usize, y: usize| { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + }; + assert_eq!(px(first, 5, 5), bg, "margin shows the page background"); + assert_eq!(px(first, 395, 595), bg); + // Zoom to fill: the top-left photo pixel (inside the 2 pt = 3 px stroke) is the stroke colour. + assert_eq!(px(first, 50, 50), [0, 0, 0]); + let centre = px(first, 200, 150); + assert_ne!(centre, bg); + assert_ne!(centre, [0, 0, 0]); + // The second page holds the third photo in the first cell only: the second cell is bare. + let second = &pages[1]; + assert_ne!(px(second, 200, 150), bg); + assert_eq!(px(second, 200, 450), bg); + // Cancelling gives no pages. + let work = prepare(&mut s, &set, &ids).unwrap(); + assert!(work.run(&mut |i, _| i < 1).is_err()); + } + + #[test] + fn package_renders_each_size_and_border() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::PicturePackage); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.layout.add_cell(1.5, 1.0).unwrap(); + set.image.border_enabled = true; + set.image.border_width = 0.1; + set.image.border_color = [10, 200, 10]; + let pages = prepare(&mut s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap(); + assert_eq!(pages.len(), 1); + let green = rec2020_from_srgb8([10, 200, 10]); + let cell = set.layout.cells[0].rect; + let (x0, y0) = ((cell.x * 100.0).round() as usize, (cell.y * 100.0).round() as usize); + let at = |x: usize, y: usize| { + let i = (y * 400 + x) * 3; + pages[0].rgb[i..i + 3].to_vec() + }; + // The frame touches the cell edge on the photo's limiting axis: 10 px (0.1 in) of border all round. + let mid = (x0 + 75, y0 + 50); + assert_ne!(at(mid.0, mid.1), green.to_vec(), "the photo, not the border, is in the middle"); + let green_px = (y0..y0 + 100).flat_map(|y| (x0..x0 + 150).map(move |x| (x, y))).filter(|&(x, y)| at(x, y) == green.to_vec()).count(); + assert!(green_px > 1000, "the border frames the photo inside its cell: {green_px} border pixels"); + } + + /// Pages for two photos, one per 4x6 page at 100 ppi, each filling its 0.5 in margins (so the + /// margins are bare background unless something draws there). + fn page_options_pages(s: &mut Session, style: Style, page: PageStyle) -> Vec<PageRaster> { + let ids = first_ids(s, 2); + let mut set = settings(style); + if style.is_package() { + set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.new_page().unwrap(); + let id = set.layout.add_cell(3.0, 5.0).unwrap(); + set.layout.set_cell_page(id, 1).unwrap(); + } + set.image.zoom_to_fill = true; + set.page = page; + prepare(s, &set, &ids).unwrap().run(&mut |_, _| true).unwrap() + } + + fn rgb(p: &PageRaster, x: usize, y: usize) -> [u16; 3] { + let i = (y * p.width as usize + x) * 3; + [p.rgb[i], p.rgb[i + 1], p.rgb[i + 2]] + } + + /// The pixels of `x0..x1` x `y0..y1` that are not the white background. + fn inked(p: &PageRaster, x0: usize, x1: usize, y0: usize, y1: usize) -> usize { + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| rgb(p, x, y) != WHITE).count() + } + + #[test] + fn crop_marks_sit_outside_each_photo_corner() { + let mut s = Session::with_demo(); + let plain = page_options_pages(&mut s, Style::SingleImage, PageStyle::default()); + assert_eq!(inked(&plain[0], 0, 400, 0, 50), 0, "no marks unless asked"); + let pages = page_options_pages(&mut s, Style::SingleImage, PageStyle { crop_marks: true, ..Default::default() }); + // The photo fills 50..350 x 50..550; a mark is a line 0.25 in long, 1/16 in from the corner, + // along each edge of the photo. + for p in &pages { + for (x, y) in [(30, 50), (50, 30), (370, 50), (349, 30), (30, 549), (50, 570), (370, 549), (349, 570)] { + assert_eq!(rgb(p, x, y), [0; 3], "a mark at ({x}, {y})"); + } + for (x, y) in [(47, 50), (50, 47), (30, 60), (60, 30), (10, 50), (50, 10)] { + assert_eq!(rgb(p, x, y), WHITE, "the gap, or past the mark, at ({x}, {y})"); + } + } + // a dark page gets white marks + let dark = page_options_pages(&mut s, Style::SingleImage, PageStyle { background: [20; 3], crop_marks: true, ..Default::default() }); + assert_eq!(rgb(&dark[0], 30, 50), WHITE); + } + + #[test] + fn page_numbers_and_info_print_in_the_bottom_margin() { + let mut s = Session::with_demo(); + let numbered = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_numbers: true, ..Default::default() }); + // bottom right: the number, different on each page; nothing at the bottom left + let (right, left) = ((200, 400), (0, 200)); + for p in &numbered { + assert!(inked(p, right.0, right.1, 550, 600) > 10, "a page number"); + assert_eq!(inked(p, left.0, left.1, 550, 600), 0); + assert_eq!(inked(p, 0, 400, 0, 50), 0, "nothing at the top"); + } + let region = |p: &PageRaster| (550..600).flat_map(|y| (right.0..right.1).map(move |x| (x, y))).map(|(x, y)| rgb(p, x, y)).collect::<Vec<_>>(); + assert_ne!(region(&numbered[0]), region(&numbered[1]), "page 1 and page 2 are numbered differently"); + let info = page_options_pages(&mut s, Style::SingleImage, PageStyle { page_info: true, ..Default::default() }); + for p in &info { + assert!(inked(p, left.0, left.1, 550, 600) > 10, "the print settings line"); + } + // the packages have no Page Options in Lightroom: nothing is drawn + let all = PageStyle { page_numbers: true, page_info: true, crop_marks: true, ..Default::default() }; + let package = page_options_pages(&mut s, Style::PicturePackage, all); + assert_eq!(package.len(), 2); + for p in &package { + assert_eq!(inked(p, 0, 400, 550, 600) + inked(p, 0, 400, 0, 50), 0); + } + } + + #[test] + fn page_info_names_the_sharpening_profile_and_resolution() { + use crate::print_color::{ManagedSpace, PrintMedia, PrintProfile, PrintSharpen}; + let mut set = PrintSettings::default(); + let line = page_info_text(&set); + assert_eq!(line, "Print Sharpening: Standard (Glossy) · Profile: Managed by Printer (sRGB) · 240 ppi"); + set.job.color.sharpen = PrintSharpen::Off; + set.job.color.media = PrintMedia::Matte; + set.job.color.profile = PrintProfile::Icc { path: "/profiles/Lustre Paper.icc".into() }; + set.job.ppi = 300.0; + assert_eq!(page_info_text(&set), "Print Sharpening: Off · Profile: Lustre Paper · 300 ppi"); + set.job.color.profile = PrintProfile::Managed { space: ManagedSpace::AdobeRgb }; + assert!(page_info_text(&set).contains("Managed by Printer (Adobe RGB)")); + } + + #[test] + fn caps_and_hostile_settings_are_refused() { + let mut s = Session::with_demo(); + let ids = first_ids(&s, 1); + let mut set = settings(Style::SingleImage); + set.layout.page.paper = "custom".into(); + set.layout.page.custom_width = 60.0; + set.layout.page.custom_height = 60.0; + set.job.ppi = 720.0; + assert!(prepare(&mut s, &set, &ids).is_err(), "a 60 in page at 720 ppi is far beyond the page cap"); + let mut set = settings(Style::SingleImage); + set.job.ppi = f32::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + let mut set = settings(Style::SingleImage); + set.image.border_width = f64::NAN; + assert!(prepare(&mut s, &set, &ids).is_err()); + set.image.border_width = 0.1; + set.image.stroke_width_pt = -1.0; + assert!(prepare(&mut s, &set, &ids).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[PhotoId(999_999)]).is_err()); + assert!(prepare(&mut s, &settings(Style::SingleImage), &[]).is_err()); + let set = settings(Style::SingleImage); + assert!(prepare(&mut s, &set, &vec![ids[0]; 101]).is_err(), "more than 100 pages"); + // 100 pages of 4x6 at 100 ppi is 24 Mpx: allowed; at 720 ppi it would be over the job cap. + let mut big = settings(Style::SingleImage); + big.job.ppi = 720.0; + assert!(prepare(&mut s, &big, &vec![ids[0]; 60]).is_err()); + } +} diff --git a/crates/engine/src/print/layout.rs b/crates/engine/src/print/layout.rs new file mode 100644 index 000000000..26d1818e5 --- /dev/null +++ b/crates/engine/src/print/layout.rs @@ -0,0 +1,1332 @@ +//! The Print module's page layout model: paper, margins, layout styles and cells. +//! +//! Pure data and geometry, no rendering. Every length is in **inches** in page space: the origin +//! is the top-left corner of the page *as printed* (after orientation), x grows right and y down. +//! Rulers show other units ([`Unit`]) but convert at the edge; the model never stores them. +//! +//! Four layout styles ([`Style`]): +//! * `singleImage`: one photo fills the printable area of each page; +//! * `contactSheet`: a grid of identical cells ([`GridSpec`]), the photos paginated over pages; +//! * `picturePackage`: one photo at several cell sizes, packed onto pages by [`Layout::auto_layout`]; +//! * `customPackage`: free placement of cells (overlap allowed, [`Stack`] order), each with its own photo. +//! +//! Package cells live in [`Layout::cells`] in stacking order (later = on top). Everything that +//! arrives from outside (commands, saved templates) passes through [`Layout::validate`]; all +//! setters reject NaN, infinite, negative and oversized numbers instead of storing them. +use serde::{Deserialize, Serialize}; + +/// Largest page edge accepted, inches. +pub const MAX_PAGE_IN: f64 = 60.0; +/// Smallest page edge accepted, inches. +pub const MIN_PAGE_IN: f64 = 1.0; +/// Smallest cell edge accepted, inches. +pub const MIN_CELL_IN: f64 = 0.1; +/// Package layouts hold at most this many cells. +pub const MAX_CELLS: usize = 200; +/// Package layouts span at most this many pages. +pub const MAX_PAGES: u32 = 50; +/// Largest margin accepted, inches. +pub const MAX_MARGIN_IN: f64 = 10.0; +/// The printable area must stay at least this wide and tall, inches. +pub const MIN_PRINTABLE_IN: f64 = 0.5; +/// Largest grid dimension of a contact sheet. +pub const MAX_GRID: usize = 20; +/// Geometry tolerance, inches (far below one printed pixel at any supported resolution). +pub const EPS: f64 = 1e-6; + +/// Paper sizes by name: portrait width and height in millimetres (ISO) or inches (US/photo). +const PAPER: &[(&str, f64, f64)] = &[ + ("a3", 297.0 / 25.4, 420.0 / 25.4), + ("a4", 210.0 / 25.4, 297.0 / 25.4), + ("a5", 148.0 / 25.4, 210.0 / 25.4), + ("letter", 8.5, 11.0), + ("legal", 8.5, 14.0), + ("tabloid", 11.0, 17.0), + ("4x6", 4.0, 6.0), + ("5x7", 5.0, 7.0), + ("8x10", 8.0, 10.0), + ("13x19", 13.0, 19.0), +]; + +/// Names accepted by [`PageSetup::paper`] (besides `custom`). +pub fn paper_names() -> impl Iterator<Item = &'static str> { + PAPER.iter().map(|p| p.0) +} + +/// Portrait size in inches of a named paper (case-insensitive), `None` for unknown names and `custom`. +pub fn paper_size(name: &str) -> Option<(f64, f64)> { + let name = name.to_ascii_lowercase(); + PAPER.iter().find(|p| p.0 == name).map(|p| (p.1, p.2)) +} + +/// An axis-aligned rectangle in inches. +#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Rect { + pub x: f64, + pub y: f64, + pub w: f64, + pub h: f64, +} + +impl Rect { + pub const fn new(x: f64, y: f64, w: f64, h: f64) -> Self { + Self { x, y, w, h } + } + pub fn right(&self) -> f64 { + self.x + self.w + } + pub fn bottom(&self) -> f64 { + self.y + self.h + } + pub fn area(&self) -> f64 { + self.w * self.h + } + pub fn is_finite(&self) -> bool { + self.x.is_finite() && self.y.is_finite() && self.w.is_finite() && self.h.is_finite() + } + /// Whether the interiors overlap by more than [`EPS`] on both axes (touching edges do not count). + pub fn overlaps(&self, o: &Rect) -> bool { + self.x < o.right() - EPS && o.x < self.right() - EPS && self.y < o.bottom() - EPS && o.y < self.bottom() - EPS + } + /// Whether `o` lies entirely inside `self`, within [`EPS`]. + pub fn contains(&self, o: &Rect) -> bool { + o.x >= self.x - EPS && o.y >= self.y - EPS && o.right() <= self.right() + EPS && o.bottom() <= self.bottom() + EPS + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Orientation { + #[default] + Portrait, + Landscape, +} + +/// Ruler and dimension units. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Unit { + #[default] + #[serde(rename = "in")] + Inches, + #[serde(rename = "cm")] + Centimeters, + #[serde(rename = "mm")] + Millimeters, + #[serde(rename = "pt")] + Points, +} + +impl Unit { + pub fn per_inch(self) -> f64 { + match self { + Unit::Inches => 1.0, + Unit::Centimeters => 2.54, + Unit::Millimeters => 25.4, + Unit::Points => 72.0, + } + } + pub fn from_inches(self, inches: f64) -> f64 { + inches * self.per_inch() + } + pub fn to_inches(self, value: f64) -> f64 { + value / self.per_inch() + } + pub fn label(self) -> &'static str { + match self { + Unit::Inches => "in", + Unit::Centimeters => "cm", + Unit::Millimeters => "mm", + Unit::Points => "pt", + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Margins { + pub top: f64, + pub right: f64, + pub bottom: f64, + pub left: f64, +} + +impl Default for Margins { + fn default() -> Self { + Self::uniform(0.25) + } +} + +impl Margins { + pub const fn uniform(v: f64) -> Self { + Self { top: v, right: v, bottom: v, left: v } + } +} + +/// Paper, orientation and margins (Lightroom's Page Setup + the Page margins). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PageSetup { + /// A name from [`paper_names`] or `custom`. + pub paper: String, + /// Portrait size of a `custom` paper, inches. + pub custom_width: f64, + pub custom_height: f64, + pub orientation: Orientation, + pub margins: Margins, +} + +impl Default for PageSetup { + fn default() -> Self { + Self { paper: "letter".into(), custom_width: 8.5, custom_height: 11.0, orientation: Orientation::Portrait, margins: Margins::default() } + } +} + +fn check(v: f64, what: &str, min: f64, max: f64) -> Result<f64, String> { + if v.is_finite() && v >= min && v <= max { Ok(v) } else { Err(format!("{what} must be a number from {min} to {max}")) } +} + +impl PageSetup { + /// Page width and height in inches after orientation. + pub fn size(&self) -> Result<(f64, f64), String> { + let (w, h) = if self.paper.eq_ignore_ascii_case("custom") { + ( + check(self.custom_width, "custom width", MIN_PAGE_IN, MAX_PAGE_IN)?, + check(self.custom_height, "custom height", MIN_PAGE_IN, MAX_PAGE_IN)?, + ) + } else { + paper_size(&self.paper) + .ok_or_else(|| format!("unknown paper `{}` (one of {} or custom)", self.paper, paper_names().collect::<Vec<_>>().join(", ")))? + }; + Ok(match self.orientation { + Orientation::Portrait => (w, h), + Orientation::Landscape => (h, w), + }) + } + + /// The area inside the margins. + pub fn printable(&self) -> Result<Rect, String> { + let (w, h) = self.size()?; + let m = &self.margins; + let (t, r, b, l) = ( + check(m.top, "top margin", 0.0, MAX_MARGIN_IN)?, + check(m.right, "right margin", 0.0, MAX_MARGIN_IN)?, + check(m.bottom, "bottom margin", 0.0, MAX_MARGIN_IN)?, + check(m.left, "left margin", 0.0, MAX_MARGIN_IN)?, + ); + let (pw, ph) = (w - l - r, h - t - b); + if pw < MIN_PRINTABLE_IN || ph < MIN_PRINTABLE_IN { + return Err(format!("margins leave less than {MIN_PRINTABLE_IN} in of printable area")); + } + Ok(Rect::new(l, t, pw, ph)) + } + + /// The page size in PDF points (1/72 in). + pub fn size_points(&self) -> Result<(f64, f64), String> { + let (w, h) = self.size()?; + Ok((w * 72.0, h * 72.0)) + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Style { + #[default] + SingleImage, + ContactSheet, + PicturePackage, + CustomPackage, +} + +impl Style { + /// Styles whose cells are listed in [`Layout::cells`]. + pub fn is_package(self) -> bool { + matches!(self, Style::PicturePackage | Style::CustomPackage) + } +} + +/// The contact-sheet grid. `singleImage` ignores it (one cell per page, no spacing). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct GridSpec { + pub columns: usize, + pub rows: usize, + /// Gap between columns / rows, inches. + pub spacing_x: f64, + pub spacing_y: f64, + /// Make the cells square (the grid is centred in the printable area). + pub keep_square: bool, +} + +impl Default for GridSpec { + fn default() -> Self { + Self { columns: 3, rows: 4, spacing_x: 0.1, spacing_y: 0.1, keep_square: false } + } +} + +/// One cell of a package layout. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Cell { + pub id: u32, + /// Zero-based page the cell is on. + pub page: u32, + pub rect: Rect, + /// The photo printed in the cell (Custom Package); `None` uses the job's first photo. + /// Not part of a saved template. + #[serde(skip_serializing_if = "Option::is_none")] + pub photo: Option<u64>, +} + +/// Where a cell goes in the stacking order of its page. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Stack { + ToBack, + Backward, + Forward, + ToFront, +} + +/// How dragged cells snap. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum Snap { + Off, + /// To the edges of the other cells and of the printable area. + #[default] + Cells, + /// To multiples of [`Guides::grid_size`] from the printable area's corner. + Grid, +} + +/// Rulers, grid and guides (Lightroom's Guides panel). +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Guides { + pub show_rulers: bool, + pub units: Unit, + pub snap: Snap, + /// Grid pitch, inches. + pub grid_size: f64, + pub show_margins: bool, + pub show_bleed: bool, + pub show_page_grid: bool, + pub show_cells: bool, + pub show_dimensions: bool, +} + +impl Default for Guides { + fn default() -> Self { + Self { + show_rulers: true, + units: Unit::Inches, + snap: Snap::Cells, + grid_size: 0.25, + show_margins: true, + show_bleed: false, + show_page_grid: false, + show_cells: true, + show_dimensions: false, + } + } +} + +impl Guides { + pub fn validate(&self) -> Result<(), String> { + check(self.grid_size, "grid size", 0.05, 10.0).map(|_| ()) + } +} + +/// The page layout: setup, style and cells. See the module docs for the units and style rules. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct Layout { + pub style: Style, + pub page: PageSetup, + pub grid: GridSpec, + /// Gap kept between cells by [`Layout::auto_layout`] and new cells, inches. + pub spacing: f64, + /// Pages of a package layout (at least 1 and past every cell). + pub pages: u32, + /// Package cells in stacking order, back to front. + pub cells: Vec<Cell>, + next_id: u32, +} + +impl Default for Layout { + fn default() -> Self { + Self { + style: Style::SingleImage, + page: PageSetup::default(), + grid: GridSpec::default(), + spacing: 0.1, + pages: 1, + cells: Vec::new(), + next_id: 1, + } + } +} + +/// Free space on one page as a set of maximal rectangles (the MaxRects scheme). +struct Free(Vec<Rect>); + +impl Free { + fn new(area: Rect) -> Self { + Self(vec![area]) + } + + /// Removes `used` from the free space. + fn occupy(&mut self, used: &Rect) { + let mut next = Vec::with_capacity(self.0.len() + 4); + for f in &self.0 { + if !f.overlaps(used) { + next.push(*f); + continue; + } + if used.x > f.x + EPS { + next.push(Rect::new(f.x, f.y, used.x - f.x, f.h)); + } + if used.right() < f.right() - EPS { + next.push(Rect::new(used.right(), f.y, f.right() - used.right(), f.h)); + } + if used.y > f.y + EPS { + next.push(Rect::new(f.x, f.y, f.w, used.y - f.y)); + } + if used.bottom() < f.bottom() - EPS { + next.push(Rect::new(f.x, used.bottom(), f.w, f.bottom() - used.bottom())); + } + } + // Drop rectangles contained in another (the first of two equal ones survives). + let mut keep: Vec<Rect> = Vec::with_capacity(next.len()); + for (i, r) in next.iter().enumerate() { + let dominated = next.iter().enumerate().any(|(j, o)| i != j && o.contains(r) && (!r.contains(o) || j < i)); + if !dominated { + keep.push(*r); + } + } + self.0 = keep; + } + + /// The best spot for a `w` x `h` block: least leftover on the short side, then the long side, + /// then top-most and left-most. The block is turned a quarter (when `rotate`) only if it fits + /// nowhere as given, so cells keep their shape whenever they can. Returns the spot and + /// whether the block was turned. + fn best_fit(&self, w: f64, h: f64, rotate: bool) -> Option<(Rect, bool)> { + self.fit_block(w, h) + .map(|r| (r, false)) + .or_else(|| if rotate && (w - h).abs() >= EPS { self.fit_block(h, w).map(|r| (r, true)) } else { None }) + } + + fn fit_block(&self, bw: f64, bh: f64) -> Option<Rect> { + let mut best: Option<((f64, f64, f64, f64), Rect)> = None; + let eq = |a: f64, b: f64| (a - b).abs() < EPS; + for f in &self.0 { + if bw > f.w + EPS || bh > f.h + EPS { + continue; + } + let (dw, dh) = (f.w - bw, f.h - bh); + let key = (dw.min(dh), dw.max(dh), f.y, f.x); + let better = best.as_ref().is_none_or(|(k, _)| { + if !eq(k.0, key.0) { + return key.0 < k.0; + } + if !eq(k.1, key.1) { + return key.1 < k.1; + } + if !eq(k.2, key.2) { + return key.2 < k.2; + } + key.3 < k.3 - EPS + }); + if better { + best = Some((key, Rect::new(f.x, f.y, bw, bh))); + } + } + best.map(|(_, r)| r) + } +} + +/// Where each block of [`pack`] went: page index, rectangle in page space and whether it was turned. +pub type Placement = (u32, Rect, bool); + +/// Packs blocks (width, height) onto pages of `area`, keeping `spacing` between them. +/// +/// Deterministic: blocks are placed largest first (area, then longer side, then input order) with +/// the MaxRects best-short-side-fit rule, filling earlier pages before opening a new one. Blocks +/// may be turned a quarter when `rotate`. Fails when a block cannot fit an empty page or the +/// result would need more than [`MAX_PAGES`] pages. The result is in input order. +pub fn pack(sizes: &[(f64, f64)], area: Rect, spacing: f64, rotate: bool) -> Result<Vec<Placement>, String> { + if !area.is_finite() || area.w <= 0.0 || area.h <= 0.0 { + return Err("no printable area to pack into".into()); + } + let spacing = check(spacing, "spacing", 0.0, MAX_PAGE_IN)?; + if sizes.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + for (w, h) in sizes { + check(*w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(*h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + } + // Each block carries `spacing` on its right and bottom; the area grows by the same so a block + // flush with the margin still fits. + let padded = Rect::new(area.x, area.y, area.w + spacing, area.h + spacing); + let mut order: Vec<usize> = (0..sizes.len()).collect(); + order.sort_by(|&a, &b| { + let (sa, sb) = (sizes.get(a).copied().unwrap_or_default(), sizes.get(b).copied().unwrap_or_default()); + (sb.0 * sb.1).total_cmp(&(sa.0 * sa.1)).then((sb.0.max(sb.1)).total_cmp(&sa.0.max(sa.1))).then(a.cmp(&b)) + }); + let mut pages: Vec<Free> = Vec::new(); + let mut out: Vec<Option<Placement>> = vec![None; sizes.len()]; + for i in order { + let (w, h) = sizes.get(i).copied().unwrap_or_default(); + let (bw, bh) = (w + spacing, h + spacing); + let mut placed = None; + for (p, free) in pages.iter().enumerate() { + if let Some(hit) = free.best_fit(bw, bh, rotate) { + placed = Some((p, hit)); + break; + } + } + let (p, (spot, turned)) = match placed { + Some(v) => v, + None => { + if pages.len() as u32 >= MAX_PAGES { + return Err(format!("the cells need more than {MAX_PAGES} pages")); + } + let fresh = Free::new(padded); + let hit = fresh.best_fit(bw, bh, rotate).ok_or_else(|| format!("a {w:.2} x {h:.2} in cell does not fit the printable area"))?; + pages.push(fresh); + (pages.len() - 1, hit) + } + }; + if let Some(free) = pages.get_mut(p) { + free.occupy(&spot); + } + let (cw, ch) = if turned { (h, w) } else { (w, h) }; + if let Some(slot) = out.get_mut(i) { + *slot = Some((p as u32, Rect::new(spot.x, spot.y, cw, ch), turned)); + } + } + out.into_iter().map(|p| p.ok_or_else(|| "internal packing error".to_string())).collect() +} + +/// Snaps a span (`start`, `len`) along one axis so its start or end lands on the nearest candidate +/// within `threshold`; unchanged when none is close. +fn snap_span(start: f64, len: f64, candidates: &[f64], threshold: f64) -> f64 { + let mut best: Option<(f64, f64)> = None; + for &c in candidates { + for shift in [c - start, c - (start + len)] { + if shift.abs() <= threshold && best.is_none_or(|(d, _)| shift.abs() < d) { + best = Some((shift.abs(), shift)); + } + } + } + start + best.map_or(0.0, |b| b.1) +} + +/// Snaps one coordinate (a dragged edge) to the nearest candidate within `threshold`. +fn snap_edge(v: f64, candidates: &[f64], threshold: f64) -> f64 { + candidates.iter().copied().filter(|c| (c - v).abs() <= threshold).min_by(|a, b| (a - v).abs().total_cmp(&(b - v).abs())).unwrap_or(v) +} + +impl Layout { + /// Page size in inches after orientation. + pub fn page_size(&self) -> Result<(f64, f64), String> { + self.page.size() + } + + /// The printable area (inside the margins). + pub fn printable(&self) -> Result<Rect, String> { + self.page.printable() + } + + /// Checks every number and cross-reference; also repairs `next_id` so ids stay unique. + /// Run on anything that did not come from the setters (commands, templates). + pub fn validate(&mut self) -> Result<(), String> { + let area = self.page.printable()?; + check(self.spacing, "spacing", 0.0, 10.0)?; + if !(1..=MAX_GRID).contains(&self.grid.columns) || !(1..=MAX_GRID).contains(&self.grid.rows) { + return Err(format!("columns and rows must be 1 to {MAX_GRID}")); + } + check(self.grid.spacing_x, "column spacing", 0.0, 10.0)?; + check(self.grid.spacing_y, "row spacing", 0.0, 10.0)?; + for r in self.grid_rects_for(area)? { + if r.w < MIN_CELL_IN || r.h < MIN_CELL_IN { + return Err("the grid leaves cells smaller than 0.1 in".into()); + } + } + if self.cells.len() > MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if !(1..=MAX_PAGES).contains(&self.pages) { + return Err(format!("pages must be 1 to {MAX_PAGES}")); + } + let mut seen = std::collections::BTreeSet::new(); + for c in &self.cells { + if !seen.insert(c.id) { + return Err(format!("duplicate cell id {}", c.id)); + } + if c.page >= self.pages { + return Err(format!("cell {} is on page {} but the layout has {} pages", c.id, c.page + 1, self.pages)); + } + self.check_rect(&c.rect, area)?; + } + self.next_id = self.next_id.max(self.cells.iter().map(|c| c.id).max().unwrap_or(0).saturating_add(1)).max(1); + Ok(()) + } + + fn check_rect(&self, r: &Rect, area: Rect) -> Result<(), String> { + if !r.is_finite() || r.w < MIN_CELL_IN - EPS || r.h < MIN_CELL_IN - EPS { + return Err(format!("cells must be at least {MIN_CELL_IN} in wide and tall")); + } + if !area.contains(r) { + return Err("a cell lies outside the printable area".into()); + } + Ok(()) + } + + /// The cells of one grid page in reading order (`singleImage`: one cell filling the area). + pub fn grid_rects(&self) -> Result<Vec<Rect>, String> { + self.grid_rects_for(self.page.printable()?) + } + + fn grid_rects_for(&self, area: Rect) -> Result<Vec<Rect>, String> { + if self.style == Style::SingleImage { + return Ok(vec![area]); + } + let g = &self.grid; + let (cols, rows) = (g.columns.clamp(1, MAX_GRID), g.rows.clamp(1, MAX_GRID)); + let gaps_x = g.spacing_x * (cols - 1) as f64; + let gaps_y = g.spacing_y * (rows - 1) as f64; + let mut cw = (area.w - gaps_x) / cols as f64; + let mut ch = (area.h - gaps_y) / rows as f64; + if !(cw > 0.0 && ch > 0.0) { + return Err("the grid spacing leaves no room for cells".into()); + } + let (mut ox, mut oy) = (area.x, area.y); + if g.keep_square { + let side = cw.min(ch); + ox += (cw - side) * cols as f64 / 2.0; + oy += (ch - side) * rows as f64 / 2.0; + cw = side; + ch = side; + } + let mut out = Vec::with_capacity(cols * rows); + for r in 0..rows { + for c in 0..cols { + out.push(Rect::new(ox + c as f64 * (cw + g.spacing_x), oy + r as f64 * (ch + g.spacing_y), cw, ch)); + } + } + Ok(out) + } + + /// Cells per page of a grid style; 1 for the package styles. + pub fn cells_per_page(&self) -> usize { + match self.style { + Style::SingleImage => 1, + Style::ContactSheet => self.grid.columns.clamp(1, MAX_GRID) * self.grid.rows.clamp(1, MAX_GRID), + Style::PicturePackage | Style::CustomPackage => 1, + } + } + + /// Pages a print of `photos` photos needs: the photos over the grid for the grid styles, the + /// layout's own pages for the packages. + pub fn page_count(&self, photos: usize) -> usize { + match self.style { + Style::SingleImage | Style::ContactSheet => photos.max(1).div_ceil(self.cells_per_page()), + Style::PicturePackage | Style::CustomPackage => self.pages.max(1) as usize, + } + } + + pub fn cell(&self, id: u32) -> Option<&Cell> { + self.cells.iter().find(|c| c.id == id) + } + + fn index(&self, id: u32) -> Result<usize, String> { + self.cells.iter().position(|c| c.id == id).ok_or_else(|| format!("no cell {id}")) + } + + /// Cells of one page, back to front. + pub fn cells_on_page(&self, page: u32) -> impl Iterator<Item = &Cell> { + self.cells.iter().filter(move |c| c.page == page) + } + + /// Sets the layout style. Entering a package style from a grid style keeps existing cells. + pub fn set_style(&mut self, style: Style) { + self.style = style; + } + + fn require_package(&self) -> Result<(), String> { + if self.style.is_package() { Ok(()) } else { Err("cells exist only in the Picture Package and Custom Package styles".into()) } + } + + /// Adds a `w` x `h` in cell at the first free spot of the earliest page with room (a new page + /// when none has), and returns its id. A cell too large for the printable area is refused. + pub fn add_cell(&mut self, w: f64, h: f64) -> Result<u32, String> { + self.require_package()?; + let area = self.printable()?; + check(w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?; + check(h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?; + if self.cells.len() >= MAX_CELLS { + return Err(format!("at most {MAX_CELLS} cells")); + } + if w > area.w + EPS || h > area.h + EPS { + return Err(format!("a {w:.2} x {h:.2} in cell is larger than the printable area ({:.2} x {:.2} in)", area.w, area.h)); + } + let padded = Rect::new(area.x, area.y, area.w + self.spacing, area.h + self.spacing); + let spot = (0..self.pages).find_map(|p| { + let mut free = Free::new(padded); + for c in self.cells_on_page(p) { + free.occupy(&Rect::new(c.rect.x, c.rect.y, c.rect.w + self.spacing, c.rect.h + self.spacing)); + } + free.best_fit(w + self.spacing, h + self.spacing, false).map(|(r, _)| (p, r)) + }); + let (page, at) = match spot { + Some(v) => v, + None => { + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + (self.pages - 1, Rect::new(area.x, area.y, w, h)) + } + }; + let id = self.take_id(); + self.cells.push(Cell { id, page, rect: Rect::new(at.x, at.y, w, h), photo: None }); + Ok(id) + } + + fn take_id(&mut self) -> u32 { + let id = self.next_id.max(1); + self.next_id = id.saturating_add(1); + id + } + + /// Moves a cell so its top-left corner is at (`x`, `y`), kept inside the printable area. + pub fn move_cell(&mut self, id: u32, x: f64, y: f64) -> Result<Rect, String> { + self.require_package()?; + let area = self.printable()?; + if !x.is_finite() || !y.is_finite() { + return Err("position must be finite".into()); + } + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + let r = &mut cell.rect; + r.x = x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + Ok(*r) + } + + /// Gives a cell a new rectangle (position and size), clamped into the printable area. + pub fn resize_cell(&mut self, id: u32, rect: Rect) -> Result<Rect, String> { + self.require_package()?; + let area = self.printable()?; + if !rect.is_finite() { + return Err("rectangle must be finite".into()); + } + let w = check(rect.w, "cell width", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.w); + let h = check(rect.h, "cell height", MIN_CELL_IN, MAX_PAGE_IN)?.min(area.h); + let i = self.index(id)?; + let cell = self.cells.get_mut(i).ok_or("no such cell")?; + cell.rect = Rect::new(rect.x.clamp(area.x, (area.right() - w).max(area.x)), rect.y.clamp(area.y, (area.bottom() - h).max(area.y)), w, h); + Ok(cell.rect) + } + + pub fn delete_cell(&mut self, id: u32) -> Result<(), String> { + let i = self.index(id)?; + self.cells.remove(i); + Ok(()) + } + + /// Removes every cell and extra page (Lightroom's Clear Layout). + pub fn clear(&mut self) { + self.cells.clear(); + self.pages = 1; + } + + /// Appends an empty page to a package layout and returns its zero-based index. + pub fn new_page(&mut self) -> Result<u32, String> { + self.require_package()?; + if self.pages >= MAX_PAGES { + return Err(format!("a layout holds at most {MAX_PAGES} pages")); + } + self.pages += 1; + Ok(self.pages - 1) + } + + /// Moves a cell to another page, keeping its rectangle (clamped inside the printable area). + pub fn set_cell_page(&mut self, id: u32, page: u32) -> Result<(), String> { + self.require_package()?; + if page >= self.pages { + return Err(format!("the layout has {} pages", self.pages)); + } + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.page = page; + } + Ok(()) + } + + /// Assigns (or with `None` clears) the photo printed in a cell. + pub fn set_cell_photo(&mut self, id: u32, photo: Option<u64>) -> Result<(), String> { + let i = self.index(id)?; + if let Some(c) = self.cells.get_mut(i) { + c.photo = photo; + } + Ok(()) + } + + /// Changes a cell's place in the stacking order of its page (Send to Back, Backward, Forward, + /// Bring to Front). Cells on other pages are not moved past. + pub fn restack(&mut self, id: u32, how: Stack) -> Result<(), String> { + let i = self.index(id)?; + let page = self.cells.get(i).map(|c| c.page).ok_or("no such cell")?; + let same: Vec<usize> = self.cells.iter().enumerate().filter(|(_, c)| c.page == page).map(|(k, _)| k).collect(); + let pos = same.iter().position(|&k| k == i).ok_or("no such cell")?; + let target = match how { + Stack::ToBack => 0, + Stack::Backward => pos.saturating_sub(1), + Stack::Forward => (pos + 1).min(same.len().saturating_sub(1)), + Stack::ToFront => same.len().saturating_sub(1), + }; + if target == pos { + return Ok(()); + } + let cell = self.cells.remove(i); + // Index in the shortened vector where the target neighbour now sits. + let at = same.get(target).map(|&k| if k > i { k - 1 } else { k }).ok_or("no such cell")?; + let at = if target > pos { at + 1 } else { at }; + self.cells.insert(at.min(self.cells.len()), cell); + Ok(()) + } + + /// Re-packs every cell of the package onto as few pages as possible (Auto Layout). Cells keep + /// their sizes (a cell may be turned a quarter to fit) and their stacking order; their + /// photos are untouched. Fails, changing nothing, when a cell cannot fit the printable area. + pub fn auto_layout(&mut self) -> Result<(), String> { + self.require_package()?; + let area = self.printable()?; + let sizes: Vec<(f64, f64)> = self.cells.iter().map(|c| (c.rect.w, c.rect.h)).collect(); + let placed = pack(&sizes, area, self.spacing, true)?; + let mut pages = 1; + for (c, (page, rect, _)) in self.cells.iter_mut().zip(placed) { + c.page = page; + c.rect = rect; + pages = pages.max(page + 1); + } + self.pages = pages; + Ok(()) + } + + /// Pulls every cell back inside the printable area (shrinking one that is bigger than it), for + /// after the paper, orientation or margins changed. + pub fn fit_cells(&mut self) -> Result<(), String> { + let area = self.printable()?; + for c in &mut self.cells { + let r = &mut c.rect; + if !r.is_finite() { + continue; + } + r.w = r.w.min(area.w); + r.h = r.h.min(area.h); + r.x = r.x.clamp(area.x, (area.right() - r.w).max(area.x)); + r.y = r.y.clamp(area.y, (area.bottom() - r.h).max(area.y)); + } + Ok(()) + } + + /// Whether two cells on the same page overlap. + pub fn has_overlaps(&self) -> bool { + self.cells.iter().enumerate().any(|(i, a)| self.cells.iter().skip(i + 1).any(|b| a.page == b.page && a.rect.overlaps(&b.rect))) + } + + /// Snap candidates along each axis for cell `skip` on `page`: edges of other cells and of the + /// printable area, or grid lines. + fn candidates(&self, page: u32, skip: Option<u32>, snap: Snap, grid: f64) -> Result<(Vec<f64>, Vec<f64>), String> { + let area = self.printable()?; + let (mut xs, mut ys) = (Vec::new(), Vec::new()); + match snap { + Snap::Off => {} + Snap::Cells => { + xs.extend([area.x, area.right()]); + ys.extend([area.y, area.bottom()]); + for c in self.cells_on_page(page).filter(|c| Some(c.id) != skip) { + xs.extend([c.rect.x, c.rect.right()]); + ys.extend([c.rect.y, c.rect.bottom()]); + } + } + Snap::Grid => { + let grid = check(grid, "grid size", 0.05, 10.0)?; + let n = |len: f64| (len / grid).floor().min(4000.0) as usize; + xs.extend((0..=n(area.w)).map(|k| area.x + k as f64 * grid)); + ys.extend((0..=n(area.h)).map(|k| area.y + k as f64 * grid)); + xs.push(area.right()); + ys.push(area.bottom()); + } + } + Ok((xs, ys)) + } + + /// Snaps a cell being dragged: `rect` keeps its size, and its left or right edge (likewise top + /// or bottom) lands on a candidate within `threshold` inches. + pub fn snap_move(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result<Rect, String> { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let area = self.printable()?; + let x = snap_span(rect.x, rect.w, &xs, threshold.max(0.0)); + let y = snap_span(rect.y, rect.h, &ys, threshold.max(0.0)); + Ok(Rect::new(x.clamp(area.x, (area.right() - rect.w).max(area.x)), y.clamp(area.y, (area.bottom() - rect.h).max(area.y)), rect.w, rect.h)) + } + + /// Snaps a cell being resized by its bottom-right corner: the right and bottom edges land on + /// candidates within `threshold`, the top-left corner stays. + pub fn snap_resize(&self, id: u32, rect: Rect, snap: Snap, grid: f64, threshold: f64) -> Result<Rect, String> { + if !rect.is_finite() || !threshold.is_finite() { + return Err("rectangle must be finite".into()); + } + let page = self.cell(id).map(|c| c.page).ok_or_else(|| format!("no cell {id}"))?; + let (xs, ys) = self.candidates(page, Some(id), snap, grid)?; + let right = snap_edge(rect.right(), &xs, threshold.max(0.0)); + let bottom = snap_edge(rect.bottom(), &ys, threshold.max(0.0)); + Ok(Rect::new(rect.x, rect.y, (right - rect.x).max(MIN_CELL_IN), (bottom - rect.y).max(MIN_CELL_IN))) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn package() -> Layout { + Layout { style: Style::PicturePackage, ..Default::default() } + } + + fn assert_clean(l: &Layout) { + let area = l.printable().unwrap(); + for c in &l.cells { + assert!(area.contains(&c.rect), "{c:?} outside {area:?}"); + assert!(c.page < l.pages); + } + assert!(!l.has_overlaps(), "{:#?}", l.cells); + } + + #[test] + fn papers_and_orientation() { + let mut p = PageSetup::default(); + assert_eq!(p.size().unwrap(), (8.5, 11.0)); + p.orientation = Orientation::Landscape; + assert_eq!(p.size().unwrap(), (11.0, 8.5)); + assert_eq!(p.size_points().unwrap(), (792.0, 612.0)); + p.paper = "A4".into(); + let (w, h) = p.size().unwrap(); + assert!((w - 11.693).abs() < 1e-3 && (h - 8.268).abs() < 1e-3); + for n in paper_names() { + p.paper = n.into(); + assert!(p.printable().is_ok(), "{n}"); + } + p.paper = "custom".into(); + p.custom_width = 20.0; + p.custom_height = 30.0; + assert_eq!(p.size().unwrap(), (30.0, 20.0)); + p.paper = "poster".into(); + assert!(p.size().is_err()); + } + + #[test] + fn hostile_numbers_are_rejected() { + for bad in [f64::NAN, f64::INFINITY, f64::NEG_INFINITY, -1.0, 1e9] { + let p = PageSetup { paper: "custom".into(), custom_width: bad, ..Default::default() }; + assert!(p.size().is_err(), "{bad}"); + let p = PageSetup { margins: Margins { left: bad, ..Margins::default() }, ..Default::default() }; + assert!(p.printable().is_err(), "{bad}"); + let mut l = package(); + assert!(l.add_cell(bad, 2.0).is_err(), "{bad}"); + assert!(l.add_cell(2.0, bad).is_err()); + let id = l.add_cell(2.0, 2.0).unwrap(); + assert!(bad.is_finite() || l.move_cell(id, bad, 0.0).is_err()); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, bad, 1.0)).is_err()); + assert!(bad.is_finite() || l.snap_move(id, Rect::new(bad, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + let mut g = Layout { spacing: bad, ..package() }; + assert!(g.validate().is_err()); + assert!(pack(&[(1.0, 1.0)], l.printable().unwrap(), bad, true).is_err()); + assert!(pack(&[(bad, 1.0)], l.printable().unwrap(), 0.0, true).is_err()); + } + // Margins that leave nothing. + let p = PageSetup { margins: Margins::uniform(4.5), ..Default::default() }; + assert!(p.printable().is_err()); + let mut l = Layout { grid: GridSpec { columns: 0, ..Default::default() }, ..Default::default() }; + assert!(l.validate().is_err()); + l.grid.columns = 21; + assert!(l.validate().is_err()); + } + + #[test] + fn units_convert() { + for u in [Unit::Inches, Unit::Centimeters, Unit::Millimeters, Unit::Points] { + assert!((u.to_inches(u.from_inches(3.5)) - 3.5).abs() < 1e-12); + } + assert!((Unit::Millimeters.from_inches(1.0) - 25.4).abs() < 1e-12); + assert_eq!(Unit::Points.from_inches(2.0), 144.0); + assert_eq!(serde_json::to_string(&Unit::Centimeters).unwrap(), "\"cm\""); + } + + #[test] + fn contact_grid_fills_the_printable_area() { + let l = Layout { + style: Style::ContactSheet, + grid: GridSpec { columns: 3, rows: 4, spacing_x: 0.2, spacing_y: 0.1, keep_square: false }, + ..Default::default() + }; + let area = l.printable().unwrap(); + let cells = l.grid_rects().unwrap(); + assert_eq!(cells.len(), 12); + let first = cells.first().unwrap(); + let last = cells.last().unwrap(); + assert!((first.x - area.x).abs() < 1e-9 && (first.y - area.y).abs() < 1e-9); + assert!((last.right() - area.right()).abs() < 1e-9 && (last.bottom() - area.bottom()).abs() < 1e-9); + for (i, a) in cells.iter().enumerate() { + assert!((a.w - first.w).abs() < 1e-12 && (a.h - first.h).abs() < 1e-12); + for b in cells.iter().skip(i + 1) { + assert!(!a.overlaps(b)); + } + } + // Square cells are centred. + let sq = Layout { grid: GridSpec { keep_square: true, ..l.grid }, ..l.clone() }; + let cells = sq.grid_rects().unwrap(); + assert!(cells.iter().all(|c| (c.w - c.h).abs() < 1e-12)); + let (lo, hi) = (cells.iter().map(|c| c.x).fold(f64::MAX, f64::min), cells.iter().map(|c| c.right()).fold(f64::MIN, f64::max)); + assert!(((lo - area.x) - (area.right() - hi)).abs() < 1e-9); + // Single image ignores the grid. + let one = Layout { style: Style::SingleImage, ..l }; + assert_eq!(one.grid_rects().unwrap(), vec![area]); + } + + #[test] + fn page_counts() { + let mut l = Layout { style: Style::ContactSheet, grid: GridSpec { columns: 2, rows: 2, ..Default::default() }, ..Default::default() }; + assert_eq!(l.page_count(0), 1); + assert_eq!(l.page_count(4), 1); + assert_eq!(l.page_count(5), 2); + l.style = Style::SingleImage; + assert_eq!(l.page_count(3), 3); + l.style = Style::PicturePackage; + l.pages = 4; + assert_eq!(l.page_count(99), 4); + } + + #[test] + fn packer_is_deterministic_and_clean() { + let area = Layout::default().printable().unwrap(); + let sizes: Vec<(f64, f64)> = + [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (3.5, 5.0), (8.0, 10.0), (4.0, 6.0), (2.0, 2.0), (1.0, 1.5), (5.0, 7.0), (4.0, 6.0)] + .to_vec(); + let a = pack(&sizes, area, 0.1, true).unwrap(); + assert_eq!(a, pack(&sizes, area, 0.1, true).unwrap()); + assert_eq!(a.len(), sizes.len()); + for (i, (p, r, turned)) in a.iter().enumerate() { + assert!(area.contains(r), "{r:?}"); + let (w, h) = sizes[i]; + let expect = if *turned { (h, w) } else { (w, h) }; + assert!((r.w - expect.0).abs() < 1e-9 && (r.h - expect.1).abs() < 1e-9); + for (j, (q, s, _)) in a.iter().enumerate().skip(i + 1) { + if p == q { + // The spacing is kept between neighbours. + let grown = Rect::new(r.x, r.y, r.w + 0.1 - 2e-6, r.h + 0.1 - 2e-6); + assert!(!grown.overlaps(s), "{i} and {j}: {r:?} {s:?}"); + } + } + } + // The input order does not hide a block that needs a page of its own. + assert!(a.iter().map(|p| p.0).max().unwrap() >= 1); + } + + #[test] + fn packer_rotates_only_when_needed_and_refuses_oversize() { + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + // 10 x 5 only fits a portrait page turned. + let r = pack(&[(10.0, 5.0)], area, 0.0, true).unwrap(); + assert!(r[0].2 && (r[0].1.w - 5.0).abs() < 1e-9 && (r[0].1.h - 10.0).abs() < 1e-9); + assert!(pack(&[(10.0, 5.0)], area, 0.0, false).is_err()); + let r = pack(&[(4.0, 6.0)], area, 0.0, true).unwrap(); + assert!(!r[0].2, "a block that fits as given is not turned"); + assert!(pack(&[(9.0, 11.0)], area, 0.0, true).is_err()); + // A block that exactly fills the area fits despite the spacing. + assert!(pack(&[(8.0, 10.5)], area, 0.2, true).is_ok()); + assert!(pack(&vec![(1.0, 1.0); MAX_CELLS + 1], area, 0.0, true).is_err()); + // More pages than allowed. + assert!(pack(&vec![(8.0, 10.5); 51], area, 0.0, true).is_err()); + assert_eq!(pack(&vec![(8.0, 10.5); 50], area, 0.0, true).unwrap().iter().map(|p| p.0).max(), Some(49)); + } + + #[test] + fn packing_is_dense() { + // Four 4x5.25 cells tile a letter page's 8 x 10.5 area exactly. + let area = Rect::new(0.25, 0.25, 8.0, 10.5); + let r = pack(&[(4.0, 5.25); 4], area, 0.0, false).unwrap(); + assert!(r.iter().all(|p| p.0 == 0)); + // Eight 4x6 photos fit two pages (four per page with the rotation trick is impossible: 4x6 x2 per column). + let r = pack(&[(4.0, 6.0); 4], area, 0.0, true).unwrap(); + assert_eq!(r.iter().map(|p| p.0).max(), Some(1)); + } + + #[test] + fn add_cell_fills_gaps_then_opens_pages() { + let mut l = package(); + let a = l.add_cell(3.9, 6.0).unwrap(); + let b = l.add_cell(3.9, 6.0).unwrap(); + assert_eq!(l.cell(a).unwrap().rect.x, 0.25); + assert_ne!(l.cell(a).unwrap().rect, l.cell(b).unwrap().rect); + assert_clean(&l); + // Keep adding until a second page appears. + for _ in 0..6 { + l.add_cell(3.9, 6.0).unwrap(); + } + assert!(l.pages >= 2); + assert_clean(&l); + assert_eq!(l.cells.len(), 8); + assert!(l.add_cell(9.0, 9.0).is_err(), "wider than the printable area"); + let mut grid = Layout::default(); + assert!(grid.add_cell(1.0, 1.0).is_err()); + } + + #[test] + fn cell_cap() { + let mut l = package(); + for _ in 0..MAX_CELLS { + if l.add_cell(0.5, 0.5).is_err() { + break; + } + } + assert!(l.cells.len() <= MAX_CELLS); + let n = l.cells.len(); + if n == MAX_CELLS { + assert!(l.add_cell(0.5, 0.5).is_err()); + } + } + + #[test] + fn move_and_resize_stay_inside() { + let mut l = package(); + let id = l.add_cell(2.0, 3.0).unwrap(); + let r = l.move_cell(id, 100.0, -5.0).unwrap(); + let area = l.printable().unwrap(); + assert!((r.right() - area.right()).abs() < 1e-9 && (r.y - area.y).abs() < 1e-9); + let r = l.resize_cell(id, Rect::new(7.0, 9.0, 50.0, 50.0)).unwrap(); + assert!(area.contains(&r)); + assert!(l.resize_cell(id, Rect::new(0.0, 0.0, 0.0, 1.0)).is_err()); + assert!(l.move_cell(77, 0.0, 0.0).is_err()); + l.delete_cell(id).unwrap(); + assert!(l.delete_cell(id).is_err()); + } + + #[test] + fn stacking_order() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let ids: Vec<u32> = (0..4).map(|_| l.add_cell(1.0, 1.0).unwrap()).collect(); + let order = |l: &Layout| l.cells.iter().map(|c| c.id).collect::<Vec<_>>(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::ToFront).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[0], ids[3]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::Forward).unwrap(); + assert_eq!(order(&l), vec![ids[1], ids[2], ids[3], ids[0]]); + l.restack(ids[0], Stack::ToBack).unwrap(); + assert_eq!(order(&l), ids); + l.restack(ids[0], Stack::Backward).unwrap(); + assert_eq!(order(&l), ids); + assert!(l.restack(99, Stack::ToBack).is_err()); + } + + #[test] + fn stacking_ignores_other_pages() { + let mut l = Layout { style: Style::CustomPackage, pages: 2, ..Default::default() }; + let a = l.add_cell(1.0, 1.0).unwrap(); + let b = l.add_cell(1.0, 1.0).unwrap(); + let c = l.add_cell(1.0, 1.0).unwrap(); + l.set_cell_page(b, 1).unwrap(); + l.restack(a, Stack::ToFront).unwrap(); + // `a` goes past `c` (same page) and not necessarily past `b`. + let pos = |id| l.cells.iter().position(|x| x.id == id).unwrap(); + assert!(pos(a) > pos(c)); + } + + #[test] + fn overlap_is_allowed_by_move_and_reported() { + let mut l = Layout { style: Style::CustomPackage, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(3.0, 3.0).unwrap(); + assert!(!l.has_overlaps()); + let r = l.cell(a).unwrap().rect; + l.move_cell(b, r.x + 1.0, r.y + 1.0).unwrap(); + assert!(l.has_overlaps()); + // Touching edges are not an overlap. + l.move_cell(b, r.right(), r.y).unwrap(); + assert!(!l.has_overlaps()); + } + + #[test] + fn auto_layout_repacks_cleanly_and_keeps_photos() { + let mut l = package(); + for (w, h) in [(4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (8.0, 10.0), (4.0, 6.0), (3.5, 5.0)] { + l.add_cell(w, h).unwrap(); + } + let first = l.cells[0].id; + l.set_cell_photo(first, Some(42)).unwrap(); + // Scramble positions to overlap, then auto layout. + for c in &mut l.cells { + c.rect.x = 0.25; + c.rect.y = 0.25; + c.page = 0; + } + l.pages = 1; + assert!(l.has_overlaps()); + l.auto_layout().unwrap(); + assert_clean(&l); + assert_eq!(l.cell(first).unwrap().photo, Some(42)); + assert_eq!(l.cells.len(), 6); + let again = l.clone(); + let mut l2 = again.clone(); + l2.auto_layout().unwrap(); + l.auto_layout().unwrap(); + assert_eq!(l, l2); + } + + #[test] + fn auto_layout_failure_changes_nothing() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + l.cells.push(Cell { id: 50, page: 0, rect: Rect::new(0.25, 0.25, 40.0, 40.0), photo: None }); + let before = l.clone(); + assert!(l.auto_layout().is_err()); + assert_eq!(l, before); + } + + #[test] + fn clear_and_new_page() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + assert_eq!(l.new_page().unwrap(), 1); + assert_eq!(l.pages, 2); + l.clear(); + assert_eq!((l.cells.len(), l.pages), (0, 1)); + for _ in 1..MAX_PAGES { + l.new_page().unwrap(); + } + assert!(l.new_page().is_err()); + let mut g = Layout::default(); + assert!(g.new_page().is_err()); + } + + #[test] + fn validate_catches_bad_cells() { + let mut l = package(); + l.add_cell(2.0, 2.0).unwrap(); + let ok = l.clone(); + let mut bad = ok.clone(); + bad.cells[0].rect.w = f64::NAN; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].page = 3; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells[0].rect.x = 50.0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.cells.push(bad.cells[0].clone()); + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.pages = 0; + assert!(bad.validate().is_err()); + let mut bad = ok.clone(); + bad.next_id = 0; + bad.validate().unwrap(); + assert!(bad.next_id > bad.cells[0].id); + assert!(serde_json::from_str::<Layout>(r#"{"surprise":1}"#).is_err()); + let mut round: Layout = serde_json::from_str(&serde_json::to_string(&ok).unwrap()).unwrap(); + round.validate().unwrap(); + assert_eq!(round, ok); + } + + #[test] + fn snapping_to_cells_grid_and_margins() { + let mut l = Layout { style: Style::CustomPackage, spacing: 0.0, ..Default::default() }; + let a = l.add_cell(3.0, 3.0).unwrap(); + let b = l.add_cell(2.0, 2.0).unwrap(); + let ra = l.cell(a).unwrap().rect; + // Left edge of b near the right edge of a. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - ra.right()).abs() < 1e-9); + assert_eq!(m.y, 5.0); + // Too far: untouched. + let m = l.snap_move(b, Rect::new(ra.right() + 0.5, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.5)).abs() < 1e-9); + // Right edge snaps to the printable margin. + let area = l.printable().unwrap(); + let m = l.snap_move(b, Rect::new(area.right() - 2.0 - 0.03, 5.0, 2.0, 2.0), Snap::Cells, 0.25, 0.1).unwrap(); + assert!((m.right() - area.right()).abs() < 1e-9); + // Off leaves everything. + let m = l.snap_move(b, Rect::new(ra.right() + 0.04, 5.0, 2.0, 2.0), Snap::Off, 0.25, 0.1).unwrap(); + assert!((m.x - (ra.right() + 0.04)).abs() < 1e-9); + // Grid snapping lands on multiples from the printable corner. + let m = l.snap_move(b, Rect::new(1.37, 2.62, 2.0, 2.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(((m.x - area.x) / 0.5).fract().abs() < 1e-9 || (((m.x - area.x) / 0.5).fract() - 1.0).abs() < 1e-9); + // Resize snaps the dragged corner only. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 2.1, 1.9), Snap::Grid, 0.5, 0.2).unwrap(); + assert_eq!((r.x, r.y), (5.0, 5.0)); + assert!(((r.right() - area.x) / 0.5 - ((r.right() - area.x) / 0.5).round()).abs() < 1e-9); + // Never below the minimum size. + let r = l.snap_resize(b, Rect::new(5.0, 5.0, 0.0, 0.0), Snap::Grid, 0.5, 0.2).unwrap(); + assert!(r.w >= MIN_CELL_IN && r.h >= MIN_CELL_IN); + assert!(l.snap_move(99, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Cells, 0.25, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, 0.0, 0.1).is_err()); + assert!(l.snap_move(b, Rect::new(0.0, 0.0, 1.0, 1.0), Snap::Grid, f64::NAN, 0.1).is_err()); + } + + #[test] + fn fit_cells_follows_the_margins() { + let mut l = package(); + let id = l.add_cell(7.0, 10.0).unwrap(); + l.page.margins = Margins::uniform(1.0); + assert!(l.validate().is_err(), "the cell no longer fits"); + l.fit_cells().unwrap(); + l.validate().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!(l.printable().unwrap().contains(&r)); + assert!((r.w - 6.5).abs() < 1e-9 && (r.h - 9.0).abs() < 1e-9); + l.page.paper = "poster".into(); + assert!(l.fit_cells().is_err()); + } + + #[test] + fn cell_aspect_survives_auto_layout() { + let mut l = package(); + let id = l.add_cell(2.5, 3.5).unwrap(); + l.auto_layout().unwrap(); + let r = l.cell(id).unwrap().rect; + assert!((r.w / r.h - 2.5 / 3.5).abs() < 1e-9 || (r.h / r.w - 2.5 / 3.5).abs() < 1e-9); + } + + #[test] + fn guides_validate() { + assert!(Guides::default().validate().is_ok()); + for bad in [f64::NAN, 0.0, -1.0, 1e9] { + assert!(Guides { grid_size: bad, ..Default::default() }.validate().is_err()); + } + } + + #[test] + fn rect_helpers() { + let a = Rect::new(0.0, 0.0, 2.0, 2.0); + assert!(a.overlaps(&Rect::new(1.0, 1.0, 2.0, 2.0))); + assert!(!a.overlaps(&Rect::new(2.0, 0.0, 1.0, 1.0))); + assert!(a.contains(&Rect::new(0.5, 0.5, 1.0, 1.0))); + assert!(!a.contains(&Rect::new(1.5, 0.5, 1.0, 1.0))); + assert_eq!(a.area(), 4.0); + } +} diff --git a/crates/engine/src/print/mod.rs b/crates/engine/src/print/mod.rs new file mode 100644 index 000000000..c984cbf54 --- /dev/null +++ b/crates/engine/src/print/mod.rs @@ -0,0 +1,91 @@ +//! The Print module: page layout, composition of pages and PDF output. +//! +//! [`layout`] is the pure page model (paper, margins, cells); [`compose`] renders a layout and its +//! photos to [`PageRaster`]s; [`template`] holds the saved settings; [`output`] writes the pages +//! as a PDF. +pub mod compose; +pub mod layout; +pub mod output; +pub mod template; + +use serde::{Deserialize, Serialize}; + +/// Lowest and highest print resolution, pixels per inch. +pub const MIN_PPI: f32 = 72.0; +pub const MAX_PPI: f32 = 720.0; +/// Pages a single print may have. +pub const MAX_JOB_PAGES: usize = 100; +/// Most pixels one page may have (about 100 megapixels: A3 at 720 ppi is 60). +pub const MAX_PAGE_PIXELS: u64 = 100_000_000; +/// Most pixels a print job may hold across all its pages (6 bytes each in memory). +pub const MAX_JOB_PIXELS: u64 = 200_000_000; + +/// One composed page, ready for output: row-major interleaved RGB, 16 bits per sample, encoded in +/// Rec. 2020 ([`lightcraft_pipeline::OutputSpace::Rec2020`]; the transfer curve is that space's). +/// The page covers its paper edge to edge: `width / ppi` by `height / ppi` inches. +#[derive(Clone, Debug, PartialEq)] +pub struct PageRaster { + pub width: u32, + pub height: u32, + pub ppi: f32, + pub rgb: Vec<u16>, +} + +/// How a print job is produced (Lightroom's Print Job panel): resolution, and the colour +/// management, print adjustment, print sharpening and depth of [`crate::print_color`]. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintJob { + /// Print resolution in pixels per inch, 72 to 720. + pub ppi: f32, + /// Profile, rendering intent, black point compensation, print adjustment, print sharpening. + pub color: crate::print_color::PrintColorSettings, +} + +impl Default for PrintJob { + fn default() -> Self { + Self { ppi: 240.0, color: crate::print_color::PrintColorSettings::default() } + } +} + +impl PrintJob { + /// The resolution to render at: [`Self::ppi`] clamped to 72..=720 (240 for NaN). + pub fn effective_ppi(&self) -> f32 { + if self.ppi.is_finite() { self.ppi.clamp(MIN_PPI, MAX_PPI) } else { 240.0 } + } + + /// Rejects a resolution outside the supported range (commands and templates, which should + /// say so rather than silently clamp). + pub fn validate(&self) -> Result<(), String> { + if !(self.ppi.is_finite() && (MIN_PPI..=MAX_PPI).contains(&self.ppi)) { + return Err(format!("ppi must be a number from {MIN_PPI} to {MAX_PPI}")); + } + self.color.validate() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ppi_is_clamped_and_validated() { + assert_eq!(PrintJob::default().effective_ppi(), 240.0); + for (v, e) in [(10.0, 72.0), (5000.0, 720.0), (f32::NAN, 240.0), (f32::INFINITY, 240.0), (300.0, 300.0)] { + assert_eq!(PrintJob { ppi: v, ..Default::default() }.effective_ppi(), e); + } + assert!(PrintJob { ppi: 300.0, ..Default::default() }.validate().is_ok()); + for bad in [0.0, 71.0, 721.0, f32::NAN, f32::NEG_INFINITY] { + assert!(PrintJob { ppi: bad, ..Default::default() }.validate().is_err()); + } + let j: PrintJob = serde_json::from_str("{}").unwrap(); + assert_eq!(j, PrintJob::default()); + assert!(serde_json::from_str::<PrintJob>(r#"{"dpi": 1}"#).is_err()); + // the colour settings ride along, defaults filled in, and are validated with the job + let j: PrintJob = serde_json::from_str(r#"{"ppi": 300, "color": {"intent": "relative", "brightness": 20}}"#).unwrap(); + assert_eq!(j.color.intent, crate::print_color::PrintIntent::Relative); + assert!(j.color.black_point_compensation && j.validate().is_ok()); + let j: PrintJob = serde_json::from_str(r#"{"color": {"contrast": 500}}"#).unwrap(); + assert!(j.validate().is_err()); + } +} diff --git a/crates/engine/src/print/output.rs b/crates/engine/src/print/output.rs new file mode 100644 index 000000000..37881fbb8 --- /dev/null +++ b/crates/engine/src/print/output.rs @@ -0,0 +1,136 @@ +//! Writes composed pages as a print-ready PDF: each page is converted to the job's colour target +//! (a printer / paper profile, or sRGB / Adobe RGB when the printer manages colour) with its +//! intent, black point compensation and print adjustment ([`crate::print_color`]), then embedded +//! covering the paper as an ICC-tagged, losslessly compressed 16-bit (or 8-bit) image. A printer +//! profile is also named as the document's output intent ([`crate::print_pdf`]). +use super::{MAX_JOB_PAGES, PageRaster, PrintJob}; +use crate::print_color::PrintColorJob; +use crate::print_pdf::ImageData; +use lightcraft_color::transfer::encode_srgb8; +use lightcraft_color::{REC2020, SRGB}; +use lightcraft_pipeline::OutputSpace; +use lightcraft_raster::Rgba8; + +/// Largest PDF produced, bytes. +const MAX_PDF_BYTES: usize = 256 * 1024 * 1024; + +/// A Rec. 2020 page as 8-bit sRGB (out-of-gamut colours are clipped). +pub fn page_to_srgb8(page: &PageRaster) -> Result<Rgba8, String> { + let (w, h) = (page.width as usize, page.height as usize); + let n = w.checked_mul(h).ok_or("page too large")?; + if n == 0 || page.rgb.len() != n.checked_mul(3).ok_or("page too large")? { + return Err("a page's pixel data does not match its size".into()); + } + let trc = OutputSpace::Rec2020.trc(); + // 16-bit Rec. 2020 code → linear light, once per code value. + let lut: Vec<f32> = (0..=u16::MAX).map(|v| trc.decode(f32::from(v) / 65535.0)).collect(); + let m = REC2020.to_space(&SRGB).to_f32(); + let mut out = Rgba8::new(w, h); + for (dst, src) in out.data.iter_mut().zip(page.rgb.as_chunks::<3>().0) { + let l = src.map(|v| lut.get(usize::from(v)).copied().unwrap_or(0.0)); + let row = |r: usize| m.get(r).map_or(0.0, |m| m[0] * l[0] + m[1] * l[1] + m[2] * l[2]); + *dst = [encode_srgb8(row(0)), encode_srgb8(row(1)), encode_srgb8(row(2)), 255]; + } + Ok(out) +} + +/// The PDF for `pages` (all the same size): one page per raster, its MediaBox the paper size in +/// points (`pixels / ppi * 72`) with the image covering it. Each page is converted and compressed +/// in turn, so memory holds one page's device samples at a time. +pub fn write(pages: &[PageRaster], job: &PrintJob, title: &str) -> Result<Vec<u8>, String> { + let first = pages.first().ok_or("nothing to print")?; + if pages.len() > MAX_JOB_PAGES { + return Err(format!("a print is limited to {MAX_JOB_PAGES} pages")); + } + if !(first.ppi.is_finite() && first.ppi > 0.0) { + return Err("a page has no resolution".into()); + } + let points = |px: u32| f64::from(px) / f64::from(first.ppi) * 72.0; + let size = (points(first.width), points(first.height)); + let color = PrintColorJob::new(&job.color)?; + let mut out = Vec::with_capacity(pages.len()); + let mut bytes = 0usize; + for page in pages { + if (page.width, page.height) != (first.width, first.height) { + return Err("all pages of a PDF must be the same size".into()); + } + let image = color.page_image(page.width, page.height, &page.rgb)?.deflated()?; + if let ImageData::Flate { data, .. } = &image.data { + bytes = bytes.saturating_add(data.len()); + } + if bytes > MAX_PDF_BYTES { + return Err("the PDF would exceed 256 MiB; lower the print resolution, use 8 bits or print fewer pages".into()); + } + out.push((size, image)); + } + color.pdf(out, title) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::compose::rec2020_from_srgb8; + + fn raster(w: u32, h: u32, srgb: [u8; 3]) -> PageRaster { + let c = rec2020_from_srgb8(srgb); + PageRaster { width: w, height: h, ppi: 100.0, rgb: (0..w * h).flat_map(|_| c).collect() } + } + + #[test] + fn colours_survive_the_round_trip() { + for c in [[255u8, 255, 255], [0, 0, 0], [128, 128, 128], [200, 30, 30], [10, 200, 90]] { + let px = page_to_srgb8(&raster(2, 2, c)).unwrap().data[0]; + for k in 0..3 { + assert!(px[k].abs_diff(c[k]) <= 1, "{c:?} -> {px:?}"); + } + } + assert!(page_to_srgb8(&PageRaster { width: 2, height: 2, ppi: 100.0, rgb: vec![0; 5] }).is_err()); + assert!(page_to_srgb8(&PageRaster { width: 0, height: 2, ppi: 100.0, rgb: vec![] }).is_err()); + } + + #[test] + fn pdf_pages_have_the_paper_size() { + let pages = [raster(425, 550, [255, 255, 255]), raster(425, 550, [0, 0, 255])]; + let bytes = write(&pages, &PrintJob::default(), "t").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-")); + assert!(text.contains("/Count 2")); + let at = text.find("/MediaBox [").unwrap() + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + assert_eq!((nums[2], nums[3]), (306.0, 396.0), "4.25 x 5.5 in is 306 x 396 pt"); + assert!(text.contains("/Width 425 /Height 550")); + // managed by the printer: sRGB, 16 bits, tagged, no output intent + assert!(text.contains("/ICCBased") && text.contains("/BitsPerComponent 16") && !text.contains("/OutputIntents")); + assert!(write(&[], &PrintJob::default(), "t").is_err()); + let mixed = [raster(10, 10, [0; 3]), raster(10, 12, [0; 3])]; + assert!(write(&mixed, &PrintJob::default(), "t").is_err()); + let bad = PageRaster { ppi: f32::NAN, ..raster(4, 4, [0; 3]) }; + assert!(write(&[bad], &PrintJob::default(), "t").is_err()); + let zero = PageRaster { ppi: 0.0, ..raster(4, 4, [0; 3]) }; + assert!(write(&[zero], &PrintJob::default(), "t").is_err()); + } + + #[test] + fn a_printer_profile_is_converted_to_and_named_as_the_output_intent() { + use crate::print_color::{PrintColorSettings, PrintIntent, PrintProfile}; + let dir = std::env::temp_dir().join(format!("lc-print-output-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Lustre Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = + PrintColorSettings { profile: PrintProfile::Icc { path: icc }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintJob { color, ..Default::default() }; + let bytes = write(&[raster(20, 30, [200, 30, 30])], &job, "Lustre").unwrap(); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/OutputIntents") && text.contains("/DestOutputProfile"), "{}", &text[..text.len().min(500)]); + assert!(text.contains("/BitsPerComponent 8") && text.contains("/Title (Lustre)")); + // a profile that isn't one fails the print and says which file + std::fs::write(dir.join("Junk.icc"), [7u8; 200]).unwrap(); + let junk = PrintJob { + color: PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("Junk.icc") }, ..Default::default() }, + ..Default::default() + }; + let err = write(&[raster(2, 2, [0; 3])], &junk, "t").unwrap_err(); + assert!(err.contains("Junk"), "{err}"); + } +} diff --git a/crates/engine/src/print/template.rs b/crates/engine/src/print/template.rs new file mode 100644 index 000000000..2465c123a --- /dev/null +++ b/crates/engine/src/print/template.rs @@ -0,0 +1,124 @@ +//! The settings a print is made from, and (with the saved-template commands) what a print +//! template stores. +use super::PrintJob; +use super::compose::{ImageSettings, PageStyle}; +use super::layout::{Guides, Layout}; +use serde::{Deserialize, Serialize}; + +/// Layout, image settings, page, guides and job: the whole Print module state. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase", deny_unknown_fields)] +pub struct PrintSettings { + pub layout: Layout, + pub image: ImageSettings, + pub page: PageStyle, + pub guides: Guides, + pub job: PrintJob, +} + +impl PrintSettings { + /// Checks every part; run on anything that did not come from the setters. + pub fn validate(&mut self) -> Result<(), String> { + self.layout.validate()?; + self.image.validate()?; + self.guides.validate()?; + self.job.validate() + } +} + +/// Longest template name, characters. +pub const MAX_NAME: usize = 80; +/// Most templates kept. +pub const MAX_TEMPLATES: usize = 200; + +/// A saved print template: the layout, image settings, page and job of a print, without the +/// photos assigned to cells and without the guides (a view preference). The settings are kept as +/// JSON so a template written by another version never makes the preferences file unreadable; +/// [`PrintTemplate::settings`] checks them when they are used. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrintTemplate { + pub name: String, + pub settings: serde_json::Value, +} + +/// A template name: trimmed, 1 to [`MAX_NAME`] characters, no control characters. +pub fn clean_name(name: &str) -> Result<String, String> { + let name = name.trim(); + if name.is_empty() || name.chars().count() > MAX_NAME || name.chars().any(char::is_control) { + return Err(format!("a template name is 1 to {MAX_NAME} characters without control characters")); + } + Ok(name.to_string()) +} + +impl PrintTemplate { + /// A template of `settings` (validated first) named `name`. + pub fn new(name: &str, settings: &PrintSettings) -> Result<Self, String> { + let mut s = settings.clone(); + s.validate()?; + for c in &mut s.layout.cells { + c.photo = None; + } + let mut value = serde_json::to_value(&s).map_err(|e| e.to_string())?; + if let Some(o) = value.as_object_mut() { + o.remove("guides"); + } + Ok(Self { name: clean_name(name)?, settings: value }) + } + + /// The settings, checked: unknown fields, hostile numbers and inconsistent layouts are errors. + /// `guides` are those of the print the template is applied to. + pub fn settings(&self, guides: &Guides) -> Result<PrintSettings, String> { + let mut s: PrintSettings = serde_json::from_value(self.settings.clone()).map_err(|e| format!("template `{}`: {e}", self.name))?; + s.guides = *guides; + s.validate().map_err(|e| format!("template `{}`: {e}", self.name))?; + Ok(s) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::print::layout::Style; + use serde_json::json; + + #[test] + fn a_template_keeps_the_look_not_the_photos_or_guides() { + let mut s = PrintSettings::default(); + s.layout.style = Style::CustomPackage; + let id = s.layout.add_cell(3.0, 2.0).unwrap(); + s.layout.set_cell_photo(id, Some(77)).unwrap(); + s.image.zoom_to_fill = true; + s.page.background = [10, 20, 30]; + s.job.ppi = 300.0; + s.guides.grid_size = 1.0; + let t = PrintTemplate::new(" Proof sheet ", &s).unwrap(); + assert_eq!(t.name, "Proof sheet"); + assert!(t.settings.get("guides").is_none()); + let back = t.settings(&Guides::default()).unwrap(); + assert_eq!(back.layout.cells.len(), 1); + assert_eq!(back.layout.cells[0].photo, None); + assert!(back.image.zoom_to_fill); + assert_eq!((back.page.background, back.job.ppi), ([10, 20, 30], 300.0)); + assert_eq!(back.guides, Guides::default(), "the guides are the caller's"); + } + + #[test] + fn hostile_templates_are_rejected() { + for name in ["", " ", "a\nb", &"x".repeat(MAX_NAME + 1)] { + assert!(PrintTemplate::new(name, &PrintSettings::default()).is_err(), "{name:?}"); + } + let bad = |v: serde_json::Value| PrintTemplate { name: "t".into(), settings: v }.settings(&Guides::default()).is_err(); + assert!(bad(json!("text"))); + assert!(bad(json!({"surprise": 1}))); + assert!(bad(json!({"job": {"ppi": 1e30}}))); + assert!(bad(json!({"layout": {"pages": 0}}))); + assert!(bad(json!({"layout": {"page": {"paper": "custom", "customWidth": -3}}}))); + assert!(bad(json!({"layout": {"cells": [{"id": 1, "rect": {"x": 0, "y": 0, "w": 1e9, "h": 1}}]}}))); + assert!(bad(json!({"image": {"borderWidth": -1}}))); + assert!(!bad(json!({}))); + let mut s = PrintSettings::default(); + s.job.ppi = f32::NAN; + assert!(PrintTemplate::new("t", &s).is_err()); + } +} diff --git a/crates/engine/src/print_color.rs b/crates/engine/src/print_color.rs new file mode 100644 index 000000000..562b75129 --- /dev/null +++ b/crates/engine/src/print_color.rs @@ -0,0 +1,386 @@ +//! The print job's colour: which profile composed pages are converted to and how, print sharpening +//! for the paper, and the print-ready PDF they end up in. +//! +//! - **Profile**: "Managed by Printer" sends the page in sRGB or Adobe RGB with that profile +//! embedded (the driver converts); a printer / paper ICC profile converts the page to the device's +//! own values here and names the profile as the document's output intent, so the driver must not +//! convert again. +//! - **Intent, black point compensation, print adjustment**: [`lightcraft_codecs::print`]. +//! - **Print sharpening** (Low / Standard / High for Matte or Glossy paper): each photo is sharpened +//! at its print size before it goes on the page, with the export sharpener. +//! - **Depth**: 16 bits per component by default (no banding from the profile conversion); 8 bits +//! for smaller files. +//! +//! Pages arrive as 16-bit RGB in the `Rec2020` output encoding, the composed page of the layout. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use lightcraft_codecs::display::MAX_PROFILE_BYTES; +use lightcraft_codecs::print::{PrintColor, PrintTarget, PrintTransform}; +use lightcraft_codecs::{NamedSpace, proof::ProofIntent}; +use serde::{Deserialize, Serialize}; + +use crate::export::{DeepImage, DeepSamples, OutputSpace, SharpenAmount, SharpenFor, output_sharpen_deep}; +use crate::print_pdf::{ImageData, OutputIntent, PdfImage, PdfInfo, PdfPage, Placement}; + +/// The colour space a printer that manages colour receives. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ManagedSpace { + #[default] + Srgb, + AdobeRgb, +} + +/// Where the print's colours go. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", tag = "kind")] +pub enum PrintProfile { + /// Managed by Printer: the page goes in this space and the driver converts it. + Managed { space: ManagedSpace }, + /// A printer / paper profile (an `.icc` / `.icm` file): converted here. + Icc { path: PathBuf }, +} + +impl Default for PrintProfile { + fn default() -> Self { + PrintProfile::Managed { space: ManagedSpace::Srgb } + } +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintIntent { + #[default] + Perceptual, + Relative, +} + +/// Print sharpening strength (Off leaves the photos as rendered). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintSharpen { + Off, + Low, + #[default] + Standard, + High, +} + +/// The paper print sharpening is tuned for. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum PrintMedia { + Matte, + #[default] + Glossy, +} + +/// The print job's colour settings, as saved with print templates. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PrintColorSettings { + pub profile: PrintProfile, + pub intent: PrintIntent, + /// Map the page's black to the paper's darkest (relative colorimetric). + pub black_point_compensation: bool, + /// Print adjustment, −100..100. + pub brightness: f32, + /// Print adjustment, −100..100. + pub contrast: f32, + pub sharpen: PrintSharpen, + pub media: PrintMedia, + /// 16 bits per component in the PDF (else 8). + pub sixteen_bit: bool, +} + +impl Default for PrintColorSettings { + fn default() -> Self { + PrintColorSettings { + profile: PrintProfile::default(), + intent: PrintIntent::default(), + black_point_compensation: true, + brightness: 0.0, + contrast: 0.0, + sharpen: PrintSharpen::default(), + media: PrintMedia::default(), + sixteen_bit: true, + } + } +} + +impl PrintColorSettings { + /// Refuse settings a command or template shouldn't carry (out-of-range adjustments, an empty + /// profile path); whether the profile file is usable is checked when the job is built. + pub fn validate(&self) -> Result<(), String> { + for (name, v) in [("brightness", self.brightness), ("contrast", self.contrast)] { + if !(v.is_finite() && (-100.0..=100.0).contains(&v)) { + return Err(format!("{name} must be a number from -100 to 100")); + } + } + if let PrintProfile::Icc { path } = &self.profile + && path.as_os_str().is_empty() + { + return Err("the printer profile needs a path".into()); + } + Ok(()) + } + + /// Sharpen one photo for the paper, at the size it prints (before it goes on the page). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + let amount = match self.sharpen { + PrintSharpen::Off => return, + PrintSharpen::Low => SharpenAmount::Low, + PrintSharpen::Standard => SharpenAmount::Standard, + PrintSharpen::High => SharpenAmount::High, + }; + let target = match self.media { + PrintMedia::Matte => SharpenFor::Matte, + PrintMedia::Glossy => SharpenFor::Glossy, + }; + output_sharpen_deep(photo, target, amount); + } +} + +/// Read a printer profile, refusing files that can't be one (the parse happens later). +fn read_profile(path: &Path) -> Result<Vec<u8>, String> { + let shown = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_else(|| path.display().to_string()); + let len = std::fs::metadata(path).map_err(|e| format!("can't read the printer profile {shown}: {e}"))?.len(); + if len > MAX_PROFILE_BYTES as u64 { + return Err(format!("the printer profile {shown} is too large")); + } + std::fs::read(path).map_err(|e| format!("can't read the printer profile {shown}: {e}")) +} + +/// A print job's colour, ready to apply: the conversion built once and what the PDF needs to know. +#[derive(Debug)] +pub struct PrintColorJob { + pub settings: PrintColorSettings, + transform: PrintTransform, + /// The printing condition named in the PDF (only for a printer profile). + intent: Option<OutputIntent>, +} + +impl PrintColorJob { + /// Build the job's conversion. Errors name the profile and what's wrong with it. + pub fn new(settings: &PrintColorSettings) -> Result<PrintColorJob, String> { + let s = settings.clone(); + let color = PrintColor { + intent: match s.intent { + PrintIntent::Perceptual => ProofIntent::Perceptual, + PrintIntent::Relative => ProofIntent::Relative, + }, + black_point_compensation: s.black_point_compensation, + brightness: s.brightness, + contrast: s.contrast, + }; + let (target, name) = match &s.profile { + PrintProfile::Managed { space } => { + (PrintTarget::Managed(if *space == ManagedSpace::AdobeRgb { NamedSpace::AdobeRgb } else { NamedSpace::Srgb }), None) + } + PrintProfile::Icc { path } => { + let name = path.file_stem().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + (PrintTarget::Profile(Arc::new(read_profile(path)?)), Some(name)) + } + }; + let transform = PrintTransform::new(&target, &color).map_err(|e| match &name { + Some(n) => format!("the printer profile {n}: {e}"), + None => e, + })?; + let intent = name.map(|file| OutputIntent { + icc: Arc::clone(transform.icc()), + channels: transform.channels() as u8, + condition: transform.description().map(str::to_owned).unwrap_or(file), + }); + Ok(PrintColorJob { settings: s, transform, intent }) + } + + /// Sharpen one photo for the paper ([`PrintColorSettings::sharpen_photo`]). + pub fn sharpen_photo(&self, photo: &mut DeepImage) { + self.settings.sharpen_photo(photo); + } + + /// Convert a composed page (16-bit RGB, `Rec2020` output encoding) to the PDF image that covers + /// it, in the target's colours. + pub fn page_image(&self, width: u32, height: u32, rgb: &[u16]) -> Result<PdfImage, String> { + let want = u64::from(width).checked_mul(u64::from(height)).and_then(|p| p.checked_mul(3)); + if width == 0 || height == 0 || want != Some(rgb.len() as u64) { + return Err(format!("a page of {width} × {height} pixels can't be made from {} samples", rgb.len())); + } + let device = self.transform.apply(rgb)?; + let channels = self.transform.channels() as u8; + let icc = Some(Arc::clone(self.transform.icc())); + Ok(if self.settings.sixteen_bit { + PdfImage::from_u16(width, height, channels, &device, icc) + } else { + let samples = device.iter().map(|&v| ((u32::from(v) * 255 + 32767) / 65535) as u8).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 8, samples }, icc } + }) + } + + /// The print-ready document: each page image covers its page (`size` in points). + pub fn pdf(&self, pages: Vec<((f64, f64), PdfImage)>, title: &str) -> Result<Vec<u8>, String> { + let pages: Vec<PdfPage> = pages + .into_iter() + .map(|((w, h), image)| PdfPage { width: w, height: h, images: vec![(image, Placement { x: 0.0, y: 0.0, width: w, height: h })] }) + .collect(); + let title = (!title.trim().is_empty()).then(|| title.to_owned()); + crate::print_pdf::write(&pages, &PdfInfo { title, output_intent: self.intent.clone() }) + } + + /// Samples per pixel the pages are converted to (3 RGB, 4 CMYK, 1 grey). + pub fn channels(&self) -> usize { + self.transform.channels() + } + + /// The profile's description (or file name) for the job's summary. + pub fn profile_name(&self) -> Option<&str> { + self.intent.as_ref().map(|i| i.condition.as_str()).or(self.transform.description()) + } +} + +/// A photo rendered for the page as the layout gets it from the export renderer. +pub fn is_print_ready(photo: &DeepImage) -> bool { + photo.space == OutputSpace::Rec2020 && matches!(photo.samples, DeepSamples::U16(_)) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::print::print_adjust; + + fn scratch(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("lc-print-color-{}-{name}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + dir + } + + /// A small, flat page of one 16-bit colour. + fn page(w: u32, h: u32, v: [u16; 3]) -> Vec<u16> { + (0..w * h).flat_map(|_| v).collect() + } + + #[test] + fn settings_default_and_round_trip() { + let d = PrintColorSettings::default(); + assert_eq!(d.profile, PrintProfile::Managed { space: ManagedSpace::Srgb }); + assert!(d.black_point_compensation && d.sixteen_bit); + let s = PrintColorSettings { + profile: PrintProfile::Icc { path: "/Library/ColorSync/Profiles/Paper.icc".into() }, + intent: PrintIntent::Relative, + brightness: 12.0, + sharpen: PrintSharpen::High, + media: PrintMedia::Matte, + ..d.clone() + }; + let json = serde_json::to_value(&s).unwrap(); + assert_eq!(json["profile"]["kind"], "icc"); + assert_eq!(json["intent"], "relative"); + assert_eq!(serde_json::from_value::<PrintColorSettings>(json).unwrap(), s); + // partial and unknown-free JSON fills in the defaults + let partial: PrintColorSettings = serde_json::from_str(r#"{"intent":"relative"}"#).unwrap(); + assert_eq!(partial, PrintColorSettings { intent: PrintIntent::Relative, ..d }); + assert!(serde_json::from_str::<PrintColorSettings>(r#"{"profile":{"kind":"cmyk"}}"#).is_err()); + } + + #[test] + fn managed_srgb_page_becomes_a_16_bit_srgb_pdf_without_an_output_intent() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + let img = job.page_image(4, 2, &page(4, 2, [65535, 65535, 65535])).unwrap(); + assert_eq!((img.channels, img.width, img.height), (3, 4, 2)); + let ImageData::Raw { bits: 16, samples } = &img.data else { panic!("16-bit samples") }; + assert!(samples.chunks(2).all(|b| u16::from_be_bytes([b[0], b[1]]) > 65400), "white stays white"); + let pdf = job.pdf(vec![((288.0, 144.0), img)], "Prints").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/ICCBased") && s.contains("/BitsPerComponent 16") && s.contains("/MediaBox [0 0 288 144]")); + assert!(!s.contains("/OutputIntents"), "the printer converts: no output intent"); + assert!(s.contains("/Title (Prints)")); + } + + #[test] + fn a_printer_profile_converts_and_becomes_the_output_intent() { + let dir = scratch("icc"); + let path = dir.join("Studio Glossy.icc"); + std::fs::write(&path, lightcraft_codecs::icc::write_named(NamedSpace::AdobeRgb)).unwrap(); + let settings = + PrintColorSettings { profile: PrintProfile::Icc { path }, intent: PrintIntent::Relative, sixteen_bit: false, ..Default::default() }; + let job = PrintColorJob::new(&settings).unwrap(); + assert_eq!(job.channels(), 3); + let img = job.page_image(2, 2, &page(2, 2, [0, 65535, 0])).unwrap(); + assert!(matches!(&img.data, ImageData::Raw { bits: 8, .. })); + let pdf = job.pdf(vec![((100.0, 100.0), img)], "").unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/OutputIntents") && s.contains("/BitsPerComponent 8"), "{}", &s[..s.len().min(600)]); + assert!(!s.contains("/Title"), "an empty title is left out"); + assert!(job.profile_name().is_some()); + } + + #[test] + fn profile_errors_name_the_file() { + let dir = scratch("bad"); + let garbage = dir.join("Broken Paper.icc"); + std::fs::write(&garbage, [0u8; 300]).unwrap(); + let err = PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: garbage }, ..Default::default() }).unwrap_err(); + assert!(err.contains("Broken Paper"), "{err}"); + let missing = + PrintColorJob::new(&PrintColorSettings { profile: PrintProfile::Icc { path: dir.join("nope.icc") }, ..Default::default() }).unwrap_err(); + assert!(missing.contains("nope.icc"), "{missing}"); + } + + #[test] + fn print_adjustment_reaches_the_page() { + let mid = [32768u16; 3]; + let plain = PrintColorJob::new(&PrintColorSettings::default()).unwrap().page_image(1, 1, &mid).unwrap(); + let bright = PrintColorJob::new(&PrintColorSettings { brightness: 60.0, ..Default::default() }).unwrap().page_image(1, 1, &mid).unwrap(); + let first = |i: &PdfImage| match &i.data { + ImageData::Raw { samples, .. } => u16::from_be_bytes([samples[0], samples[1]]), + ImageData::Jpeg(_) | ImageData::Flate { .. } => 0, + }; + assert!(first(&bright) > first(&plain) + 1500, "{} vs {}", first(&bright), first(&plain)); + assert!(print_adjust(0.5, 60.0, 0.0) > 0.5); + } + + #[test] + fn print_sharpening_follows_the_setting() { + // a hard vertical edge in a 16-bit Rec2020 photo + let (w, h) = (8usize, 4usize); + let samples: Vec<u16> = (0..w * h).flat_map(|i| if i % w < w / 2 { [20000u16; 3] } else { [40000u16; 3] }).collect(); + let photo = DeepImage { width: w, height: h, space: OutputSpace::Rec2020, samples: DeepSamples::U16(samples) }; + assert!(is_print_ready(&photo)); + let sharpened = |s: PrintSharpen| { + let job = PrintColorJob::new(&PrintColorSettings { sharpen: s, media: PrintMedia::Matte, ..Default::default() }).unwrap(); + let mut p = photo.clone(); + job.sharpen_photo(&mut p); + p + }; + assert_eq!(sharpened(PrintSharpen::Off), photo); + let edge = |p: &DeepImage| match &p.samples { + DeepSamples::U16(v) => (v[(w / 2 - 1) * 3], v[(w / 2) * 3]), + DeepSamples::F32(_) => (0, 0), + }; + let (dark, light) = edge(&sharpened(PrintSharpen::High)); + assert!(dark < 20000 && light > 40000, "the edge gets more contrast: {dark} {light}"); + } + + #[test] + fn validation_refuses_out_of_range_settings() { + assert!(PrintColorSettings::default().validate().is_ok()); + for (b, c) in [(101.0, 0.0), (0.0, -100.5), (f32::NAN, 0.0), (0.0, f32::INFINITY)] { + assert!(PrintColorSettings { brightness: b, contrast: c, ..Default::default() }.validate().is_err(), "{b} {c}"); + } + assert!(PrintColorSettings { profile: PrintProfile::Icc { path: PathBuf::new() }, ..Default::default() }.validate().is_err()); + assert!(PrintColorSettings { brightness: -100.0, contrast: 100.0, ..Default::default() }.validate().is_ok()); + } + + #[test] + fn hostile_pages_are_errors() { + let job = PrintColorJob::new(&PrintColorSettings::default()).unwrap(); + assert!(job.page_image(0, 1, &[]).is_err()); + assert!(job.page_image(2, 2, &[0; 11]).is_err()); + assert!(job.page_image(u32::MAX, u32::MAX, &[0; 3]).is_err()); + let img = job.page_image(1, 1, &[0; 3]).unwrap(); + assert!(job.pdf(vec![((f64::NAN, 10.0), img)], "x").is_err()); + assert!(job.pdf(vec![], "x").is_err()); + } +} diff --git a/crates/engine/src/print_pdf.rs b/crates/engine/src/print_pdf.rs new file mode 100644 index 000000000..abce4061e --- /dev/null +++ b/crates/engine/src/print_pdf.rs @@ -0,0 +1,520 @@ +//! A small PDF writer for print output: pages of a given size in points, each with raster images +//! placed on it, tagged with their ICC profile (`ICCBased`) so the numbers mean the same colour in +//! every reader and RIP, Flate-compressed (lossless) at 8 or 16 bits per component, or carried as +//! JPEG (`DCTDecode`) where small files matter more than exact samples. Optionally an output intent: +//! the printing condition (the printer / paper profile) the file was made for, as PDF/X readers +//! expect it in the catalog. Profiles shared by several images are written once. +//! +//! The file is PDF 1.6 (16-bit images need 1.5): classic cross-reference table, no object streams, +//! deterministic (no dates or IDs), so the same pages always give the same bytes. +//! +//! Sources: ISO 32000-1:2008 (§7.3 objects, §7.5 file structure, §8.6.5.5 ICCBased colour spaces, +//! §8.9 images, §14.11.5 output intents). Own design. + +use std::collections::HashMap; +use std::sync::Arc; + +/// Pages per document: a print job beyond this is a mistake, not a book. +pub const MAX_PAGES: usize = 1000; +/// Images per page. +pub const MAX_IMAGES_PER_PAGE: usize = 256; +/// Largest page side in points (200 inches): more than any printer's roll. +pub const MAX_PAGE_POINTS: f64 = 14_400.0; +/// Largest image, in pixels per side. +pub const MAX_IMAGE_SIDE: u32 = 65_535; + +/// How an image's samples are stored. +#[derive(Clone, Debug, PartialEq)] +pub enum ImageData { + /// Interleaved samples, `bits` (8 or 16) per component, 16-bit values big-endian: compressed + /// losslessly in the file. + Raw { bits: u8, samples: Vec<u8> }, + /// A baseline JPEG stream, copied into the file as it is. + Jpeg(Vec<u8>), + /// Samples already compressed with zlib (see [`PdfImage::deflated`]), `bits` per component. + Flate { bits: u8, data: Vec<u8> }, +} + +/// A raster image and the colour space its samples are in. +#[derive(Clone, Debug, PartialEq)] +pub struct PdfImage { + pub width: u32, + pub height: u32, + /// Components per pixel: 1 (grey), 3 (RGB) or 4 (CMYK). + pub channels: u8, + pub data: ImageData, + /// The ICC profile the samples are in; without one the image is device colour. + pub icc: Option<Arc<Vec<u8>>>, +} + +impl PdfImage { + /// An image from interleaved 16-bit samples. + pub fn from_u16(width: u32, height: u32, channels: u8, samples: &[u16], icc: Option<Arc<Vec<u8>>>) -> PdfImage { + let bytes = samples.iter().flat_map(|v| v.to_be_bytes()).collect(); + PdfImage { width, height, channels, data: ImageData::Raw { bits: 16, samples: bytes }, icc } + } + + /// The same image with its samples compressed now (checked first), so a long print doesn't + /// hold every page's uncompressed samples until the file is written. + pub fn deflated(self) -> Result<PdfImage, String> { + self.check()?; + let data = match self.data { + ImageData::Raw { bits, samples } => ImageData::Flate { bits, data: deflate(&samples) }, + other => other, + }; + Ok(PdfImage { data, ..self }) + } + + fn check(&self) -> Result<(), String> { + if self.width == 0 || self.height == 0 || self.width > MAX_IMAGE_SIDE || self.height > MAX_IMAGE_SIDE { + return Err(format!("an image of {} × {} pixels can't be written", self.width, self.height)); + } + if !matches!(self.channels, 1 | 3 | 4) { + return Err(format!("images have 1, 3 or 4 components, not {}", self.channels)); + } + match &self.data { + ImageData::Raw { bits, samples } => { + if !matches!(bits, 8 | 16) { + return Err(format!("images have 8 or 16 bits per component, not {bits}")); + } + let want = u64::from(self.width) * u64::from(self.height) * u64::from(self.channels) * u64::from(bits / 8); + if samples.len() as u64 != want { + return Err(format!("the image needs {want} bytes of samples, not {}", samples.len())); + } + } + ImageData::Jpeg(data) => { + if !data.starts_with(&[0xFF, 0xD8]) { + return Err("the image isn't a JPEG stream".into()); + } + } + ImageData::Flate { bits, data } => { + if !matches!(bits, 8 | 16) || data.is_empty() { + return Err("a compressed image has 8 or 16 bits per component and some data".into()); + } + } + } + Ok(()) + } +} + +/// Where an image goes on its page, in points from the page's bottom-left corner. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placement { + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, +} + +/// One page: its size in points and the images on it, drawn in order (later ones on top). +#[derive(Clone, Debug, PartialEq)] +pub struct PdfPage { + pub width: f64, + pub height: f64, + pub images: Vec<(PdfImage, Placement)>, +} + +/// The printing condition the document is made for (PDF/X-style output intent). +#[derive(Clone, Debug, PartialEq)] +pub struct OutputIntent { + /// The printer / paper profile. + pub icc: Arc<Vec<u8>>, + /// Its components: 1, 3 or 4. + pub channels: u8, + /// A name for the condition (the profile's description). + pub condition: String, +} + +/// Document-level settings. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PdfInfo { + pub title: Option<String>, + pub output_intent: Option<OutputIntent>, +} + +fn finite(v: f64) -> bool { + v.is_finite() +} + +/// A number as PDF writes it: up to four decimals, no exponent, no trailing zeros. +fn num(v: f64) -> String { + let s = format!("{:.4}", if finite(v) { v } else { 0.0 }); + let s = s.trim_end_matches('0').trim_end_matches('.'); + if s == "-0" || s.is_empty() { "0".into() } else { s.into() } +} + +/// A text string: plain ASCII as a literal string, anything else as UTF-16BE with a byte order mark. +fn text(s: &str) -> String { + if s.bytes().all(|b| (0x20..0x7f).contains(&b)) { + let mut out = String::with_capacity(s.len() + 2); + out.push('('); + for c in s.chars() { + if matches!(c, '(' | ')' | '\\') { + out.push('\\'); + } + out.push(c); + } + out.push(')'); + out + } else { + let mut out = String::from("<FEFF"); + for unit in s.encode_utf16() { + out.push_str(&format!("{unit:04X}")); + } + out.push('>'); + out + } +} + +struct Writer { + out: Vec<u8>, + offsets: Vec<usize>, +} + +impl Writer { + /// Reserve an object number (filled later by [`Writer::object`]). + fn reserve(&mut self) -> usize { + self.offsets.push(0); + self.offsets.len() + } + + fn object(&mut self, id: usize, dict: &str, stream: Option<&[u8]>) { + if let Some(slot) = self.offsets.get_mut(id.wrapping_sub(1)) { + *slot = self.out.len(); + } + self.out.extend_from_slice(format!("{id} 0 obj\n{dict}").as_bytes()); + if let Some(data) = stream { + self.out.extend_from_slice(b"\nstream\n"); + self.out.extend_from_slice(data); + self.out.extend_from_slice(b"\nendstream"); + } + self.out.extend_from_slice(b"\nendobj\n"); + } +} + +fn deflate(data: &[u8]) -> Vec<u8> { + miniz_oxide::deflate::compress_to_vec_zlib(data, 6) +} + +/// Write a document. Errors name the first page or image that can't be written. +pub fn write(pages: &[PdfPage], info: &PdfInfo) -> Result<Vec<u8>, String> { + if pages.is_empty() { + return Err("there are no pages to write".into()); + } + if pages.len() > MAX_PAGES { + return Err(format!("a document holds at most {MAX_PAGES} pages")); + } + for (n, page) in pages.iter().enumerate() { + let n = n + 1; + if !(finite(page.width) && finite(page.height)) + || page.width <= 0.0 + || page.height <= 0.0 + || page.width > MAX_PAGE_POINTS + || page.height > MAX_PAGE_POINTS + { + return Err(format!("page {n} has no usable size")); + } + if page.images.len() > MAX_IMAGES_PER_PAGE { + return Err(format!("page {n} has more than {MAX_IMAGES_PER_PAGE} images")); + } + for (image, at) in &page.images { + image.check().map_err(|e| format!("page {n}: {e}"))?; + if ![at.x, at.y, at.width, at.height].into_iter().all(finite) || at.width <= 0.0 || at.height <= 0.0 { + return Err(format!("page {n}: an image has no usable placement")); + } + } + } + if let Some(intent) = &info.output_intent + && !matches!(intent.channels, 1 | 3 | 4) + { + return Err("the output intent's profile has 1, 3 or 4 components".into()); + } + + let mut w = Writer { out: Vec::new(), offsets: Vec::new() }; + w.out.extend_from_slice(b"%PDF-1.6\n%\xE2\xE3\xCF\xD3\n"); + let catalog = w.reserve(); + let pages_id = w.reserve(); + let info_id = w.reserve(); + + // ICC profiles, each written once (keyed by content) with its component count + let mut profiles: HashMap<Vec<u8>, usize> = HashMap::new(); + let mut profile = |w: &mut Writer, icc: &Arc<Vec<u8>>, n: u8| -> usize { + if let Some(&id) = profiles.get(icc.as_slice()) { + return id; + } + let id = w.reserve(); + let data = deflate(icc); + let alternate = match n { + 1 => "/DeviceGray", + 4 => "/DeviceCMYK", + _ => "/DeviceRGB", + }; + w.object(id, &format!("<< /N {n} /Alternate {alternate} /Filter /FlateDecode /Length {} >>", data.len()), Some(&data)); + profiles.insert(icc.to_vec(), id); + id + }; + + let intent_id = info.output_intent.as_ref().map(|intent| profile(&mut w, &intent.icc, intent.channels)); + + let mut kids = Vec::with_capacity(pages.len()); + for page in pages { + let page_id = w.reserve(); + let mut xobjects = String::new(); + let mut content = String::new(); + for (k, (image, at)) in page.images.iter().enumerate() { + let space = match &image.icc { + Some(icc) => format!("[/ICCBased {} 0 R]", profile(&mut w, icc, image.channels)), + None => match image.channels { + 1 => "/DeviceGray".into(), + 4 => "/DeviceCMYK".into(), + _ => "/DeviceRGB".into(), + }, + }; + let id = w.reserve(); + let (bits, filter, data): (u8, &str, std::borrow::Cow<[u8]>) = match &image.data { + ImageData::Raw { bits, samples } => (*bits, "/FlateDecode", deflate(samples).into()), + ImageData::Flate { bits, data } => (*bits, "/FlateDecode", data.as_slice().into()), + ImageData::Jpeg(jpeg) => (8, "/DCTDecode", jpeg.as_slice().into()), + }; + w.object( + id, + &format!( + "<< /Type /XObject /Subtype /Image /Width {} /Height {} /ColorSpace {space} /BitsPerComponent {bits} /Filter {filter} /Length {} >>", + image.width, + image.height, + data.len() + ), + Some(&data), + ); + xobjects.push_str(&format!("/Im{k} {id} 0 R ")); + content.push_str(&format!("q {} 0 0 {} {} {} cm /Im{k} Do Q\n", num(at.width), num(at.height), num(at.x), num(at.y))); + } + let content_id = w.reserve(); + let stream = deflate(content.as_bytes()); + w.object(content_id, &format!("<< /Filter /FlateDecode /Length {} >>", stream.len()), Some(&stream)); + w.object( + page_id, + &format!( + "<< /Type /Page /Parent {pages_id} 0 R /MediaBox [0 0 {} {}] /Resources << /XObject << {xobjects}>> >> /Contents {content_id} 0 R >>", + num(page.width), + num(page.height) + ), + None, + ); + kids.push(format!("{page_id} 0 R")); + } + w.object(pages_id, &format!("<< /Type /Pages /Count {} /Kids [{}] >>", kids.len(), kids.join(" ")), None); + + let intents = match (&info.output_intent, intent_id) { + (Some(intent), Some(id)) => format!( + " /OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX /OutputConditionIdentifier (Custom) /Info {} /DestOutputProfile {id} 0 R >>]", + text(&intent.condition) + ), + _ => String::new(), + }; + w.object(catalog, &format!("<< /Type /Catalog /Pages {pages_id} 0 R{intents} >>"), None); + let title = info.title.as_deref().map(|t| format!(" /Title {}", text(t))).unwrap_or_default(); + w.object(info_id, &format!("<< /Producer (LightCraft){title} >>"), None); + + let xref = w.out.len(); + let mut table = format!("xref\n0 {}\n0000000000 65535 f \n", w.offsets.len() + 1); + for offset in &w.offsets { + table.push_str(&format!("{offset:010} 00000 n \n")); + } + table.push_str(&format!("trailer\n<< /Size {} /Root {catalog} 0 R /Info {info_id} 0 R >>\nstartxref\n{xref}\n%%EOF\n", w.offsets.len() + 1)); + w.out.extend_from_slice(table.as_bytes()); + Ok(w.out) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_codecs::NamedSpace; + + /// The body of object `id` (dictionary and raw stream bytes), found through the xref table. + fn object(pdf: &[u8], id: usize) -> (String, Vec<u8>) { + // byte offsets: the streams are binary, so search the bytes, not a lossy string + let start = pdf.windows(10).rposition(|w| w == b"startxref\n").unwrap() + 10; + let tail = std::str::from_utf8(&pdf[start..]).unwrap(); + let xref: usize = tail.lines().next().unwrap().trim().parse().unwrap(); + let table = std::str::from_utf8(&pdf[xref..]).unwrap(); + let line = table.lines().nth(2 + id).unwrap(); + let offset: usize = line[..10].parse().unwrap(); + let head = format!("{id} 0 obj\n"); + assert_eq!(&pdf[offset..offset + head.len()], head.as_bytes(), "xref entry for {id}"); + let rest = &pdf[offset + head.len()..]; + let end = rest.windows(7).position(|w| w == b"\nendobj").unwrap(); + let body = &rest[..end]; + match body.windows(8).position(|w| w == b"\nstream\n") { + Some(p) => { + let data = &body[p + 8..body.len() - "\nendstream".len()]; + (String::from_utf8_lossy(&body[..p]).into_owned(), data.to_vec()) + } + None => (String::from_utf8_lossy(body).into_owned(), vec![]), + } + } + + fn inflate(data: &[u8]) -> Vec<u8> { + miniz_oxide::inflate::decompress_to_vec_zlib(data).unwrap() + } + + /// Object ids of every image XObject, in file order. + fn images(pdf: &[u8]) -> Vec<usize> { + let s = String::from_utf8_lossy(pdf); + s.match_indices(" 0 obj\n<< /Type /XObject /Subtype /Image").map(|(i, _)| s[..i].rsplit('\n').next().unwrap().parse().unwrap()).collect() + } + + fn reference(dict: &str, key: &str) -> usize { + let at = dict.find(key).unwrap() + key.len(); + dict[at..].trim_start().split(' ').next().unwrap().parse().unwrap() + } + + fn srgb() -> Arc<Vec<u8>> { + Arc::new(lightcraft_codecs::icc::write_named(NamedSpace::Srgb)) + } + + fn page(images: Vec<(PdfImage, Placement)>) -> PdfPage { + PdfPage { width: 612.0, height: 792.0, images } + } + + fn full(w: f64, h: f64) -> Placement { + Placement { x: 0.0, y: 0.0, width: w, height: h } + } + + #[test] + fn a_16_bit_icc_image_round_trips() { + let samples: Vec<u16> = (0..2 * 3 * 3).map(|i| (i * 3851) as u16).collect(); + let img = PdfImage::from_u16(3, 2, 3, &samples, Some(srgb())); + let pdf = write(&[page(vec![(img, full(612.0, 792.0))])], &PdfInfo::default()).unwrap(); + assert!(pdf.starts_with(b"%PDF-1.6\n")); + assert!(pdf.ends_with(b"%%EOF\n")); + let ids = images(&pdf); + assert_eq!(ids.len(), 1); + let (dict, data) = object(&pdf, ids[0]); + assert!(dict.contains("/Width 3 /Height 2") && dict.contains("/BitsPerComponent 16") && dict.contains("/FlateDecode"), "{dict}"); + let back: Vec<u16> = inflate(&data).chunks(2).map(|b| u16::from_be_bytes([b[0], b[1]])).collect(); + assert_eq!(back, samples); + let (profile, icc) = object(&pdf, reference(&dict, "/ICCBased")); + assert!(profile.contains("/N 3") && profile.contains("/Alternate /DeviceRGB"), "{profile}"); + assert_eq!(inflate(&icc), *srgb()); + assert_eq!(lightcraft_codecs::icc::parse(&inflate(&icc)).and_then(|i| i.named), Some(NamedSpace::Srgb)); + } + + #[test] + fn pages_sizes_placements_and_a_shared_profile() { + let px = |v: u16| PdfImage::from_u16(1, 1, 3, &[v, v, v], Some(srgb())); + let at = Placement { x: 36.0, y: 72.5, width: 288.0, height: 216.0 }; + let pages = [page(vec![(px(1), at), (px(2), full(612.0, 792.0))]), PdfPage { width: 841.89, height: 595.28, images: vec![(px(3), at)] }]; + let pdf = write(&pages, &PdfInfo::default()).unwrap(); + let s = String::from_utf8_lossy(&pdf); + assert!(s.contains("/Type /Pages /Count 2"), "two pages"); + assert!(s.contains("/MediaBox [0 0 612 792]") && s.contains("/MediaBox [0 0 841.89 595.28]")); + // the sRGB profile is written once for all three images + assert_eq!(s.matches("/N 3 /Alternate").count(), 1); + let first: Vec<_> = images(&pdf).iter().map(|&id| reference(&object(&pdf, id).0, "/ICCBased")).collect(); + assert!(first.windows(2).all(|w| w[0] == w[1]), "{first:?}"); + // placements reach the content streams + let contents: Vec<String> = s + .match_indices("/Contents ") + .map(|(i, _)| s[i + 10..].split(' ').next().unwrap().parse::<usize>().unwrap()) + .map(|id| String::from_utf8(inflate(&object(&pdf, id).1)).unwrap()) + .collect(); + assert_eq!(contents[0], "q 288 0 0 216 36 72.5 cm /Im0 Do Q\nq 612 0 0 792 0 0 cm /Im1 Do Q\n"); + assert_eq!(write(&pages, &PdfInfo::default()).unwrap(), pdf, "deterministic"); + } + + #[test] + fn output_intent_and_title() { + let cmyk_like = srgb(); + let info = PdfInfo { + title: Some("Garden – prints (2)".into()), + output_intent: Some(OutputIntent { icc: cmyk_like.clone(), channels: 3, condition: "Glossy (Photo) \\ test".into() }), + }; + let img = PdfImage::from_u16(1, 1, 3, &[0, 0, 0], Some(cmyk_like)); + let pdf = write(&[page(vec![(img, full(10.0, 10.0))])], &info).unwrap(); + let (catalog, _) = object(&pdf, 1); + assert!(catalog.contains("/OutputIntents [<< /Type /OutputIntent /S /GTS_PDFX"), "{catalog}"); + assert!(catalog.contains("/Info (Glossy \\(Photo\\) \\\\ test)"), "{catalog}"); + // the intent and the image share one profile object + let dest = reference(&catalog, "/DestOutputProfile"); + assert_eq!(reference(&object(&pdf, images(&pdf)[0]).0, "/ICCBased"), dest); + let (info_dict, _) = object(&pdf, 3); + assert!(info_dict.contains("/Title <FEFF"), "non-ASCII titles are UTF-16: {info_dict}"); + assert!(info_dict.contains("2013"), "the en dash: {info_dict}"); + } + + #[test] + fn cmyk_grey_jpeg_and_device_images() { + let jpeg = vec![0xFF, 0xD8, 0xFF, 0xD9]; + let imgs = vec![ + ( + PdfImage { width: 1, height: 1, channels: 4, data: ImageData::Raw { bits: 8, samples: vec![0, 10, 20, 30] }, icc: None }, + full(1.0, 1.0), + ), + (PdfImage { width: 2, height: 1, channels: 1, data: ImageData::Raw { bits: 8, samples: vec![0, 255] }, icc: None }, full(1.0, 1.0)), + (PdfImage { width: 4, height: 4, channels: 3, data: ImageData::Jpeg(jpeg.clone()), icc: None }, full(1.0, 1.0)), + ]; + let pdf = write(&[page(imgs)], &PdfInfo::default()).unwrap(); + let ids = images(&pdf); + let dicts: Vec<_> = ids.iter().map(|&id| object(&pdf, id)).collect(); + assert!(dicts[0].0.contains("/ColorSpace /DeviceCMYK") && dicts[0].0.contains("/BitsPerComponent 8")); + assert!(dicts[1].0.contains("/ColorSpace /DeviceGray")); + assert!(dicts[2].0.contains("/DCTDecode") && dicts[2].0.contains("/DeviceRGB")); + assert_eq!(dicts[2].1, jpeg, "JPEG streams are copied as they are"); + } + + #[test] + fn compressing_early_writes_the_same_file() { + let samples: Vec<u16> = (0..4 * 3 * 3).map(|i| (i * 977) as u16).collect(); + let raw = PdfImage::from_u16(4, 3, 3, &samples, Some(srgb())); + let early = raw.clone().deflated().unwrap(); + assert!(matches!(early.data, ImageData::Flate { bits: 16, .. })); + let at = full(100.0, 75.0); + let a = write(&[page(vec![(raw, at)])], &PdfInfo::default()).unwrap(); + let b = write(&[page(vec![(early, at)])], &PdfInfo::default()).unwrap(); + assert_eq!(a, b); + let mut short = PdfImage::from_u16(4, 3, 3, &samples, None); + short.width = 5; + assert!(short.deflated().is_err(), "checked before compressing"); + } + + #[test] + fn hostile_documents_are_errors() { + let ok = || PdfImage::from_u16(1, 1, 3, &[1, 2, 3], None); + let at = full(10.0, 10.0); + let bad = |pages: Vec<PdfPage>| write(&pages, &PdfInfo::default()).is_err(); + assert!(bad(vec![])); + assert!(bad(vec![PdfPage { width: f64::NAN, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: -1.0, height: 10.0, images: vec![] }])); + assert!(bad(vec![PdfPage { width: 1e9, height: 10.0, images: vec![] }])); + assert!(bad(vec![page(vec![(ok(), Placement { x: f64::INFINITY, ..at })])])); + assert!(bad(vec![page(vec![(ok(), Placement { width: 0.0, ..at })])])); + let mut short = ok(); + short.data = ImageData::Raw { bits: 16, samples: vec![0; 5] }; + assert!(bad(vec![page(vec![(short, at)])])); + let mut empty = ok(); + empty.width = 0; + assert!(bad(vec![page(vec![(empty, at)])])); + let mut odd = ok(); + odd.channels = 2; + assert!(bad(vec![page(vec![(odd, at)])])); + let mut not_jpeg = ok(); + not_jpeg.data = ImageData::Jpeg(vec![1, 2, 3]); + assert!(bad(vec![page(vec![(not_jpeg, at)])])); + let too_many = (0..MAX_PAGES + 1).map(|_| page(vec![])).collect(); + assert!(bad(too_many)); + let info = PdfInfo { title: None, output_intent: Some(OutputIntent { icc: srgb(), channels: 2, condition: String::new() }) }; + assert!(write(&[page(vec![])], &info).is_err()); + } + + #[test] + fn numbers_and_strings() { + assert_eq!(num(612.0), "612"); + assert_eq!(num(72.5), "72.5"); + assert_eq!(num(-0.00001), "0"); + assert_eq!(num(f64::NAN), "0"); + assert_eq!(num(1.0 / 3.0), "0.3333"); + assert_eq!(text("a(b)c\\"), "(a\\(b\\)c\\\\)"); + assert_eq!(text("é"), "<FEFF00E9>"); + } +} diff --git a/crates/engine/src/profile_refs.rs b/crates/engine/src/profile_refs.rs new file mode 100644 index 000000000..b9a7d2d37 --- /dev/null +++ b/crates/engine/src/profile_refs.rs @@ -0,0 +1,205 @@ +//! Profiles named by imported presets (issue #1001). +//! +//! A Lightroom preset doesn't carry its profile's look: it names it (`crs:Look`: name, group, +//! UUID, amount), and many presets get most of their look from that profile. On import the +//! reference is kept in the preset's settings as `profile.look`, and resolved against the +//! profiles the user has imported (by name, the group breaking ties), so the preset applies the +//! profile like the other app does. The six basic profiles of the other app map to our own basic +//! profiles; its other built-in profiles can't be used here and the import says so. +//! +//! References are resolved again whenever profiles are imported or deleted, so a pack works in +//! either order. Only the user's own files are read; no profile content ships with LightCraft. + +use serde_json::{Value, json}; + +use crate::Session; +use crate::cmd::lut_profiles::LutProfile; +use crate::crs::Props; + +/// The profile named by a preset's `crs:Look`, as kept in its settings (`profile.look`): +/// `{name, group?, uuid?, amount}` (amount in percent, like [`lightcraft_develop::Profile`]). +pub fn look_ref(props: &Props) -> Option<Value> { + let field = |k: &str| props.get(&format!("crs:Look/crs:{k}")).and_then(|v| v.first()).map(|s| s.trim()).filter(|s| !s.is_empty()); + let name = field("Name")?; + // stored as a fraction (1 = 100 %), 0–2 + let amount = field("Amount").and_then(|a| a.parse::<f64>().ok()).filter(|a| a.is_finite()).map_or(100.0, |a| (a * 100.0).clamp(0.0, 200.0)); + let mut look = json!({"name": name, "amount": amount}); + if let Some(g) = field("Group") { + look["group"] = json!(g); + } + if let Some(u) = field("UUID") { + look["uuid"] = json!(u.to_ascii_lowercase()); + } + Some(look) +} + +/// What a preset's profile reference resolved to. +#[derive(Clone, Debug, PartialEq)] +pub enum Resolved { + /// A profile the user imported (`lut:…`). + Imported(String), + /// One of our built-in profiles standing in for the other app's basic profile of that name. + BuiltIn(String), + /// One of the other app's own creative or camera profiles: not available here. + OtherAppBuiltIn, + /// A third-party profile the user hasn't imported (yet). + NotImported, +} + +fn norm(s: &str) -> String { + s.split_whitespace().collect::<Vec<_>>().join(" ").to_lowercase() +} + +/// The other app's basic profiles and ours that stand in for them. +const BASIC: &[(&str, &str)] = &[ + ("adobe color", "lc.color"), + ("adobe neutral", "lc.neutral"), + ("adobe vivid", "lc.vivid"), + ("adobe landscape", "lc.landscape"), + ("adobe portrait", "lc.portrait"), + ("adobe monochrome", "lc.mono"), +]; + +/// The other app's own profiles: everything named `Adobe …` or `Camera …`, and its numbered +/// creative profiles (`Artistic 01`, `B&W 05`, `Modern 10`, `Vintage 02`). +fn other_app_builtin(name: &str) -> bool { + let n = norm(name); + if n.starts_with("adobe ") || n.starts_with("camera ") { + return true; + } + ["artistic ", "b&w ", "modern ", "vintage "] + .iter() + .any(|p| n.strip_prefix(p).is_some_and(|rest| !rest.is_empty() && rest.len() <= 2 && rest.chars().all(|c| c.is_ascii_digit()))) +} + +/// Resolve `look` (a `profile.look` value) against the imported profiles. +pub fn resolve(look: &Value, luts: &[LutProfile]) -> Resolved { + let name = norm(look.get("name").and_then(Value::as_str).unwrap_or_default()); + let group = look.get("group").and_then(Value::as_str).map(norm); + if !name.is_empty() { + let same_name: Vec<&LutProfile> = luts.iter().filter(|p| norm(&p.name) == name).collect(); + let pick = same_name.iter().find(|p| group.as_deref() == Some(norm(&p.group).as_str())).or(same_name.first()); + if let Some(p) = pick { + return Resolved::Imported(p.id.clone()); + } + } + if let Some((_, ours)) = BASIC.iter().find(|(theirs, _)| *theirs == name) { + return Resolved::BuiltIn((*ours).to_string()); + } + if other_app_builtin(&name) { Resolved::OtherAppBuiltIn } else { Resolved::NotImported } +} + +/// Set (or clear) `profile.id` / `profile.amount` in a preset's partial settings from its +/// `profile.look`. Returns what the reference resolved to (`None`: the preset names no profile) +/// and whether `settings` changed. +pub fn apply(settings: &mut Value, luts: &[LutProfile]) -> Option<(Resolved, bool)> { + let profile = settings.get_mut("profile")?.as_object_mut()?; + let look = profile.get("look")?.clone(); + let r = resolve(&look, luts); + let before = (profile.get("id").cloned(), profile.get("amount").cloned()); + match &r { + Resolved::Imported(id) | Resolved::BuiltIn(id) => { + profile.insert("id".into(), json!(id)); + profile.insert("amount".into(), look.get("amount").cloned().unwrap_or(json!(100.0))); + } + // the photo keeps its own profile + Resolved::OtherAppBuiltIn | Resolved::NotImported => { + profile.remove("id"); + profile.remove("amount"); + } + } + let changed = before != (profile.get("id").cloned(), profile.get("amount").cloned()); + Some((r, changed)) +} + +/// How the import reports a reference it couldn't resolve (one of the preset's `unmapped` names). +pub fn report(look: &Value, r: &Resolved) -> Option<String> { + let name = look.get("name").and_then(Value::as_str).unwrap_or_default(); + match r { + Resolved::Imported(_) | Resolved::BuiltIn(_) => None, + Resolved::OtherAppBuiltIn => Some(format!("Profile “{name}” (Lightroom's own)")), + Resolved::NotImported => Some(format!("Profile “{name}” (not imported yet)")), + } +} + +/// Resolve every user preset's profile reference again (after profiles were imported or deleted). +/// Only the presets that change are touched, so an unchanged list isn't saved again. +pub fn refresh(s: &mut Session) { + let luts = s.lut_profiles.clone(); + let stale: Vec<usize> = s + .presets + .iter() + .enumerate() + .filter(|(_, p)| !p.builtin) + .filter(|(_, p)| { + let mut settings = p.settings.clone(); + apply(&mut settings, &luts).is_some_and(|(_, changed)| changed) + }) + .map(|(i, _)| i) + .collect(); + if stale.is_empty() { + return; + } + let presets = &mut *s.presets; + for i in stale { + if let Some(p) = presets.get_mut(i) { + apply(&mut p.settings, &luts); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn lut(id: &str, name: &str, group: &str) -> LutProfile { + LutProfile { id: id.into(), name: name.into(), group: group.into(), file: String::new() } + } + + #[test] + fn resolves_by_name_with_the_group_breaking_ties() { + let luts = [lut("lut:a", "Kodak Portra 400", "Films A"), lut("lut:b", "Kodak portra 400", "RNI Films")]; + let look = json!({"name": "Kodak Portra 400", "group": "RNI Films", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:b".into())); + let look = json!({"name": "kodak portra 400", "amount": 100.0}); + assert_eq!(resolve(&look, &luts), Resolved::Imported("lut:a".into()), "first by name without a group"); + assert_eq!(resolve(&json!({"name": "Fuji 400H"}), &luts), Resolved::NotImported); + } + + #[test] + fn other_app_profiles_map_to_ours_or_are_reported() { + assert_eq!(resolve(&json!({"name": "Adobe Monochrome"}), &[]), Resolved::BuiltIn("lc.mono".into())); + for n in ["Adobe Standard", "Camera Faithful", "Artistic 01", "B&W 12", "Vintage 3"] { + assert_eq!(resolve(&json!({"name": n}), &[]), Resolved::OtherAppBuiltIn, "{n}"); + } + assert_eq!(resolve(&json!({"name": "Vintage Film"}), &[]), Resolved::NotImported); + // an imported profile wins over the mapping + assert_eq!(resolve(&json!({"name": "Adobe Vivid"}), &[lut("lut:v", "Adobe Vivid", "Mine")]), Resolved::Imported("lut:v".into())); + } + + #[test] + fn apply_sets_and_clears_the_profile() { + let mut s = json!({"profile": {"look": {"name": "mb1 profile", "amount": 80.0}}, "light": {"highlights": -6.0}}); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, false))); + let luts = [lut("lut:mb1", "mb1 profile", "Imported")]; + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), true))); + assert_eq!((s["profile"]["id"].clone(), s["profile"]["amount"].clone()), (json!("lut:mb1"), json!(80.0))); + assert_eq!(apply(&mut s, &luts), Some((Resolved::Imported("lut:mb1".into()), false)), "already resolved"); + assert_eq!(apply(&mut s, &[]), Some((Resolved::NotImported, true)), "profile deleted"); + assert!(s["profile"].get("id").is_none()); + assert_eq!(apply(&mut json!({"light": {}}), &luts), None); + } + + #[test] + fn look_ref_reads_name_group_uuid_and_amount() { + let x = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0"> + <crs:Look><rdf:Description crs:Name="mb1 profile" crs:Amount="0.75" crs:UUID="0A1B2C"> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Erich McVey B&amp;W Profiles</rdf:li></rdf:Alt></crs:Group> + </rdf:Description></crs:Look> + </rdf:Description></rdf:RDF></x:xmpmeta>"#; + let props = lightcraft_meta::parse_xmp(x).unwrap().properties; + assert_eq!(look_ref(&props), Some(json!({"name": "mb1 profile", "amount": 75.0, "group": "Erich McVey B&W Profiles", "uuid": "0a1b2c"}))); + assert_eq!(look_ref(&Props::new()), None); + } +} diff --git a/crates/engine/src/rename.rs b/crates/engine/src/rename.rs index e49355c08..f0d725793 100644 --- a/crates/engine/src/rename.rs +++ b/crates/engine/src/rename.rs @@ -245,6 +245,19 @@ pub fn expand(template: &str, p: &Photo, seq: usize) -> String { /// The template's tokens replaced for `p` (no extension added, nothing sanitized). A bare `{seq}` /// is zero-padded to `seq_width` digits. pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> String { + expand_tokens_with(template, p, seq, seq_width, &|_, _, _| None) +} + +/// [`expand_tokens`] with more tokens: `extra(name, arg, tag)` (the name lower-cased, the text +/// after `:`, the whole tag without braces) answers a token first; `None` leaves it to the rename +/// tokens (and an unknown one stays as typed). Slideshow text overlays add their tokens this way. +pub fn expand_tokens_with( + template: &str, + p: &Photo, + seq: usize, + seq_width: usize, + extra: &dyn Fn(&str, Option<&str>, &str) -> Option<String>, +) -> String { let (stem, ext) = split_ext(&p.file_name); let mut out = String::new(); let mut rest = template; @@ -257,31 +270,35 @@ pub fn expand_tokens(template: &str, p: &Photo, seq: usize, seq_width: usize) -> }; let tok = &rest[i + 1..i + j]; let (name, arg) = tok.split_once(':').map(|(a, b)| (a, Some(b))).unwrap_or((tok, None)); - let v = match name.trim().to_ascii_lowercase().as_str() { - "name" | "filename" => stem.to_string(), - "num" => { - let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); - stem[stem.len() - digits..].to_string() - } - "seq" | "n" => { - let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); - format!("{seq:0w$}") - } - "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), - "folder" => match &p.source { - Source::File { path } => { - Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + let lower = name.trim().to_ascii_lowercase(); + let v = match extra(&lower, arg, tok) { + Some(v) => v, + None => match lower.as_str() { + "name" | "filename" => stem.to_string(), + "num" => { + let digits = stem.chars().rev().take_while(char::is_ascii_digit).count(); + stem[stem.len() - digits..].to_string() } - Source::Demo { .. } => String::new(), + "seq" | "n" => { + let w: usize = arg.and_then(|a| a.parse().ok()).unwrap_or(seq_width).min(9); + format!("{seq:0w$}") + } + "date" => format_date(p.date(), arg.unwrap_or("%Y%m%d")), + "folder" => match &p.source { + Source::File { path } => { + Path::new(path).parent().and_then(Path::file_name).map(|f| f.to_string_lossy().to_string()).unwrap_or_default() + } + Source::Demo { .. } => String::new(), + }, + "camera" => p.meta.camera.clone(), + "lens" => p.meta.lens.clone(), + "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), + "rating" => p.rating.to_string(), + "title" => p.meta.title.clone(), + "creator" => p.meta.creator.clone(), + "ext" => ext.to_string(), + _ => format!("{{{tok}}}"), }, - "camera" => p.meta.camera.clone(), - "lens" => p.meta.lens.clone(), - "iso" => p.meta.iso.map(|v| v.to_string()).unwrap_or_default(), - "rating" => p.rating.to_string(), - "title" => p.meta.title.clone(), - "creator" => p.meta.creator.clone(), - "ext" => ext.to_string(), - _ => format!("{{{tok}}}"), }; out.push_str(&v); rest = &rest[i + j + 1..]; @@ -416,12 +433,31 @@ impl MoveFs for RealFs { } fn copy_no_replace(&self, a: &Path, b: &Path) -> std::io::Result<()> { - use std::io::Write; - let bytes = std::fs::read(a)?; - let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(b)?; - f.write_all(&bytes).and_then(|_| f.sync_all()).inspect_err(|_| { - let _ = std::fs::remove_file(b); - }) + if self.exists(b) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, format!("{} already exists", b.display()))); + } + // Streamed (a large raw or video is never read into memory whole), into a hidden name + // beside `b` that only takes `b`'s name once complete: a share that drops mid-copy, so + // the partial file can't be removed either, leaves no truncated file under the photo's + // name for a retry to take for the photo. + let name = b.file_name().map(|n| n.to_string_lossy().to_string()).unwrap_or_default(); + // a free name: one left by a copy the share cut off (it couldn't be removed then) doesn't + // block the next + let partial = (0..1000u32) + .map(|k| b.with_file_name(format!(".{name}.lc-partial-{}-{k}", std::process::id()))) + .find(|p| !self.exists(p)) + .ok_or_else(|| std::io::Error::other(format!("no free temporary name beside {}", b.display())))?; + let copied = (|| { + let mut from = std::fs::File::open(a)?; + let mut f = std::fs::OpenOptions::new().write(true).create_new(true).open(&partial)?; + std::io::copy(&mut from, &mut f)?; + f.sync_all() + })(); + let placed = copied.and_then(|()| self.rename_no_replace(&partial, b)); + if placed.is_err() { + let _ = std::fs::remove_file(&partial); + } + placed } fn remove_file(&self, p: &Path) -> std::io::Result<()> { @@ -880,7 +916,8 @@ fn plan_rename_core( ) -> Vec<RenamePlan> { let mut taken: HashSet<String> = HashSet::new(); // lower-case target paths/names claimed in this batch let mut out = Vec::new(); - for (seq, p) in (start..).zip(photos) { + for (i, p) in photos.iter().enumerate() { + let seq = start.saturating_add(i); let want = expand(template, p, seq); let (stem, ext) = split_ext(&want); let (stem, ext) = (stem.to_string(), ext.to_string()); diff --git a/crates/engine/src/sidecar.rs b/crates/engine/src/sidecar.rs index c67a2c84f..cdee1e894 100644 --- a/crates/engine/src/sidecar.rs +++ b/crates/engine/src/sidecar.rs @@ -4,6 +4,8 @@ //! change when [`XmpPrefs::auto_write`] is on): rating, colour label, title, caption, copyright, //! creator, keywords, capture time and GPS in the standard namespaces, plus our complete develop //! settings (`lc:settings`, JSON), the pick/reject flag (`lc:flag`) and location (`lc:location`). +//! A sidecar is written only beside an original that is an existing file at an absolute path; +//! otherwise the save fails. //! //! An existing sidecar is never replaced wholesale (issue #92): LightCraft's properties //! ([`OWNED`], [`OWNED_IF_STATED`]) are merged into it ([`lightcraft_meta::merge_xmp`]) and @@ -63,17 +65,31 @@ pub fn sidecar_path(original: &str, naming: SidecarNaming) -> PathBuf { } /// An existing sidecar for `original`: the preferred naming first, then the other one (also -/// accepting an upper-case `.XMP`). +/// accepting `.XMP` and `.Xmp`, and a full name whose photo extension differs in case, such as +/// `IMG_1.cr3.xmp` beside `IMG_1.CR3`, which a case-sensitive file system would otherwise miss). pub fn find_sidecar(original: &str, naming: SidecarNaming) -> Option<PathBuf> { + sidecar_candidates(original, naming).find(|p| p.is_file()) +} + +/// The names `original`'s sidecar can have, in the order they are looked for: under `naming`, +/// then under the other naming (the full name also with the photo extension in the other case), +/// each as `.xmp`, `.XMP` and `.Xmp`. +pub(crate) fn sidecar_candidates(original: &str, naming: SidecarNaming) -> impl Iterator<Item = PathBuf> { let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; + let ext = Path::new(original).extension().map(|e| e.to_string_lossy().to_string()).unwrap_or_default(); + let base = Path::new(original).with_extension(""); + let recased = |e: String| (e != ext).then(|| format!("{}.{e}", base.to_string_lossy())); + let full_variants: Vec<String> = [recased(ext.to_lowercase()), recased(ext.to_uppercase())].into_iter().flatten().collect(); [naming, other] .into_iter() - .flat_map(|n| { - let p = sidecar_path(original, n); - let upper = p.with_extension("XMP"); - [p, upper] + .flat_map(move |n| { + let mut v = vec![sidecar_path(original, n)]; + if n == SidecarNaming::Full { + v.extend(full_variants.iter().map(|o| sidecar_path(o, n))); + } + v }) - .find(|p| p.is_file()) + .flat_map(|p| [p.clone(), p.with_extension("XMP"), p.with_extension("Xmp")]) } /// What a sidecar (or embedded XMP packet) says about a photo. `None` = not stated. @@ -502,10 +518,10 @@ pub fn read_packet(original: &str, kind: MediaKind, naming: SidecarNaming) -> Op /// Photo ids touched by an op (for auto-write). pub(crate) fn op_photos(op: &Op, out: &mut Vec<PhotoId>) { match op { - Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } => { - if !out.contains(id) { - out.push(*id); - } + Op::SetRating { id, .. } | Op::SetFlag { id, .. } | Op::SetLabel { id, .. } | Op::SetDevelop { id, .. } | Op::SetMeta { id, .. } + if !out.contains(id) => + { + out.push(*id); } Op::Batch { ops } => ops.iter().for_each(|o| op_photos(o, out)), _ => {} @@ -618,6 +634,10 @@ impl Session { return Err(EngineError::Other(format!("{} is a virtual copy: its settings live only in the library", p.file_name))); } let orig = file_path(p).ok_or_else(|| EngineError::Other(format!("{} is not a file on disk", p.file_name)))?; + // A relative path would be taken from the working directory. + if Path::new(orig).is_relative() || !Path::new(orig).is_file() { + return Err(EngineError::Other(format!("{}: the original {orig} is missing; no XMP sidecar is written", p.file_name))); + } let naming = owners.naming(p, self.xmp.naming); let preferred = sidecar_path(orig, naming); let other = if naming == SidecarNaming::Stem { SidecarNaming::Full } else { SidecarNaming::Stem }; @@ -713,17 +733,89 @@ impl Session { } let owners = StemOwners::among(&self.catalog, &ids); for id in ids { - if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) - && let Err(e) = self.save_sidecar_with(id, &owners) - { - log::warn!("auto-write XMP: {e}"); + if self.catalog.photo(id).is_some_and(|p| file_path(p).is_some() && p.copy_of.is_none()) { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + } + Err(e) => { + // kept for a retry once the file is reachable: a network share that dropped out mustn't leave + // the sidecar silently behind the edit + log::warn!("auto-write XMP: {e}"); + self.unsaved_sidecars.insert(id); + } + } } } } + + /// How many photos have an automatic sidecar write waiting (it failed: the folder offline, read-only or full). + pub fn unsaved_sidecars(&self) -> usize { + self.unsaved_sidecars.len() + } + + /// Write the waiting sidecars whose originals are reachable again (the availability cache answers without + /// touching the disk for the others); photos that are gone, or with auto-write off, are dropped from the wait. + /// Returns how many were written. + pub fn retry_unsaved_sidecars(&mut self) -> usize { + if !self.xmp.auto_write { + self.unsaved_sidecars.clear(); + return 0; + } + let mut ready = Vec::new(); + for id in self.unsaved_sidecars.clone() { + match self.catalog.photo(id).filter(|p| p.copy_of.is_none()).and_then(|p| file_path(p).map(str::to_owned)) { + None => { + self.unsaved_sidecars.remove(&id); + } + Some(path) if self.media.availability.exists(&path) == Some(true) => ready.push(id), + Some(_) => {} + } + } + if ready.is_empty() { + return 0; + } + let owners = StemOwners::among(&self.catalog, &ready); + let mut written = 0; + for id in ready { + match self.save_sidecar_with(id, &owners) { + Ok(_) => { + self.unsaved_sidecars.remove(&id); + written += 1; + } + Err(e) => log::debug!("retry XMP: {e}"), + } + } + written + } } #[cfg(test)] mod tests { + #[test] + fn sidecars_are_written_only_beside_an_existing_original() { + let dir = std::env::temp_dir().join(format!("lightcraft-sidecar-local-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write(dir.join("IMG_1.jpg"), b"original").unwrap(); + let originals = [ + (r"\\192.0.2.1\share\IMG_1.jpg".to_string(), false), + (dir.join("IMG_2.jpg").to_string_lossy().to_string(), false), + (dir.join("IMG_1.jpg").to_string_lossy().to_string(), true), + ]; + for (original, written) in originals { + let _ = std::fs::remove_file(dir.join("IMG_1.xmp")); + let mut s = Session::new(); + let p = Photo::new(PhotoId(1), Source::File { path: original.clone() }, "IMG_1.jpg", "JPEG", 4, 3, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + assert_eq!(s.save_sidecar(PhotoId(1)).is_ok(), written, "{original}"); + assert_eq!(dir.join("IMG_1.xmp").is_file(), written, "{original}"); + assert!(!dir.join("IMG_2.xmp").exists(), "{original}"); + assert!(!Path::new(r"\\192.0.2.1\share\IMG_1.xmp").exists(), "{original}"); + } + std::fs::remove_dir_all(dir).unwrap(); + } + /// A sidecar written by Lightroom Classic (names flat, paths in `lr:hierarchicalSubject`) /// gives the photo its keywords as paths. #[test] diff --git a/crates/engine/src/slide.rs b/crates/engine/src/slide.rs new file mode 100644 index 000000000..009a2e2ac --- /dev/null +++ b/crates/engine/src/slide.rs @@ -0,0 +1,203 @@ +//! Slideshow slides in the engine: the library's slide layout ([`SlideSettings`], Lightroom +//! Classic's Layout, Options and Backdrop panels; the `slideshow.layout` command), its overlays +//! ([`crate::slide_overlay`], `slideshow.overlays`) and render jobs that compose a photo into its +//! slide ([`SlideCompose`], [`crate::media::RenderJob::with_slide`]). +//! +//! The composer itself is `lightcraft-slideshow` (no UI, no catalog): playback, the Settings +//! preview and `slideshow.preview` all compose through it, so they draw the same pixels. + +use std::sync::{Arc, Mutex, PoisonError}; + +use lightcraft_catalog::PhotoId; +use lightcraft_raster::Rgba8; +pub use lightcraft_slideshow::{Aspect, Layout, SlideSettings}; + +use crate::media::RenderJob; + +/// The last rendered background image: (its render key, the image). Shared by the slide jobs of +/// a session, so the backdrop photo is rendered once, not once per slide. +pub type BackdropCache = Mutex<Option<(u64, Arc<Rgba8>)>>; + +/// The background image of a slide: a library photo's render job and the shared cache it goes to. +#[derive(Clone)] +pub struct Backdrop { + pub photo: PhotoId, + job: Box<RenderJob>, + cache: Arc<BackdropCache>, +} + +impl Backdrop { + /// The rendered background image (from the cache when it holds this render; otherwise + /// rendered now, on the calling thread). `None` when it can't be rendered (the slide then + /// shows the backdrop colour alone). + pub fn image(&self) -> Option<Arc<Rgba8>> { + let key = self.job.key; + if let Some((k, img)) = self.cache.lock().unwrap_or_else(PoisonError::into_inner).as_ref() + && *k == key + { + return Some(img.clone()); + } + let img = Arc::new((*self.job).clone().run().rendered.ok()?.image); + *self.cache.lock().unwrap_or_else(PoisonError::into_inner) = Some((key, img.clone())); + Some(img) + } +} + +/// How to turn a photo's render into its slide: the layout, the slide's size in pixels and the +/// background image, if any. +#[derive(Clone)] +pub struct SlideCompose { + pub settings: SlideSettings, + pub width: usize, + pub height: usize, + pub backdrop: Option<Backdrop>, + /// The overlays resolved for the slide's photo (`None`: none draw). + pub overlays: Option<Arc<crate::slide_overlay::SlideOverlays>>, +} + +impl SlideCompose { + /// What makes the slide's pixels differ (for render keys). + pub fn key(&self) -> u64 { + self.settings.pixel_key() + ^ (self.width as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15) + ^ (self.height as u64).wrapping_mul(0xc2b2_ae3d_27d4_eb4f) + ^ self.backdrop.as_ref().map_or(0, |b| b.job.key.rotate_left(29)) + ^ self.overlays.as_ref().map_or(0, |o| o.key().rotate_left(43)) + } + + /// The slide showing `photo` (rendered at [`lightcraft_slideshow::photo_size`], or any size). + pub fn compose(&self, photo: &Rgba8) -> Rgba8 { + let bg = self.backdrop.as_ref().and_then(Backdrop::image); + let Some(o) = &self.overlays else { + return lightcraft_slideshow::compose(photo, bg.as_deref(), &self.settings, self.width, self.height); + }; + let (mut img, l) = + lightcraft_slideshow::compose_with(photo, bg.as_deref(), &self.settings, self.width, self.height, &mut |img, l| o.draw_behind(img, l)); + o.draw_front(&mut img, &l); + img + } +} + +impl crate::Session { + /// The aspect (width / height) photo `id` shows at: cropped, straightened, lens-corrected. + pub fn slide_photo_aspect(&self, id: PhotoId) -> Option<f64> { + let p = self.catalog.photo(id)?; + let frame = lightcraft_pipeline::geometry::Frame::with_lens( + p.width.max(1) as usize, + p.height.max(1) as usize, + &p.develop, + true, + p.embedded_lens.as_ref(), + ); + let a = frame.aspect(); + Some(if a.is_finite() && a > 0.0 { a } else { 1.5 }) + } + + /// Does a slide show nothing but the photo fitted on black (the plain layout and no + /// overlays)? Playback then shows the photo's own render. + pub fn slides_plain(&self) -> bool { + self.slide_layout.is_plain() && !self.slide_overlays.any() + } + + /// The composition of `settings` at `width × height`, with its background image's render job + /// (the photo `settings.background_image` names, when it is in the catalog) and the library's + /// overlays resolved for `photo`. + pub fn slide_compose(&mut self, settings: &SlideSettings, width: usize, height: usize, photo: Option<PhotoId>) -> Arc<SlideCompose> { + let overlays = self.slide_overlays.clone(); + self.slide_compose_with(settings, &overlays, width, height, photo) + } + + /// [`Self::slide_compose`] with `overlays` in place of the library's (a template's preview). + pub fn slide_compose_with( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + width: usize, + height: usize, + photo: Option<PhotoId>, + ) -> Arc<SlideCompose> { + let settings = settings.clone().sanitized(); + let backdrop = settings.background_image.filter(|_| settings.background_opacity > 0.0).and_then(|bid| { + let id = PhotoId(bid); + let aspect = self.slide_photo_aspect(id)?; + let fill = + SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..settings.clone() }; + let (w, h) = lightcraft_slideshow::photo_size(&fill, aspect, width, height); + let job = self.render_job(id, w, h, false, true)?; + Some(Backdrop { photo: id, job: Box::new(job), cache: self.slide_backdrop.clone() }) + }); + let overlays = overlays.any().then(|| { + let p = photo.and_then(|id| self.catalog.photo(id)); + Arc::new(crate::slide_overlay::SlideOverlays::resolve(overlays, p.map(|p| &**p))) + }); + Arc::new(SlideCompose { settings, width, height, backdrop, overlays }) + } + + /// A render job whose result is photo `id`'s slide at `width × height` with the library's + /// slide layout: the photo is rendered at the size its cell needs (its long edge at most + /// `photo_edge`, when given), then composed on the worker. The photo's render is kept as its + /// view preview, like the loupe's. + pub fn slide_job(&mut self, id: PhotoId, width: usize, height: usize, photo_edge: Option<usize>) -> Option<RenderJob> { + let settings = self.slide_layout.clone(); + self.slide_job_with(&settings, id, width, height, photo_edge, true) + } + + /// [`Self::slide_job`] with `settings` in place of the library's layout; `view` keeps the + /// photo's render as its view preview. + pub fn slide_job_with( + &mut self, + settings: &SlideSettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option<usize>, + view: bool, + ) -> Option<RenderJob> { + let overlays = self.slide_overlays.clone(); + self.slide_job_look(settings, &overlays, id, width, height, photo_edge, view) + } + + /// [`Self::slide_job_with`] with `overlays` in place of the library's (a template's preview). + #[allow(clippy::too_many_arguments)] + pub fn slide_job_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + photo_edge: Option<usize>, + view: bool, + ) -> Option<RenderJob> { + let aspect = self.slide_photo_aspect(id)?; + let (mut w, mut h) = lightcraft_slideshow::photo_size(settings, aspect, width, height); + if let Some(edge) = photo_edge.filter(|e| *e > 0 && w.max(h) > *e) { + let k = edge as f64 / w.max(h) as f64; + (w, h) = (((w as f64 * k).round() as usize).max(1), ((h as f64 * k).round() as usize).max(1)); + } + let job = if view { self.loupe_job(id, w.max(8), h.max(8), true)? } else { self.render_job(id, w.max(8), h.max(8), false, true)? }; + let compose = self.slide_compose_with(settings, overlays, width, height, Some(id)); + Some(job.with_slide(compose)) + } + + /// Photo `id`'s slide at `width × height` with `settings`, rendered now on this thread. + pub fn slide_now(&mut self, settings: &SlideSettings, id: PhotoId, width: usize, height: usize) -> Result<Rgba8, String> { + let overlays = self.slide_overlays.clone(); + self.slide_now_look(settings, &overlays, id, width, height) + } + + /// [`Self::slide_now`] with `overlays` in place of the library's. + pub fn slide_now_look( + &mut self, + settings: &SlideSettings, + overlays: &crate::slide_overlay::OverlaySettings, + id: PhotoId, + width: usize, + height: usize, + ) -> Result<Rgba8, String> { + let job = self.slide_job_look(settings, overlays, id, width, height, None, false).ok_or("no such photo")?; + let r = job.run(); + self.accept(&r); + r.rendered.map(|r| r.image) + } +} diff --git a/crates/engine/src/slide_overlay.rs b/crates/engine/src/slide_overlay.rs new file mode 100644 index 000000000..a27f75d49 --- /dev/null +++ b/crates/engine/src/slide_overlay.rs @@ -0,0 +1,1344 @@ +//! Slideshow overlays (Lightroom Classic's Slideshow ▸ Overlays panel): the identity plate (text +//! or an image file), the photo's rating as stars, text overlays with tokens (file name, title, +//! caption, capture date, exposure, camera, lens, rating…) and the export watermark. +//! [`OverlaySettings`] is a library preference (`prefs.json`), edited with the +//! `slideshow.overlays` command; [`SlideOverlays`] is those settings resolved for one photo (its +//! texts expanded, its rating) and drawn onto the composed slide. +//! +//! **Where the drawing lives:** text needs the fonts (Inter plus the craft-fonts CJK fallbacks, +//! [`crate::text`]) and tokens need the catalog's photo, both in the engine, so overlays are a +//! second pass in the engine over `lightcraft_slideshow::compose_with`'s slide (its `behind` hook +//! draws an identity plate set to "render behind image" before the photo). The composer stays a +//! layout-and-pixels crate with no fonts and no catalog. +//! +//! **Resolution independence:** sizes and offsets are fractions of the slide's shorter side (also +//! for overlays anchored to the photo), so a slide at any size has the same proportions. An +//! overlay is placed at one of nine anchors of the slide or of the photo, moved by its offset +//! (x right, y down) and kept on the slide. +//! +//! **Never crash:** settings are validated by [`OverlaySettings::merged`] and clamped by +//! [`OverlaySettings::sanitized`]; text is cut at [`TEXT_MAX_CHARS`]; an unreadable identity +//! plate image draws nothing (and `slideshow.overlays` reports it); unknown tokens stay as typed +//! (as in rename and export file names) and are reported. + +use std::sync::Arc; + +use lightcraft_catalog::Photo; +use lightcraft_raster::{Image, Plane, Rgba8}; +use lightcraft_slideshow::{Layout, Rect, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::{Anchor, Watermark}; + +/// Smallest text, plate and star size (fraction of the slide's shorter side). +pub const SIZE_MIN: f64 = 0.005; +/// Largest text line height. +pub const TEXT_SIZE_MAX: f64 = 0.3; +/// Largest identity plate height. +pub const PLATE_SIZE_MAX: f64 = 0.5; +/// Largest star. +pub const STAR_SIZE_MAX: f64 = 0.15; +/// Largest offset either way (fraction of the shorter side). +pub const OFFSET_MAX: f64 = 1.0; +/// Longest shadow offset and radius (fractions of the shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.05; +pub const SHADOW_RADIUS_MAX: f64 = 0.05; +/// Most text overlays. +pub const MAX_TEXTS: usize = 16; +/// Longest overlay text or template (characters; longer is cut). +pub const TEXT_MAX_CHARS: usize = 1000; + +/// What an overlay is anchored to. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum RelativeTo { + #[default] + Slide, + /// The visible photo. + Photo, +} + +impl RelativeTo { + fn id(self) -> &'static str { + match self { + RelativeTo::Slide => "slide", + RelativeTo::Photo => "photo", + } + } +} + +/// The nine anchors, in reading order (for pickers and tests). +pub const ANCHORS: [Anchor; 9] = [ + Anchor::TopLeft, + Anchor::Top, + Anchor::TopRight, + Anchor::Left, + Anchor::Center, + Anchor::Right, + Anchor::BottomLeft, + Anchor::Bottom, + Anchor::BottomRight, +]; + +/// An anchor's id (`topLeft`…). +pub fn anchor_id(a: Anchor) -> &'static str { + match a { + Anchor::TopLeft => "topLeft", + Anchor::Top => "top", + Anchor::TopRight => "topRight", + Anchor::Left => "left", + Anchor::Center => "center", + Anchor::Right => "right", + Anchor::BottomLeft => "bottomLeft", + Anchor::Bottom => "bottom", + Anchor::BottomRight => "bottomRight", + } +} + +/// Where an overlay goes: an anchor of the slide or the photo, and an offset from it. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct Placement { + pub anchor: Anchor, + /// Fractions of the slide's shorter side, x to the right, y down. + pub offset_x: f64, + pub offset_y: f64, + pub relative_to: RelativeTo, +} + +impl Default for Placement { + fn default() -> Self { + Placement { anchor: Anchor::BottomRight, offset_x: -0.03, offset_y: -0.03, relative_to: RelativeTo::Slide } + } +} + +impl Placement { + fn sanitized(mut self) -> Self { + self.offset_x = clamp_or(self.offset_x, -OFFSET_MAX, OFFSET_MAX, 0.0); + self.offset_y = clamp_or(self.offset_y, -OFFSET_MAX, OFFSET_MAX, 0.0); + self + } + + fn json(&self, o: &mut Map<String, Value>) { + o.insert("anchor".into(), json!(anchor_id(self.anchor))); + o.insert("offsetX".into(), json!(self.offset_x)); + o.insert("offsetY".into(), json!(self.offset_y)); + o.insert("relativeTo".into(), json!(self.relative_to.id())); + } + + /// Apply a placement key; `false` when `k` isn't one. + fn merge(&mut self, ctx: &str, k: &str, v: &Value) -> Result<bool, String> { + match k { + "anchor" => { + self.anchor = serde_json::from_value(v.clone()).map_err(|_| { + format!("`{ctx}.anchor` must be one of topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, not {v}") + })? + } + "offsetX" => self.offset_x = number(ctx, k, v)?, + "offsetY" => self.offset_y = number(ctx, k, v)?, + "relativeTo" => { + self.relative_to = match v.as_str() { + Some("slide") => RelativeTo::Slide, + Some("photo") => RelativeTo::Photo, + _ => return Err(format!("`{ctx}.relativeTo` must be \"slide\" or \"photo\", not {v}")), + } + } + _ => return Ok(false), + } + Ok(true) + } + + /// The box of a `w × h` overlay on the slide `l`: at the anchor of the slide or the visible + /// photo, moved by the offset, then kept on the slide (when it fits). + pub fn place(&self, l: &Layout, w: f64, h: f64) -> Rect { + let c = match self.relative_to { + RelativeTo::Slide => l.slide, + RelativeTo::Photo => l.visible, + }; + let short = l.slide.width().min(l.slide.height()); + use Anchor::*; + let x = match self.anchor { + TopLeft | Left | BottomLeft => c.x0, + Top | Center | Bottom => (c.x0 + c.x1 - w) / 2.0, + TopRight | Right | BottomRight => c.x1 - w, + } + self.offset_x * short; + let y = match self.anchor { + TopLeft | Top | TopRight => c.y0, + Left | Center | Right => (c.y0 + c.y1 - h) / 2.0, + BottomLeft | Bottom | BottomRight => c.y1 - h, + } + self.offset_y * short; + let s = l.slide; + let fit = |v: f64, lo: f64, hi: f64, len: f64| if v.is_finite() { v.min(hi - len).max(lo) } else { lo }; + let (x, y) = (fit(x, s.x0, s.x1, w), fit(y, s.y0, s.y1, h)); + Rect { x0: x, y0: y, x1: x + w, y1: y + h } + } +} + +/// A soft drop shadow under text. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextShadow { + pub on: bool, + pub opacity: f64, + /// Fraction of the slide's shorter side. + pub offset: f64, + /// Blur radius, fraction of the slide's shorter side. + pub radius: f64, + /// Degrees counter-clockwise from the right (−45 = down right). + pub angle: f64, +} + +impl Default for TextShadow { + fn default() -> Self { + TextShadow { on: false, opacity: 0.6, offset: 0.004, radius: 0.004, angle: -45.0 } + } +} + +/// The identity plate: a line of text, or an image file (PNG / JPEG…, its transparency kept). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct IdentityPlate { + pub enabled: bool, + pub text: String, + /// An image file drawn instead of the text (empty = the text). + pub image: String, + /// Height: the text's line height or the image's height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + /// Draw it before the photo, so the photo covers it where they meet. + pub behind: bool, +} + +impl Default for IdentityPlate { + fn default() -> Self { + IdentityPlate { + enabled: false, + text: "LightCraft".into(), + image: String::new(), + size: 0.05, + color: [255, 255, 255], + opacity: 0.8, + place: Placement { anchor: Anchor::TopLeft, offset_x: 0.03, offset_y: 0.03, relative_to: RelativeTo::Slide }, + behind: false, + } + } +} + +/// The photo's star rating, drawn as stars. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct RatingStars { + pub enabled: bool, + pub color: [u8; 3], + /// A star's height, fraction of the shorter side. + pub size: f64, + pub place: Placement, +} + +impl Default for RatingStars { + fn default() -> Self { + RatingStars { + enabled: false, + color: [235, 235, 235], + size: 0.03, + place: Placement { anchor: Anchor::TopRight, offset_x: -0.015, offset_y: 0.015, relative_to: RelativeTo::Photo }, + } + } +} + +/// A text overlay: a template with tokens (`{title}`, `{caption}`, `{exposure}`…; see [`TOKENS`]). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TextOverlay { + pub text: String, + /// Line height, fraction of the shorter side. + pub size: f64, + pub color: [u8; 3], + pub opacity: f64, + pub place: Placement, + pub shadow: TextShadow, +} + +impl Default for TextOverlay { + fn default() -> Self { + TextOverlay { + text: "{file}".into(), + size: 0.03, + color: [255, 255, 255], + opacity: 0.9, + place: Placement { anchor: Anchor::Bottom, offset_x: 0.0, offset_y: -0.02, relative_to: RelativeTo::Slide }, + shadow: TextShadow::default(), + } + } +} + +/// Every overlay of a slide. +#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct OverlaySettings { + pub identity_plate: IdentityPlate, + pub rating_stars: RatingStars, + pub texts: Vec<TextOverlay>, + /// Draw [`Self::watermark`] on the photo. + pub show_watermark: bool, + /// The export watermark's settings (sizes relative to the photo, as in export). + pub watermark: Watermark, +} + +/// Tokens text overlays add to the rename tokens ([`crate::rename::TOKENS`]): tag, meaning. +pub const TOKENS: &[(&str, &str)] = &[ + ("{file}", "File name with its extension"), + ("{caption}", "Caption metadata"), + ("{copyright}", "Copyright metadata"), + ("{exposure}", "Shutter speed and aperture (1/250 s at f/8)"), + ("{shutter}", "Shutter speed"), + ("{aperture}", "Aperture (f/8)"), + ("{focal}", "Focal length (50 mm)"), + ("{date}", "Capture date as YYYY-MM-DD (or {date:%d.%m.%Y} …)"), +]; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +/// `s` cut at [`TEXT_MAX_CHARS`] characters. +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +fn number(ctx: &str, k: &str, v: &Value) -> Result<f64, String> { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + } +} + +fn boolean(ctx: &str, k: &str, v: &Value) -> Result<bool, String> { + v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")) +} + +fn string(ctx: &str, k: &str, v: &Value) -> Result<String, String> { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.{k}` must be text, not {v}"))?.to_string(); + cut(&mut s); + Ok(s) +} + +fn colour(ctx: &str, k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +fn object<'a>(ctx: &str, v: &'a Value) -> Result<&'a Map<String, Value>, String> { + v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}")) +} + +fn unknown(ctx: &str, k: &str, keys: &str) -> String { + format!("unknown key `{ctx}.{k}` ({keys})") +} + +impl IdentityPlate { + fn sanitized(mut self) -> Self { + let d = IdentityPlate::default(); + cut(&mut self.text); + cut(&mut self.image); + self.size = clamp_or(self.size, SIZE_MIN, PLATE_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("text".into(), json!(self.text)); + o.insert("image".into(), json!(self.image)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("behind".into(), json!(self.behind)); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "identityPlate"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "text" => self.text = string(C, k, v)?, + "image" => self.image = string(C, k, v)?.trim().to_string(), + "size" => self.size = number(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "opacity" => self.opacity = number(C, k, v)?, + "behind" => self.behind = boolean(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, text, image, size, color, opacity, anchor, offsetX, offsetY, relativeTo, behind")), + } + } + Ok(()) + } +} + +impl RatingStars { + fn sanitized(mut self) -> Self { + self.size = clamp_or(self.size, SIZE_MIN, STAR_SIZE_MAX, RatingStars::default().size); + self.place = self.place.sanitized(); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("size".into(), json!(self.size)); + self.place.json(&mut o); + Value::Object(o) + } + + fn merge(&mut self, v: &Value) -> Result<(), String> { + const C: &str = "ratingStars"; + for (k, v) in object(C, v)? { + if self.place.merge(C, k, v)? { + continue; + } + match k.as_str() { + "enabled" => self.enabled = boolean(C, k, v)?, + "color" => self.color = colour(C, k, v)?, + "size" => self.size = number(C, k, v)?, + _ => return Err(unknown(C, k, "enabled, color, size, anchor, offsetX, offsetY, relativeTo")), + } + } + Ok(()) + } +} + +impl TextOverlay { + fn sanitized(mut self) -> Self { + let d = TextOverlay::default(); + cut(&mut self.text); + self.size = clamp_or(self.size, SIZE_MIN, TEXT_SIZE_MAX, d.size); + self.opacity = clamp_or(self.opacity, 0.0, 1.0, d.opacity); + self.place = self.place.sanitized(); + let ds = TextShadow::default(); + let sh = &mut self.shadow; + sh.opacity = clamp_or(sh.opacity, 0.0, 1.0, ds.opacity); + sh.offset = clamp_or(sh.offset, 0.0, SHADOW_OFFSET_MAX, ds.offset); + sh.radius = clamp_or(sh.radius, 0.0, SHADOW_RADIUS_MAX, ds.radius); + sh.angle = wrap_angle(sh.angle, ds.angle); + self + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("text".into(), json!(self.text)); + o.insert("size".into(), json!(self.size)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("opacity".into(), json!(self.opacity)); + self.place.json(&mut o); + o.insert("shadow".into(), json!(self.shadow.on)); + o.insert("shadowOpacity".into(), json!(self.shadow.opacity)); + o.insert("shadowOffset".into(), json!(self.shadow.offset)); + o.insert("shadowRadius".into(), json!(self.shadow.radius)); + o.insert("shadowAngle".into(), json!(self.shadow.angle)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + for (k, v) in object(ctx, v)? { + if self.place.merge(ctx, k, v)? { + continue; + } + match k.as_str() { + "text" => self.text = string(ctx, k, v)?, + "size" => self.size = number(ctx, k, v)?, + "color" => self.color = colour(ctx, k, v)?, + "opacity" => self.opacity = number(ctx, k, v)?, + "shadow" => self.shadow.on = boolean(ctx, k, v)?, + "shadowOpacity" => self.shadow.opacity = number(ctx, k, v)?, + "shadowOffset" => self.shadow.offset = number(ctx, k, v)?, + "shadowRadius" => self.shadow.radius = number(ctx, k, v)?, + "shadowAngle" => self.shadow.angle = number(ctx, k, v)?, + _ => { + return Err(unknown( + ctx, + k, + "text, size, color, opacity, anchor, offsetX, offsetY, relativeTo, shadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle", + )); + } + } + } + Ok(()) + } +} + +/// The keys [`OverlaySettings::merged`] takes. +const KEYS: &str = "identityPlate, ratingStars, texts, addText, removeText, showWatermark, watermark"; + +impl OverlaySettings { + /// Every value in range (non-finite numbers take the defaults), at most [`MAX_TEXTS`] texts. + pub fn sanitized(mut self) -> Self { + self.identity_plate = self.identity_plate.sanitized(); + self.rating_stars = self.rating_stars.sanitized(); + self.texts.truncate(MAX_TEXTS); + self.texts = self.texts.into_iter().map(TextOverlay::sanitized).collect(); + let d = Watermark::default(); + let w = &mut self.watermark; + let f = |v: f32, lo: f32, hi: f32, fb: f32| if v.is_finite() { v.clamp(lo, hi) } else { fb }; + w.size = f(w.size, 0.005, 0.5, d.size); + w.opacity = f(w.opacity, 0.0, 1.0, d.opacity); + w.inset_x = f(w.inset_x, 0.0, 0.4, d.inset_x); + w.inset_y = f(w.inset_y, 0.0, 0.4, d.inset_y); + if !matches!(w.rotation, 0 | 90 | 180 | 270) { + w.rotation = 0; + } + w.image_width = f(w.image_width, 0.01, 1.0, d.image_width); + cut(&mut w.text); + w.target = None; + self + } + + /// Does any overlay draw (on some photo)? + pub fn any(&self) -> bool { + let p = &self.identity_plate; + (p.enabled && p.opacity > 0.0 && (!p.text.trim().is_empty() || !p.image.trim().is_empty())) + || self.rating_stars.enabled + || self.texts.iter().any(|t| t.opacity > 0.0 && !t.text.trim().is_empty()) + || (self.show_watermark && (!self.watermark.text.trim().is_empty() || !self.watermark.image.trim().is_empty())) + } + + pub fn to_json(&self) -> Value { + let wm = serde_json::to_value(&self.watermark).unwrap_or(Value::Null); + json!({ + "identityPlate": self.identity_plate.to_json(), + "ratingStars": self.rating_stars.to_json(), + "texts": self.texts.iter().map(TextOverlay::to_json).collect::<Vec<_>>(), + "showWatermark": self.show_watermark, + "watermark": wm, + }) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. `texts` replaces the list (each entry merged + /// onto the one at its index, or a new default text); `addText` appends one (an object, `{}` + /// for the default); `removeText` removes the one at an index; `watermark` takes the export + /// watermark's keys (or text alone) merged onto the current one. + pub fn merged(&self, p: &Value) -> Result<Self, String> { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "identityPlate" => s.identity_plate.merge(v)?, + "ratingStars" => s.rating_stars.merge(v)?, + "texts" => { + let list = v.as_array().ok_or_else(|| format!("`texts` must be a list of text overlays, not {v}"))?; + if list.len() > MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays, not {}", list.len())); + } + let mut next = Vec::with_capacity(list.len()); + for (i, item) in list.iter().enumerate() { + let mut t = s.texts.get(i).cloned().unwrap_or_default(); + t.merge(&format!("texts[{i}]"), item)?; + next.push(t); + } + s.texts = next; + } + "addText" => { + if s.texts.len() >= MAX_TEXTS { + return Err(format!("at most {MAX_TEXTS} text overlays")); + } + let mut t = TextOverlay::default(); + t.merge("addText", v)?; + s.texts.push(t); + } + "removeText" => { + let i = v.as_u64().ok_or_else(|| format!("`removeText` must be the index of a text overlay, not {v}"))?; + let i = usize::try_from(i).ok().filter(|i| *i < s.texts.len()).ok_or_else(|| format!("no text overlay {i}"))?; + s.texts.remove(i); + } + "showWatermark" => s.show_watermark = boolean("", k, v).map_err(|_| format!("`showWatermark` must be true or false, not {v}"))?, + "watermark" => { + crate::export::ExportOptions::validate_watermark("slideshow.overlays", v).map_err(|e| e.to_string())?; + s.watermark = match v { + Value::String(t) => Watermark { text: t.clone(), ..s.watermark.clone() }, + _ => { + let mut cur = serde_json::to_value(&s.watermark).map_err(|e| e.to_string())?; + if let (Some(c), Some(o)) = (cur.as_object_mut(), v.as_object()) { + for (k, v) in o.iter().filter(|(_, v)| !v.is_null()) { + if k == "inset" { + // the legacy single margin sets whichever of the two the patch leaves out + for axis in ["insetX", "insetY"] { + if !o.contains_key(axis) { + c.insert(axis.into(), v.clone()); + } + } + } else { + c.insert(k.clone(), v.clone()); + } + } + } + serde_json::from_value(cur).map_err(|e| format!("`watermark`: {e}"))? + } + }; + } + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + Ok(s.sanitized()) + } + + /// A hash of the settings (for render keys). Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.clone().sanitized().to_json().to_string().hash(&mut h); + h.finish() + } + + /// Problems worth telling the user about: an identity plate image that can't be read, tags in + /// text overlays that aren't tokens (they show as typed). + pub fn warnings(&self) -> Vec<String> { + let mut out = Vec::new(); + let p = &self.identity_plate; + if p.enabled && !p.image.trim().is_empty() && crate::export::watermark_logo(p.image.trim()).is_none() { + out.push(format!("identity plate image {} can't be read; nothing is drawn", p.image.trim())); + } + for (i, t) in self.texts.iter().enumerate() { + for tag in unknown_tokens(&t.text) { + out.push(format!("text overlay {}: {tag} isn't a token and shows as typed", i + 1)); + } + } + out + } +} + +/// An exposure as "1/250 s at f/8" (either part alone when the other is unknown). +fn exposure(p: &Photo) -> String { + let parts: Vec<String> = [shutter(p), aperture(p)].into_iter().filter(|s| !s.is_empty()).collect(); + parts.join(" at ") +} + +fn shutter(p: &Photo) -> String { + let s = p.meta.shutter.trim(); + if s.is_empty() || s.ends_with('s') || s.ends_with('"') { s.to_string() } else { format!("{s} s") } +} + +fn aperture(p: &Photo) -> String { + p.meta.aperture.filter(|a| a.is_finite() && *a > 0.0).map(|a| format!("f/{a:.1}").replace(".0", "")).unwrap_or_default() +} + +/// The overlay tokens beyond the rename ones; `{seq}` has no meaning on a slide and stays as typed. +fn overlay_token(p: &Photo, name: &str, arg: Option<&str>, tag: &str) -> Option<String> { + Some(match name { + "file" => p.file_name.clone(), + "caption" => p.meta.caption.clone(), + "copyright" => p.meta.copyright.clone(), + "exposure" => exposure(p), + "shutter" => shutter(p), + "aperture" => aperture(p), + "focal" => p.meta.focal_mm.filter(|f| f.is_finite() && *f > 0.0).map(|f| format!("{f:.0} mm")).unwrap_or_default(), + "date" if arg.is_none() => crate::rename::expand_tokens("{date:%Y-%m-%d}", p, 1, 1), + "seq" | "n" => format!("{{{tag}}}"), + _ => return None, + }) +} + +/// `template`'s tokens replaced for `p`: the rename tokens ([`crate::rename::expand_tokens`]) and +/// [`TOKENS`]. Unknown tags stay as typed. +pub fn expand(template: &str, p: &Photo) -> String { + crate::rename::expand_tokens_with(template, p, 1, 1, &|name, arg, tag| overlay_token(p, name, arg, tag)) +} + +/// The `{…}` tags in `template` that aren't tokens. +pub fn unknown_tokens(template: &str) -> Vec<String> { + let p = crate::rename::sample_photo(); + let mut out = Vec::new(); + let mut rest = template; + while let Some(i) = rest.find('{') { + let Some(after) = rest.get(i..) else { break }; + let Some(j) = after.find('}') else { break }; + let Some(tag) = after.get(..j + 1) else { break }; + if expand(tag, &p) == tag && !out.iter().any(|t| t == tag) { + out.push(tag.to_string()); + } + rest = after.get(j + 1..).unwrap_or(""); + } + out +} + +/// What an overlay box is, for [`SlideOverlays::boxes`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Kind { + IdentityPlate, + RatingStars, + /// The text overlay at this index. + Text(usize), + Watermark, +} + +impl Kind { + pub fn id(&self) -> String { + match self { + Kind::IdentityPlate => "identityPlate".into(), + Kind::RatingStars => "ratingStars".into(), + Kind::Text(i) => format!("text{i}"), + Kind::Watermark => "watermark".into(), + } + } +} + +/// The overlays of one photo's slide: the settings, its texts expanded, its rating. +#[derive(Clone, Debug, PartialEq)] +pub struct SlideOverlays { + pub settings: OverlaySettings, + /// One per text overlay, tokens replaced. + pub texts: Vec<String>, + pub rating: u8, +} + +/// Gap between stars, as a share of a star's size. +const STAR_GAP: f64 = 0.2; + +impl SlideOverlays { + /// `settings` resolved for `photo` (`None`: no photo, so no tokens and no rating). + pub fn resolve(settings: &OverlaySettings, photo: Option<&Photo>) -> SlideOverlays { + let settings = settings.clone().sanitized(); + let texts = settings.texts.iter().map(|t| photo.map(|p| expand(&t.text, p)).unwrap_or_else(|| t.text.clone())).collect(); + SlideOverlays { settings, texts, rating: photo.map_or(0, |p| p.rating.min(5)) } + } + + /// What makes the drawn overlays differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + self.settings.pixel_key().hash(&mut h); + self.texts.hash(&mut h); + self.rating.hash(&mut h); + h.finish() + } + + fn short(l: &Layout) -> f64 { + l.slide.width().min(l.slide.height()).max(1.0) + } + + /// The identity plate's text layout, or its image scaled to its size. + fn plate(&self, l: &Layout) -> Option<PlateContent> { + let p = &self.settings.identity_plate; + if !p.enabled || p.opacity <= 0.0 { + return None; + } + let short = Self::short(l); + let px = (p.size * short).max(1.0); + if !p.image.trim().is_empty() { + let img = crate::export::watermark_logo(p.image.trim())?; + if img.width == 0 || img.height == 0 { + return None; + } + let h = px.round().clamp(1.0, l.slide.height().max(1.0)); + let w = (h * img.width as f64 / img.height as f64).round().clamp(1.0, l.slide.width().max(1.0)); + return Some(PlateContent::Image(img, w as usize, h as usize)); + } + let text = p.text.trim(); + crate::text::TextLayout::new(text, px as f32, false, crate::fonts::CRAFT_FONTS).map(PlateContent::Text) + } + + fn text_layout(&self, i: usize, l: &Layout) -> Option<crate::text::TextLayout> { + let t = self.settings.texts.get(i)?; + let text = self.texts.get(i)?.trim(); + if t.opacity <= 0.0 { + return None; + } + crate::text::TextLayout::new(text, (t.size * Self::short(l)).max(1.0) as f32, false, crate::fonts::CRAFT_FONTS) + } + + fn stars_size(&self, l: &Layout) -> Option<(usize, f64)> { + let r = &self.settings.rating_stars; + let n = usize::from(self.rating.min(5)); + (r.enabled && n > 0).then(|| (n, (r.size * Self::short(l)).max(2.0))) + } + + /// Where each overlay goes on the slide `l` (the ones that draw something). + pub fn boxes(&self, l: &Layout) -> Vec<(Kind, Rect)> { + let mut out = Vec::new(); + let s = &self.settings; + if let Some(c) = self.plate(l) { + let (w, h) = c.size(); + out.push((Kind::IdentityPlate, s.identity_plate.place.place(l, w, h))); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + out.push((Kind::RatingStars, s.rating_stars.place.place(l, w, size))); + } + for (i, t) in s.texts.iter().enumerate() { + if let Some(tl) = self.text_layout(i, l) { + out.push((Kind::Text(i), t.place.place(l, tl.width as f64, tl.height as f64))); + } + } + if s.show_watermark && (!s.watermark.text.trim().is_empty() || !s.watermark.image.trim().is_empty()) { + out.push((Kind::Watermark, l.visible)); + } + out + } + + /// Draw the overlays drawn behind the photo (an identity plate with "render behind image"). + pub fn draw_behind(&self, img: &mut Rgba8, l: &Layout) { + if self.settings.identity_plate.behind { + self.draw_plate(img, l); + } + } + + /// Draw the overlays over the composed slide: the watermark on the photo, then the identity + /// plate (unless it is behind), the rating stars and the text overlays. + pub fn draw_front(&self, img: &mut Rgba8, l: &Layout) { + let s = &self.settings; + if s.show_watermark { + let (x0, y0, x1, y1) = pixels(&l.visible, img.width, img.height); + if x1 > x0 && y1 > y0 { + crate::export::draw_watermark_in(img, &s.watermark, x0, y0, x1 - x0, y1 - y0); + } + } + if !s.identity_plate.behind { + self.draw_plate(img, l); + } + if let Some((n, size)) = self.stars_size(l) { + let w = n as f64 * size + (n as f64 - 1.0) * size * STAR_GAP; + let b = s.rating_stars.place.place(l, w, size); + for i in 0..n { + let x = b.x0 + i as f64 * size * (1.0 + STAR_GAP); + star(img, x, b.y0, size, s.rating_stars.color); + } + } + for (i, t) in s.texts.iter().enumerate() { + let Some(tl) = self.text_layout(i, l) else { continue }; + let b = t.place.place(l, tl.width as f64, tl.height as f64); + let short = Self::short(l); + if t.shadow.on && t.shadow.opacity > 0.0 { + let (d, a) = (t.shadow.offset * short, t.shadow.angle.to_radians()); + let (dx, dy) = (d * a.cos(), -d * a.sin()); + shadow(img, &tl, b.x0 + dx, b.y0 + dy, t.shadow.radius * short / 2.0, (t.shadow.opacity * t.opacity) as f32); + } + draw_text(img, &tl, b.x0, b.y0, t.color, t.opacity as f32); + } + } + + fn draw_plate(&self, img: &mut Rgba8, l: &Layout) { + let p = &self.settings.identity_plate; + let Some(c) = self.plate(l) else { return }; + let (w, h) = c.size(); + let b = p.place.place(l, w, h); + match c { + PlateContent::Text(tl) => draw_text(img, &tl, b.x0, b.y0, p.color, p.opacity as f32), + PlateContent::Image(src, w, h) => { + if let Some(scaled) = scaled_graphic(p.image.trim(), &src, w, h) { + draw_graphic(img, &scaled, b.x0.round() as i64, b.y0.round() as i64, p.opacity as f32); + } + } + } + } +} + +enum PlateContent { + Text(crate::text::TextLayout), + Image(Arc<Rgba8>, usize, usize), +} + +impl PlateContent { + fn size(&self) -> (f64, f64) { + match self { + PlateContent::Text(t) => (t.width as f64, t.height as f64), + PlateContent::Image(_, w, h) => (*w as f64, *h as f64), + } + } +} + +/// Whole pixel bounds of `r` clipped to `w × h`. +fn pixels(r: &Rect, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(r.x0, w), c(r.y0, h)); + (x0, y0, c(r.x1, w).max(x0), c(r.y1, h).max(y0)) +} + +/// Blend `c` into pixel (`x`, `y`) at `k` (0–1); outside the image does nothing. +fn blend(img: &mut Rgba8, x: i64, y: i64, k: f32, c: [u8; 3]) { + if x < 0 || y < 0 || k <= 0.0 { + return; + } + let (x, y) = (x as usize, y as usize); + if x >= img.width || y >= img.height { + return; + } + let w = img.width; + if let Some(p) = img.data.get_mut(y * w + x) { + let k = k.min(1.0); + for i in 0..3 { + p[i] = (p[i] as f32 + (c[i] as f32 - p[i] as f32) * k).round().clamp(0.0, 255.0) as u8; + } + } +} + +fn draw_text(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, c: [u8; 3], opacity: f32) { + let (w, h) = (img.width as f32, img.height as f32); + tl.draw_in(x as f32, y as f32, w, h, |gx, gy, cov| blend(img, i64::from(gx), i64::from(gy), cov * opacity, c)); +} + +/// A soft black shadow of the text at (`x`, `y`): its coverage blurred by `sigma` pixels. Only +/// the part that can reach the image is rasterised. +fn shadow(img: &mut Rgba8, tl: &crate::text::TextLayout, x: f64, y: f64, sigma: f64, opacity: f32) { + let pad = (sigma * 3.0).ceil().clamp(0.0, 512.0); + let (iw, ih) = (img.width as f64, img.height as f64); + let mx0 = (x - pad).floor().max(-pad); + let my0 = (y - pad).floor().max(-pad); + let mx1 = (x + tl.width as f64 + pad).ceil().min(iw + pad); + let my1 = (y + tl.height as f64 + pad).ceil().min(ih + pad); + if !(mx1 > mx0 && my1 > my0 && mx0.is_finite() && my0.is_finite()) { + return; + } + let (w, h) = ((mx1 - mx0) as usize, (my1 - my0) as usize); + let mut mask = Plane::new(w, h); + tl.draw_in((x - mx0) as f32, (y - my0) as f32, w as f32, h as f32, |gx, gy, cov| { + if gx >= 0 + && gy >= 0 + && (gx as usize) < w + && (gy as usize) < h + && let Some(m) = mask.data.get_mut(gy as usize * w + gx as usize) + { + *m = (*m + cov).min(1.0); + } + }); + let mask = if sigma > 0.3 { lightcraft_raster::blur::gaussian(&mask, sigma as f32) } else { mask }; + let (ox, oy) = (mx0 as i64, my0 as i64); + for (i, k) in mask.data.iter().enumerate() { + if *k > 1e-3 { + blend(img, ox + (i % w) as i64, oy + (i / w) as i64, k * opacity, [0, 0, 0]); + } + } +} + +/// The identity plate image scaled to its size, premultiplied: (file, its decoded image, w, h) → +/// pixels, so the slides of a slideshow don't each scale a large logo again. +type ScaledCache = std::sync::Mutex<Option<(String, usize, usize, usize, Arc<Image<[f32; 4]>>)>>; +static SCALED: ScaledCache = std::sync::Mutex::new(None); + +/// `src` (decoded from `path`) scaled to `w × h` (premultiplied, so transparent edges stay clean). +fn scaled_graphic(path: &str, src: &Arc<Rgba8>, w: usize, h: usize) -> Option<Arc<Image<[f32; 4]>>> { + if src.width == 0 || src.height == 0 || w == 0 || h == 0 || src.data.len() != src.width * src.height { + return None; + } + let id = Arc::as_ptr(src) as usize; + let mut cache = SCALED.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if let Some((p, i, cw, ch, img)) = cache.as_ref() + && (p.as_str(), *i, *cw, *ch) == (path, id, w, h) + { + return Some(img.clone()); + } + let pre: Image<[f32; 4]> = Image::from_fn(src.width, src.height, |x, y| { + let p = src.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = Arc::new(lightcraft_raster::resample::resize(&pre, w, h, lightcraft_raster::resample::Filter::Mitchell)); + *cache = Some((path.to_string(), id, w, h, scaled.clone())); + Some(scaled) +} + +/// The scaled graphic at (`x`, `y`), blended with its alpha × `opacity`. +fn draw_graphic(img: &mut Rgba8, scaled: &Image<[f32; 4]>, x: i64, y: i64, opacity: f32) { + for yy in 0..scaled.height { + for xx in 0..scaled.width { + let [r, g, b, a] = scaled.get(xx, yy); + let a = a.clamp(0.0, 1.0); + if a <= 1e-4 { + continue; + } + let c = [r, g, b].map(|v| (v / a).round().clamp(0.0, 255.0) as u8); + blend(img, x + xx as i64, y + yy as i64, a * opacity, c); + } + } +} + +/// The ten points of a five-pointed star in a `size` box at (`x`, `y`), point up. +fn star_points(x: f64, y: f64, size: f64) -> [(f64, f64); 10] { + let r = size / 2.0; + // the star's height is r (1 + cos 36°); centre it vertically in the box + let cy = y + size / 2.0 + r * (1.0 - (36f64).to_radians().cos()) / 2.0; + let cx = x + size / 2.0; + let inner = r * 0.382; + let mut pts = [(0.0, 0.0); 10]; + for (i, p) in pts.iter_mut().enumerate() { + let a = (90.0 + 36.0 * i as f64).to_radians(); + let rr = if i % 2 == 0 { r } else { inner }; + *p = (cx + rr * a.cos(), cy - rr * a.sin()); + } + pts +} + +/// A filled star: even-odd scanlines, four a pixel row, with exact horizontal coverage. +fn star(img: &mut Rgba8, x: f64, y: f64, size: f64, c: [u8; 3]) { + if !(x.is_finite() && y.is_finite() && size.is_finite() && size > 0.0) { + return; + } + let pts = star_points(x, y, size); + let (x0, y0) = (x.floor() as i64, y.floor() as i64); + let n = size.ceil() as usize + 2; + let mut row = vec![0f32; n]; + let mut xs: Vec<f64> = Vec::with_capacity(pts.len()); + for py in y0..y0 + n as i64 { + row.fill(0.0); + for s in 0..4 { + let sy = py as f64 + (s as f64 + 0.5) / 4.0; + xs.clear(); + for (i, &(xi, yi)) in pts.iter().enumerate() { + let Some(&(xj, yj)) = pts.get((i + pts.len() - 1) % pts.len()) else { continue }; + if (yi > sy) != (yj > sy) { + xs.push((xj - xi) * (sy - yi) / (yj - yi) + xi); + } + } + xs.sort_by(f64::total_cmp); + for &[a, b] in xs.as_chunks::<2>().0 { + let (a, b) = (a - x0 as f64, b - x0 as f64); + let first = a.floor().max(0.0) as usize; + let last = (b.ceil().max(0.0) as usize).min(n); + for (i, v) in row.iter_mut().enumerate().take(last).skip(first) { + let cover = (b.min(i as f64 + 1.0) - a.max(i as f64)).max(0.0); + *v += cover as f32 / 4.0; + } + } + } + for (i, k) in row.iter().enumerate() { + if *k > 0.0 { + blend(img, x0 + i as i64, py, k.min(1.0), c); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn layout(w: usize, h: usize) -> Layout { + lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default().merged(&json!({"margins": 0.1})).unwrap(), 1.5, w, h) + } + + fn photo() -> Photo { + let mut p = crate::rename::sample_photo(); + p.rating = 3; + p.meta.caption = "Morning fog".into(); + p.meta.shutter = "1/250".into(); + p.meta.aperture = Some(8.0); + p.meta.focal_mm = Some(50.0); + p + } + + #[test] + fn tokens_expand_like_the_rename_tokens_plus_slide_ones() { + let p = photo(); + assert_eq!(expand("{caption}", &p), "Morning fog"); + assert_eq!(expand("{exposure}", &p), "1/250 s at f/8"); + assert_eq!(expand("{focal} · ISO {iso}", &p), format!("50 mm · ISO {}", p.meta.iso.unwrap_or_default())); + assert_eq!(expand("{file}", &p), p.file_name); + assert_eq!(expand("{name}", &p), crate::rename::expand_tokens("{name}", &p, 1, 1), "rename tokens work"); + assert_eq!(expand("{date}", &p), crate::rename::expand_tokens("{date:%Y-%m-%d}", &p, 1, 1)); + assert_eq!(expand("{rating}", &p), "3"); + // unknown and slide-less tags show as typed, and are reported + assert_eq!(expand("{sparkle} {seq} {", &p), "{sparkle} {seq} {"); + assert_eq!(unknown_tokens("{title} {sparkle} {seq} {é}"), vec!["{sparkle}", "{seq}", "{é}"]); + // a photo without metadata: empty values, no panic + let mut bare = p.clone(); + bare.meta = Default::default(); + assert_eq!(expand("[{exposure}|{camera}|{caption}|{focal}]", &bare), "[|||]"); + } + + #[test] + fn nine_anchors_place_inside_the_slide_with_their_offsets() { + let l = layout(1000, 600); + let short = 600.0; + for anchor in ANCHORS { + for relative_to in [RelativeTo::Slide, RelativeTo::Photo] { + let at = |ox: f64, oy: f64| Placement { anchor, offset_x: ox, offset_y: oy, relative_to }.place(&l, 100.0, 40.0); + let b = at(0.0, 0.0); + assert!(b.x0 >= 0.0 && b.y0 >= 0.0 && b.x1 <= 1000.0 && b.y1 <= 600.0, "{anchor:?} {relative_to:?}: {b:?}"); + assert_eq!((b.width(), b.height()), (100.0, 40.0)); + let c = if relative_to == RelativeTo::Slide { l.slide } else { l.visible }; + let (cx, cy) = ((b.x0 + b.x1) / 2.0, (b.y0 + b.y1) / 2.0); + let id = anchor_id(anchor); + let want_x = if id.ends_with("eft") { + c.x0 + 50.0 + } else if id.ends_with("ight") { + c.x1 - 50.0 + } else { + (c.x0 + c.x1) / 2.0 + }; + let want_y = if id.starts_with("top") { + c.y0 + 20.0 + } else if id.starts_with("bottom") { + c.y1 - 20.0 + } else { + (c.y0 + c.y1) / 2.0 + }; + assert!((cx - want_x).abs() < 1e-9 && (cy - want_y).abs() < 1e-9, "{id} {relative_to:?}: {b:?}"); + // an inward offset moves it by that share of the shorter side + let (sx, sy) = (if id.ends_with("ight") { -0.02 } else { 0.02 }, if id.starts_with("bottom") { -0.03 } else { 0.03 }); + let m = at(sx, sy); + assert!((m.x0 - b.x0 - sx * short).abs() < 1e-9 && (m.y0 - b.y0 - sy * short).abs() < 1e-9, "{id}: {m:?}"); + } + } + // pushed off the slide: kept on it + let b = Placement { anchor: Anchor::BottomRight, offset_x: 0.5, offset_y: 0.5, relative_to: RelativeTo::Slide }.place(&l, 100.0, 40.0); + assert_eq!((b.x1, b.y1), (1000.0, 600.0)); + } + + fn blank(w: usize, h: usize) -> Rgba8 { + Image::filled(w, h, [0, 0, 0, 255]) + } + + fn lit(img: &Rgba8, r: &Rect, min: u8) -> usize { + let (x0, y0, x1, y1) = pixels(r, img.width, img.height); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| img.get(x, y)[1] >= min).count() + } + + #[test] + fn rating_stars_count_matches_the_rating() { + let l = layout(800, 500); + let s = OverlaySettings::default().merged(&json!({"ratingStars": {"enabled": true, "color": "#00ff00", "size": 0.05}})).unwrap(); + let mut p = photo(); + let mut widths = Vec::new(); + for rating in 0..=5u8 { + p.rating = rating; + let o = SlideOverlays::resolve(&s, Some(&p)); + let boxes = o.boxes(&l); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let Some((_, b)) = boxes.iter().find(|(k, _)| *k == Kind::RatingStars) else { + assert_eq!(rating, 0, "no stars only for an unrated photo"); + assert_eq!(lit(&img, &l.slide, 1), 0); + continue; + }; + widths.push(b.width()); + // count the stars: separate runs of lit columns along the box's middle row + let y = ((b.y0 + b.y1) / 2.0) as usize; + let mut runs = 0; + let mut was = false; + for x in 0..800 { + let on = img.get(x, y)[1] > 128; + runs += usize::from(on && !was); + was = on; + } + assert_eq!(runs, usize::from(rating), "{rating} stars"); + } + assert!(widths.windows(2).all(|w| w[1] > w[0])); + } + + #[test] + fn text_with_a_shadow_draws_the_shadow_at_its_angle() { + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged( + &json!({"texts": [{"text": "Shadow", "size": 0.08, "color": "#ffffff", "opacity": 1, "anchor": "center", "offsetX": 0, "offsetY": 0, + "shadow": true, "shadowOpacity": 1, "shadowOffset": 0.02, "shadowRadius": 0, "shadowAngle": -90}]}), + ) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let b = o.boxes(&l)[0].1; + let mut grey = Image::filled(800, 500, [128, 128, 128, 255]); + o.draw_front(&mut grey, &l); + // straight down by 10 px: dark pixels below the text box's glyphs, none above + let dark = |r: Rect| { + let (x0, y0, x1, y1) = pixels(&r, 800, 500); + (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| grey.get(x, y)[0] < 40).count() + }; + assert!(dark(Rect { y0: b.y1 - 2.0, y1: b.y1 + 10.0, ..b }) > 20, "the shadow falls below"); + assert_eq!(dark(Rect { y0: b.y0 - 12.0, y1: b.y0, ..b }), 0, "none above"); + // without the shadow: no dark pixels at all + let off = s.merged(&json!({"texts": [{"shadow": false}]})).unwrap(); + let mut grey2 = Image::filled(800, 500, [128, 128, 128, 255]); + SlideOverlays::resolve(&off, Some(&photo())).draw_front(&mut grey2, &l); + assert!(grey2.data.iter().all(|p| p[0] >= 128)); + } + + #[test] + fn identity_plate_image_draws_at_its_opacity_and_a_missing_one_is_reported() { + let dir = std::env::temp_dir().join(format!("lc-plate-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("plate.png"); + let red = Image::filled(40, 20, [255u8, 0, 0, 255]); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&red), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(&path, png).unwrap(); + let l = layout(800, 500); + let s = OverlaySettings::default() + .merged(&json!({"identityPlate": {"enabled": true, "image": path.to_string_lossy(), "size": 0.1, "opacity": 0.5, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}})) + .unwrap(); + assert!(s.warnings().is_empty()); + let o = SlideOverlays::resolve(&s, None); + let b = o.boxes(&l)[0].1; + assert_eq!((b.x0, b.y0, b.width(), b.height()), (0.0, 0.0, 100.0, 50.0), "50 px high (10 % of 500), its aspect kept"); + let mut img = blank(800, 500); + o.draw_front(&mut img, &l); + let p = img.get(50, 25); + assert!((p[0] as i32 - 128).abs() <= 2 && p[1] == 0, "half-opaque red over black: {p:?}"); + assert_eq!(img.get(150, 25)[0], 0, "only inside its box"); + // behind the image: the photo covers it + let behind = s.merged(&json!({"identityPlate": {"behind": true, "anchor": "center"}})).unwrap(); + let photo_img = Image::filled(30, 20, [0u8, 0, 255, 255]); + let ob = SlideOverlays::resolve(&behind, None); + let (img, l2) = + lightcraft_slideshow::compose_with(&photo_img, None, &lightcraft_slideshow::SlideSettings::default(), 800, 500, &mut |i, l| { + ob.draw_behind(i, l) + }); + let _ = l2; + assert_eq!(img.get(400, 250), [0, 0, 255, 255], "the photo is on top"); + // a missing file: reported, nothing drawn, no panic + let gone = s.merged(&json!({"identityPlate": {"image": dir.join("nope.png").to_string_lossy()}})).unwrap(); + assert_eq!(gone.warnings().len(), 1); + let mut img = blank(800, 500); + SlideOverlays::resolve(&gone, None).draw_front(&mut img, &l); + assert!(img.data.iter().all(|p| p[0] == 0)); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn overlays_are_resolution_independent() { + let s = OverlaySettings::default() + .merged(&json!({ + "identityPlate": {"enabled": true, "text": "Studio", "size": 0.06}, + "ratingStars": {"enabled": true, "size": 0.04}, + "texts": [{"text": "{caption}", "size": 0.04, "anchor": "bottomLeft", "offsetX": 0.02, "offsetY": -0.02, "relativeTo": "photo"}], + })) + .unwrap(); + let o = SlideOverlays::resolve(&s, Some(&photo())); + let (a, b) = (o.boxes(&layout(800, 500)), o.boxes(&layout(1600, 1000))); + assert_eq!(a.len(), 3); + for ((ka, ra), (kb, rb)) in a.iter().zip(&b) { + assert_eq!(ka, kb); + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 4.0, "{ka:?}: {ra:?} vs {rb:?}"); + } + } + } + + #[test] + fn watermark_takes_the_export_insets_and_rotation() { + let s = OverlaySettings::default().merged(&json!({"watermark": {"inset": 0.1, "insetY": 0.2, "rotation": 90}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y, s.watermark.rotation), (0.1, 0.2, 90)); + let s = s.merged(&json!({"watermark": {"insetX": 0.05}})).unwrap(); + assert_eq!((s.watermark.inset_x, s.watermark.inset_y), (0.05, 0.2)); + assert!(OverlaySettings::default().merged(&json!({"watermark": {"rotation": 45}})).is_err()); + } + + #[test] + fn hostile_settings_are_refused_or_clamped_and_never_panic() { + let d = OverlaySettings::default(); + for bad in [ + json!({"identityPlate": {"size": "big"}}), + json!({"identityPlate": {"anchor": "middle"}}), + json!({"identityPlate": 3}), + json!({"ratingStars": {"color": "#zzzzzz"}}), + json!({"texts": {"text": "a"}}), + json!({"texts": [{"relativeTo": "page"}]}), + json!({"texts": [{"sparkle": 1}]}), + json!({"texts": vec![json!({}); MAX_TEXTS + 1]}), + json!({"removeText": 0}), + json!({"removeText": -1}), + json!({"watermark": {"size": 9}}), + json!({"watermark": 5}), + json!({"showWatermark": "yes"}), + json!({"glitter": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"identityPlate": {"size": 1e300, "opacity": -3, "offsetX": -1e9}, "ratingStars": {"size": 0}, "addText": {"size": -1, "shadowAngle": 900, "shadowRadius": 7}})) + .unwrap(); + assert_eq!((s.identity_plate.size, s.identity_plate.opacity, s.identity_plate.place.offset_x), (PLATE_SIZE_MAX, 0.0, -OFFSET_MAX)); + assert_eq!(s.rating_stars.size, SIZE_MIN); + assert_eq!((s.texts[0].size, s.texts[0].shadow.angle, s.texts[0].shadow.radius), (SIZE_MIN, 180.0, SHADOW_RADIUS_MAX)); + assert!(d.merged(&json!({"removeText": 0})).is_err()); + assert!(s.merged(&json!({"removeText": 0})).unwrap().texts.is_empty()); + // round trips (command JSON and prefs.json) + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + let back: OverlaySettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + // NaN / ∞ from a hand-edited prefs file + let mut wild = s.clone(); + wild.identity_plate.size = f64::NAN; + wild.texts[0].place.offset_y = f64::INFINITY; + wild.watermark.size = f32::NAN; + let w = wild.sanitized(); + assert_eq!( + (w.identity_plate.size, w.texts[0].place.offset_y, w.watermark.size), + (IdentityPlate::default().size, 0.0, Watermark::default().size) + ); + // drawing hostile text at every size: empty, huge, CJK, RTL, control characters + let huge = "W".repeat(50_000); + for text in ["", " ", "\n\n", huge.as_str(), "日本語のテキスト", "שלום עולם", "مرحبا", "a\u{0}b\u{7}c\r\n", "{caption}{"] + { + let s = d + .merged(&json!({"identityPlate": {"enabled": true, "text": text, "size": PLATE_SIZE_MAX}, "ratingStars": {"enabled": true}, "addText": {"text": text, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX}, "showWatermark": true, "watermark": text})) + .unwrap(); + assert!(s.identity_plate.text.chars().count() <= TEXT_MAX_CHARS); + let mut bare = photo(); + bare.meta = Default::default(); + for (w, h) in [(1, 1), (17, 9), (320, 200)] { + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), f64::NAN, w, h); + let o = SlideOverlays::resolve(&s, Some(&bare)); + let mut img = blank(l.slide.width() as usize, l.slide.height() as usize); + o.draw_behind(&mut img, &l); + o.draw_front(&mut img, &l); + for (_, b) in o.boxes(&l) { + assert!(b.x0.is_finite() && b.y1.is_finite()); + } + } + } + } + + /// Huge text and stars on a big slide cost what shows: glyphs off the slide aren't rasterised. + #[test] + fn huge_overlays_on_a_big_slide_stay_cheap() { + let long = "Wide ".repeat(400); + let s = OverlaySettings::default() + .merged(&json!({ + "ratingStars": {"enabled": true, "size": STAR_SIZE_MAX}, + "addText": {"text": long, "size": TEXT_SIZE_MAX, "shadow": true, "shadowRadius": SHADOW_RADIUS_MAX, "anchor": "topLeft", "offsetX": 0, "offsetY": 0}, + })) + .unwrap(); + let mut p = photo(); + p.rating = 5; + let l = lightcraft_slideshow::layout(&lightcraft_slideshow::SlideSettings::default(), 1.5, 4000, 2500); + let mut img = blank(4000, 2500); + let t = std::time::Instant::now(); + SlideOverlays::resolve(&s, Some(&p)).draw_front(&mut img, &l); + assert!(t.elapsed().as_secs_f64() < 20.0, "{:?}", t.elapsed()); + assert!(img.get(100, 300)[0] > 0, "the text's start shows"); + } + + /// CJK text draws with the craft-fonts faces (skips without them). + #[test] + fn cjk_text_uses_the_craft_fonts() { + if crate::fonts::cjk(crate::fonts::CRAFT_FONTS).next().is_none() { + eprintln!("skipped: built without craft-fonts (CRAFT_FONTS_DIR)"); + return; + } + let cjk = crate::text::TextLayout::new("日本語", 40.0, false, crate::fonts::CRAFT_FONTS).unwrap(); + let tofu = crate::text::TextLayout::new("日本語", 40.0, false, &[]).unwrap(); + let count = |t: &crate::text::TextLayout| { + let mut n = 0usize; + t.draw(0.0, 0.0, |_, _, c| n += usize::from(c > 0.5)); + n + }; + assert_ne!(count(&cjk), count(&tofu), "real glyphs, not missing-glyph boxes"); + } +} diff --git a/crates/engine/src/slide_template.rs b/crates/engine/src/slide_template.rs new file mode 100644 index 000000000..32e26ed59 --- /dev/null +++ b/crates/engine/src/slide_template.rs @@ -0,0 +1,632 @@ +//! Slideshow templates (Lightroom Classic's Template Browser): a named snapshot of how a +//! slideshow looks and plays — the Playback settings, the slide's Layout / Options / Backdrop, +//! the Overlays and the Titles — as one JSON document with a format version. +//! +//! Built-in templates are our own designs ([`builtins`], JSON files beside this module). User +//! templates are files in the settings folder, `<config>/slideshow-templates/<name>.json` +//! ([`Session::slide_templates_dir`]); they are shared by every library on the machine, like +//! Lightroom's templates. Every section is read through the settings' own `merged()` (so a +//! hand-edited or hostile file can never set a value the panels couldn't), files are capped in +//! size and number, a file that can't be used is reported and skipped, and file names are made +//! from the template's name so they can never leave the templates folder. +//! +//! Applying a template replaces the four settings in one step and saves them with the library. +//! Library preferences have no undo, so the settings it replaced are kept for the session as +//! "Previous settings" ([`PREVIOUS`]): applying that puts them back (and applying it again goes +//! forward). "Prepare previews" is a machine preference, not part of a look: templates neither +//! save nor change it. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use serde_json::{Map, Value, json}; + +use crate::Session; +use crate::slide::SlideSettings; +use crate::slide_overlay::OverlaySettings; +use crate::slide_title::TitleSettings; +use crate::slideshow::PlaybackSettings; + +/// The `format` of a template document. +pub const FORMAT: &str = "lightcraft.slideshowTemplate"; +/// The newest document version this build reads (and the one it writes). +pub const VERSION: u64 = 1; +/// Largest template file read (bytes). +pub const MAX_FILE: u64 = 256 * 1024; +/// Most user templates listed (the rest of a folder that holds more are reported, not read). +pub const MAX_TEMPLATES: usize = 1000; +/// Directory entries looked at before giving up on a huge folder. +const MAX_SCAN: usize = 20_000; +/// Longest template name (characters). +pub const NAME_MAX: usize = 80; +/// Longest description (characters). +pub const DESCRIPTION_MAX: usize = 500; +/// Longest file stem made from a name (characters). +const STEM_MAX: usize = 64; +/// The folder under the settings folder user templates live in. +pub const DIR_NAME: &str = "slideshow-templates"; +/// Id prefix of built-in templates. +pub const BUILTIN: &str = "builtin:"; +/// Id prefix of user templates (followed by the file stem). +pub const USER: &str = "user:"; +/// Id of the settings before the last template was applied. +pub const PREVIOUS: &str = "previous"; + +/// Everything a template sets. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct SlideLook { + pub playback: PlaybackSettings, + pub layout: SlideSettings, + pub overlays: OverlaySettings, + pub titles: TitleSettings, +} + +impl SlideLook { + /// The library's current slideshow settings. + pub fn of(s: &Session) -> SlideLook { + SlideLook { + playback: s.slideshow.clone(), + layout: s.slide_layout.clone(), + overlays: s.slide_overlays.clone(), + titles: s.slide_titles.clone(), + } + } + + /// The four sections as JSON (playback without `preparePreviews`, which isn't part of a look). + pub fn sections(&self) -> Map<String, Value> { + let mut playback = self.playback.to_json(); + if let Some(o) = playback.as_object_mut() { + o.remove("preparePreviews"); + } + let mut m = Map::new(); + m.insert("playback".into(), playback); + m.insert("layout".into(), self.layout.to_json()); + m.insert("overlays".into(), self.overlays.to_json()); + m.insert("titles".into(), self.titles.to_json()); + m + } + + /// Read the sections of a template document: each one merged onto the defaults by the + /// settings' own validation (a missing section is the defaults; a wrong type, an unknown key + /// or a non-finite number is an error). + fn from_sections(doc: &Map<String, Value>) -> Result<SlideLook, String> { + fn section<T>(doc: &Map<String, Value>, key: &str, merge: impl Fn(&Value) -> Result<T, String>, default: T) -> Result<T, String> { + match doc.get(key) { + None | Some(Value::Null) => Ok(default), + Some(v @ Value::Object(_)) => merge(v).map_err(|e| format!("{key}: {e}")), + Some(v) => Err(format!("`{key}` must be an object, not {v}")), + } + } + let mut playback = section(doc, "playback", |v| PlaybackSettings::default().merged(v), PlaybackSettings::default())?; + playback.prepare_previews = PlaybackSettings::default().prepare_previews; + Ok(SlideLook { + playback, + layout: section(doc, "layout", |v| SlideSettings::default().merged(v), SlideSettings::default())?, + overlays: section(doc, "overlays", |v| OverlaySettings::default().merged(v), OverlaySettings::default())?, + titles: section(doc, "titles", |v| TitleSettings::default().merged(v), TitleSettings::default())?, + }) + } + + /// What doesn't work with this look in `s`'s library: a background photo that isn't in it, + /// an identity plate or watermark image file that can't be read, unknown tokens, a title + /// screen with nothing to show. The look still applies; these draw nothing. + pub fn warnings(&self, s: &Session) -> Vec<String> { + let mut out = Vec::new(); + if let Some(id) = self.layout.background_image + && s.catalog.photo(lightcraft_catalog::PhotoId(id)).is_none() + { + out.push(format!("the background image (photo {id}) isn't in this library; the backdrop colour shows instead")); + } + out.extend(self.overlays.warnings()); + let w = &self.overlays.watermark; + if self.overlays.show_watermark && !w.image.trim().is_empty() && crate::export::watermark_logo(w.image.trim()).is_none() { + out.push(format!("watermark image {} can't be read; nothing is drawn", w.image.trim())); + } + for t in self.titles.warnings(&self.overlays) { + if !out.contains(&t) { + out.push(t); + } + } + out + } +} + +/// A template: built in or a user's file. +#[derive(Clone, Debug, PartialEq)] +pub struct Template { + /// `builtin:<slug>` or `user:<file stem>`. + pub id: String, + pub name: String, + pub description: String, + pub builtin: bool, + /// The file a user template is read from. + pub path: Option<PathBuf>, + pub look: SlideLook, +} + +impl Template { + /// The template as a document (what a user template's file holds, and what export writes). + pub fn to_doc(&self) -> Value { + doc(&self.name, &self.description, &self.look) + } + + /// The template for listings (no settings). + pub fn info(&self) -> Value { + let mut v = json!({ + "id": self.id, + "name": self.name, + "builtin": self.builtin, + "group": if self.builtin { "builtin" } else { "user" }, + }); + if !self.description.is_empty() { + v["description"] = json!(self.description); + } + if let Some(p) = &self.path { + v["path"] = json!(p.to_string_lossy()); + } + v + } +} + +/// A template document for `look`. +pub fn doc(name: &str, description: &str, look: &SlideLook) -> Value { + let mut m = Map::new(); + m.insert("format".into(), json!(FORMAT)); + m.insert("version".into(), json!(VERSION)); + m.insert("name".into(), json!(name)); + if !description.is_empty() { + m.insert("description".into(), json!(description)); + } + m.extend(look.sections()); + Value::Object(m) +} + +/// A template document read and checked: its name (`None` when it has none), description and look. +#[derive(Clone, Debug, PartialEq)] +pub struct Parsed { + pub name: Option<String>, + pub description: String, + pub look: SlideLook, +} + +/// Read a template document. Refused: more than [`MAX_FILE`] bytes, not JSON, another format, a +/// version from a newer LightCraft, a bad name, unknown keys and any setting its section's +/// validation refuses. +pub fn parse(bytes: &[u8]) -> Result<Parsed, String> { + if bytes.len() as u64 > MAX_FILE { + return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", bytes.len(), MAX_FILE / 1024)); + } + let text = std::str::from_utf8(bytes).map_err(|_| "not a slideshow template (not UTF-8 text)".to_string())?; + let text = text.trim_start_matches('\u{feff}'); + let v: Value = serde_json::from_str(text).map_err(|e| format!("not a slideshow template: {e}"))?; + let Value::Object(o) = v else { return Err("not a slideshow template (not a JSON object)".into()) }; + match o.get("format").and_then(Value::as_str) { + Some(FORMAT) => {} + Some(other) => return Err(format!("not a slideshow template (format `{other}`, expected `{FORMAT}`)")), + None => return Err(format!("not a slideshow template (no `format`: \"{FORMAT}\")")), + } + match o.get("version") { + Some(v) => match v.as_u64() { + Some(n) if n > VERSION => { + return Err(format!( + "made by a newer LightCraft (template version {n}; this one reads version {VERSION}): update LightCraft to use it" + )); + } + Some(n) if n >= 1 => {} + _ => return Err(format!("`version` must be a whole number from 1, not {v}")), + }, + None => return Err("no `version`".into()), + } + for k in o.keys() { + if !matches!(k.as_str(), "format" | "version" | "name" | "description" | "playback" | "layout" | "overlays" | "titles") { + return Err(format!("unknown key `{k}` (format, version, name, description, playback, layout, overlays, titles)")); + } + } + let name = match o.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) if n.trim().is_empty() => None, + Some(Value::String(n)) => Some(valid_name(n)?), + Some(v) => return Err(format!("`name` must be text, not {v}")), + }; + let description = match o.get("description") { + None | Some(Value::Null) => String::new(), + Some(Value::String(d)) => clean_text(d, DESCRIPTION_MAX), + Some(v) => return Err(format!("`description` must be text, not {v}")), + }; + Ok(Parsed { name, description, look: SlideLook::from_sections(&o)? }) +} + +/// `s` without control characters, trimmed, at most `max` characters. +fn clean_text(s: &str, max: usize) -> String { + s.chars().filter(|c| !c.is_control()).take(max).collect::<String>().trim().to_string() +} + +/// A template name as stored: trimmed, 1–[`NAME_MAX`] characters, no control characters. +pub fn valid_name(name: &str) -> Result<String, String> { + let n = name.trim(); + if n.is_empty() { + return Err("a template needs a name".into()); + } + if n.chars().any(char::is_control) { + return Err("a template name can't contain control characters (line breaks, tabs…)".into()); + } + if n.chars().count() > NAME_MAX { + return Err(format!("a template name has at most {NAME_MAX} characters")); + } + Ok(n.to_string()) +} + +/// The file stem a template called `name` is saved as: letters, digits, spaces, `-`, `_`, `(` +/// and `)` kept, anything else (path separators, dots, `:`, `*`…) `_`; trimmed; at most 64 +/// characters; Windows' reserved device names get a `_`. `None` when nothing usable is left. +pub fn file_stem(name: &str) -> Option<String> { + let s: String = + name.trim().chars().map(|c| if c.is_alphanumeric() || matches!(c, ' ' | '-' | '_' | '(' | ')') { c } else { '_' }).take(STEM_MAX).collect(); + let s = s.trim().to_string(); + if s.is_empty() || s.chars().all(|c| c == '_') { + return None; + } + let upper = s.to_ascii_uppercase(); + let reserved = matches!(upper.as_str(), "CON" | "PRN" | "AUX" | "NUL") + || ((upper.starts_with("COM") || upper.starts_with("LPT")) && upper.len() == 4 && upper.ends_with(|c: char| c.is_ascii_digit())); + Some(if reserved { format!("{s}_") } else { s }) +} + +/// The stem of a user template id, when it is one this module could have made (so it can't +/// name a file outside the templates folder). +fn user_stem(id: &str) -> Option<&str> { + let stem = id.strip_prefix(USER)?; + (file_stem(stem).as_deref() == Some(stem)).then_some(stem) +} + +/// The built-in templates: our own designs (no Lightroom template, name or design copied). +const BUILTINS: &[(&str, &str)] = &[ + ("plain-black", include_str!("slideshow_templates/plain-black.json")), + ("gallery-wall", include_str!("slideshow_templates/gallery-wall.json")), + ("edge-to-edge", include_str!("slideshow_templates/edge-to-edge.json")), + ("cinema-wide", include_str!("slideshow_templates/cinema-wide.json")), + ("shooting-data", include_str!("slideshow_templates/shooting-data.json")), +]; + +/// The built-in templates (one that doesn't parse — a bug the tests catch — is left out). +pub fn builtins() -> Vec<Template> { + BUILTINS + .iter() + .filter_map(|(slug, json)| { + let p = parse(json.as_bytes()).inspect_err(|e| eprintln!("lightcraft: built-in slideshow template {slug}: {e}")).ok()?; + Some(Template { + id: format!("{BUILTIN}{slug}"), + name: p.name.unwrap_or_else(|| (*slug).to_string()), + description: p.description, + builtin: true, + path: None, + look: p.look, + }) + }) + .collect() +} + +/// Every template, and what couldn't be read. +#[derive(Clone, Debug, Default)] +pub struct Listing { + /// Built-ins first (in their order), then user templates by name. + pub templates: Vec<Arc<Template>>, + /// Files skipped, each with why. + pub problems: Vec<String>, +} + +impl Listing { + pub fn get(&self, id: &str) -> Option<&Arc<Template>> { + self.templates.iter().find(|t| t.id == id) + } + + /// A template by name (case-insensitive); a user template before a built-in. + pub fn by_name(&self, name: &str) -> Option<&Arc<Template>> { + let n = name.trim().to_lowercase(); + let mut hits = self.templates.iter().filter(|t| t.name.to_lowercase() == n); + let first = hits.next()?; + Some(hits.find(|t| !t.builtin).unwrap_or(first)) + } +} + +/// When the templates folder last changed (the listing is read again when this does). +type Stamp = (Option<PathBuf>, Option<std::time::SystemTime>); + +/// The cached listing and the folder stamp it was read at. +pub type Cache = Option<(Stamp, Arc<Listing>)>; + +fn stamp(dir: Option<&Path>) -> Stamp { + (dir.map(Path::to_path_buf), dir.and_then(|d| std::fs::metadata(d).ok()).and_then(|m| m.modified().ok())) +} + +/// Read the user templates in `dir`: `*.json` regular files, at most [`MAX_TEMPLATES`], each +/// parsed and checked; anything else reported. +fn read_dir(dir: &Path, out: &mut Listing) { + let entries = match std::fs::read_dir(dir) { + Ok(e) => e, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return, + Err(e) => { + out.problems.push(format!("{}: {e}", dir.display())); + return; + } + }; + let mut files: Vec<(String, PathBuf)> = Vec::new(); + for entry in entries.take(MAX_SCAN).flatten() { + let path = entry.path(); + let is_json = path.extension().and_then(|e| e.to_str()).is_some_and(|e| e.eq_ignore_ascii_case("json")); + if !is_json { + continue; + } + let file = entry.file_name().to_string_lossy().to_string(); + files.push((file, path)); + } + files.sort_by(|a, b| a.0.to_lowercase().cmp(&b.0.to_lowercase()).then_with(|| a.0.cmp(&b.0))); + if files.len() > MAX_TEMPLATES { + out.problems.push(format!("{}: {} template files; only the first {MAX_TEMPLATES} are listed", dir.display(), files.len())); + files.truncate(MAX_TEMPLATES); + } + let mut user = Vec::new(); + for (file, path) in files { + match read_user(&file, &path) { + Ok(t) => user.push(Arc::new(t)), + Err(e) => out.problems.push(format!("{file}: {e}")), + } + } + user.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.id.cmp(&b.id))); + out.templates.extend(user); +} + +/// One user template file. +fn read_user(file: &str, path: &Path) -> Result<Template, String> { + let stem = file.get(..file.len().saturating_sub(5)).unwrap_or_default(); + if file_stem(stem).as_deref() != Some(stem) { + return Err("the file name isn't one LightCraft makes (letters, digits, spaces, - _ ( ) only); rename the file".into()); + } + let meta = std::fs::symlink_metadata(path).map_err(|e| e.to_string())?; + if !meta.file_type().is_file() { + return Err("not a regular file (a link or a folder)".into()); + } + if meta.len() > MAX_FILE { + return Err(format!("{} bytes is too large for a slideshow template (at most {} KB)", meta.len(), MAX_FILE / 1024)); + } + let bytes = std::fs::read(path).map_err(|e| e.to_string())?; + let p = parse(&bytes)?; + Ok(Template { + id: format!("{USER}{stem}"), + name: p.name.unwrap_or_else(|| stem.to_string()), + description: p.description, + builtin: false, + path: Some(path.to_path_buf()), + look: p.look, + }) +} + +/// Read a template file the user picked (import): size-capped, then [`parse`]d. Its name is the +/// document's, else the file's. +pub fn read_file(path: &Path) -> Result<Parsed, String> { + let meta = std::fs::metadata(path).map_err(|e| format!("{}: {e}", path.display()))?; + if !meta.is_file() { + return Err(format!("{}: not a file", path.display())); + } + if meta.len() > MAX_FILE { + return Err(format!("{}: {} bytes is too large for a slideshow template (at most {} KB)", path.display(), meta.len(), MAX_FILE / 1024)); + } + let bytes = std::fs::read(path).map_err(|e| format!("{}: {e}", path.display()))?; + let mut p = parse(&bytes).map_err(|e| format!("{}: {e}", path.display()))?; + if p.name.is_none() { + let stem = path.file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_default(); + p.name = valid_name(&clean_text(&stem, NAME_MAX)).ok(); + } + Ok(p) +} + +fn write_json(path: &Path, v: &Value) -> Result<(), String> { + let bytes = serde_json::to_vec_pretty(v).map_err(|e| e.to_string())?; + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + lightcraft_catalog::safe_file::write_atomic(path, &bytes).map_err(|e| format!("{}: {e}", path.display())) +} + +impl Session { + /// Keep user slideshow templates in `<config>/slideshow-templates` (hosts with a file system). + pub fn with_default_slide_templates(mut self) -> Self { + self.slide_templates_dir = crate::config::config_dir().map(|d| d.join(DIR_NAME)); + self + } + + /// Every template (built-in, then the user's), read again when the templates folder changed. + pub fn slide_templates(&mut self) -> Arc<Listing> { + let now = stamp(self.slide_templates_dir.as_deref()); + if let Some((st, l)) = &self.slide_templates_cache + && *st == now + { + return l.clone(); + } + let mut l = Listing { templates: builtins().into_iter().map(Arc::new).collect(), problems: Vec::new() }; + if let Some(dir) = self.slide_templates_dir.clone() { + read_dir(&dir, &mut l); + } + let l = Arc::new(l); + self.slide_templates_cache = Some((now, l.clone())); + l + } + + /// Forget the cached listing (after a write: a folder's time may not change within its + /// resolution). + fn templates_changed(&mut self) { + self.slide_templates_cache = None; + } + + /// The template `id` (or [`PREVIOUS`], the settings before the last apply). + pub fn slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + if id == PREVIOUS { + let look = self.slide_templates_previous.clone().ok_or("no previous settings (no template has been applied this session)")?; + return Ok(Arc::new(Template { + id: PREVIOUS.into(), + name: "Previous settings".into(), + description: String::new(), + builtin: false, + path: None, + look, + })); + } + if id.starts_with(USER) && user_stem(id).is_none() { + return Err(format!("`{id}` isn't a template id (user templates are `user:` and a file name LightCraft made)")); + } + self.slide_templates().get(id).cloned().ok_or_else(|| format!("no slideshow template `{id}`")) + } + + /// The template a command names: `id`, or `name` (case-insensitive). + pub fn slide_template_named(&mut self, id: Option<&str>, name: Option<&str>) -> Result<Arc<Template>, String> { + match (id.map(str::trim).filter(|s| !s.is_empty()), name.map(str::trim).filter(|s| !s.is_empty())) { + (Some(id), _) => self.slide_template(id), + (None, Some(name)) => self.slide_templates().by_name(name).cloned().ok_or_else(|| format!("no slideshow template named \"{name}\"")), + (None, None) => Err("name a template: `id` or `name`".into()), + } + } + + /// Replace the slideshow settings with `look` (prepare previews kept) and save them; the + /// settings it replaced become [`PREVIOUS`]. Returns the warnings for this library. + pub fn apply_slide_look(&mut self, look: &SlideLook) -> crate::Result<Vec<String>> { + let before = SlideLook::of(self); + let mut next = look.clone(); + next.playback.prepare_previews = self.slideshow.prepare_previews; + let next = SlideLook { + playback: next.playback.sanitized(), + layout: next.layout.sanitized(), + overlays: next.overlays.sanitized(), + titles: next.titles.sanitized(), + }; + let warnings = next.warnings(self); + if next != before { + (self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) = (next.playback, next.layout, next.overlays, next.titles); + if let Err(e) = self.save_prefs() { + (self.slideshow, self.slide_layout, self.slide_overlays, self.slide_titles) = + (before.playback, before.layout, before.overlays, before.titles); + return Err(e); + } + self.slide_templates_previous = Some(before); + } + Ok(warnings) + } + + fn templates_dir(&self) -> Result<PathBuf, String> { + self.slide_templates_dir.clone().ok_or_else(|| { + "user templates need a settings folder, and this LightCraft has none (the web build, or LIGHTCRAFT_CONFIG_DIR is set empty)".into() + }) + } + + /// Is a user template file called `stem` there, or one whose name differs only in case? + fn stem_taken(&mut self, dir: &Path, stem: &str, except: Option<&str>) -> bool { + let lower = stem.to_lowercase(); + let other = |s: &str| except.is_none_or(|e| e.to_lowercase() != s.to_lowercase()); + (dir.join(format!("{stem}.json")).exists() && other(stem)) + || self.slide_templates().templates.iter().filter_map(|t| t.id.strip_prefix(USER)).any(|s| s.to_lowercase() == lower && other(s)) + } + + fn builtin_name(name: &str) -> bool { + builtins().iter().any(|b| b.name.to_lowercase() == name.to_lowercase()) + } + + /// Save the current settings as user template `name` (`replace`: over one of that name). + pub fn save_slide_template(&mut self, name: &str, replace: bool) -> Result<Arc<Template>, String> { + let name = valid_name(name)?; + if Self::builtin_name(&name) { + return Err(format!("\"{name}\" is the name of a built-in template; pick another name")); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + let dir = self.templates_dir()?; + if !replace && self.stem_taken(&dir, &stem, None) { + return Err(format!("a template named \"{name}\" (file {stem}.json) already exists: update it, or save under another name")); + } + let look = SlideLook::of(self); + write_json(&dir.join(format!("{stem}.json")), &doc(&name, "", &look))?; + self.templates_changed(); + self.slide_template(&format!("{USER}{stem}")) + } + + /// A user template (not a built-in). + fn user_template(&mut self, id: &str) -> Result<(Arc<Template>, PathBuf), String> { + if id.starts_with(BUILTIN) { + return Err(format!("`{id}` is a built-in template and can't be changed (save the settings as a template of your own)")); + } + if id == PREVIOUS { + return Err("the previous settings aren't a saved template".into()); + } + let t = self.slide_template(id)?; + let path = t.path.clone().ok_or_else(|| format!("`{id}` isn't a user template"))?; + Ok((t, path)) + } + + /// Replace user template `id`'s settings with the current ones (its name kept). + pub fn update_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + write_json(&path, &doc(&t.name, &t.description, &SlideLook::of(self)))?; + self.templates_changed(); + self.slide_template(id) + } + + /// Rename user template `id` (its file follows the name). + pub fn rename_slide_template(&mut self, id: &str, name: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + let name = valid_name(name)?; + if Self::builtin_name(&name) { + return Err(format!("\"{name}\" is the name of a built-in template; pick another name")); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + let dir = self.templates_dir()?; + let old = id.strip_prefix(USER).unwrap_or_default().to_string(); + if self.stem_taken(&dir, &stem, Some(&old)) { + return Err(format!("a template named \"{name}\" (file {stem}.json) already exists")); + } + // the new name into the file, then the file to its new name (one rename: never two copies + // or none, and a change of case works on case-insensitive file systems too) + write_json(&path, &doc(&name, &t.description, &t.look))?; + let to = dir.join(format!("{stem}.json")); + if to != path { + std::fs::rename(&path, &to).map_err(|e| format!("{}: {e}", to.display()))?; + } + self.templates_changed(); + self.slide_template(&format!("{USER}{stem}")) + } + + /// Delete user template `id`'s file. + pub fn delete_slide_template(&mut self, id: &str) -> Result<Arc<Template>, String> { + let (t, path) = self.user_template(id)?; + std::fs::remove_file(&path).map_err(|e| format!("{}: {e}", path.display()))?; + self.templates_changed(); + Ok(t) + } + + /// Import the template file at `path` as a user template. A name already taken gets " (2)", + /// " (3)"…; returns the template and whether it was renamed. + pub fn import_slide_template(&mut self, path: &Path) -> Result<(Arc<Template>, bool), String> { + let p = read_file(path)?; + let dir = self.templates_dir()?; + let base = p.name.clone().ok_or_else(|| format!("{}: the template has no name, nor a file name to take one from", path.display()))?; + let mut name = base.clone(); + let mut n = 1; + loop { + let free = !Self::builtin_name(&name) && file_stem(&name).is_some_and(|stem| !self.stem_taken(&dir, &stem, None)); + if free { + break; + } + n += 1; + if n > 999 { + return Err(format!("too many templates named \"{base}\"")); + } + let suffix = format!(" ({n})"); + let keep: String = base.chars().take(NAME_MAX.saturating_sub(suffix.chars().count())).collect(); + name = format!("{}{suffix}", keep.trim_end()); + } + let stem = file_stem(&name).ok_or_else(|| format!("\"{name}\" has no letters or digits to make a file name from"))?; + write_json(&dir.join(format!("{stem}.json")), &doc(&name, &p.description, &p.look))?; + self.templates_changed(); + Ok((self.slide_template(&format!("{USER}{stem}"))?, name != base)) + } +} + +#[cfg(test)] +#[path = "tests_slide_template.rs"] +mod tests; diff --git a/crates/engine/src/slide_title.rs b/crates/engine/src/slide_title.rs new file mode 100644 index 000000000..5885463b1 --- /dev/null +++ b/crates/engine/src/slide_title.rs @@ -0,0 +1,527 @@ +//! Slideshow title screens (Lightroom Classic's Slideshow ▸ Titles panel): an intro shown before +//! the first slide and an ending shown after the last one, each switched on on its own, with a +//! background colour and, centred on it, the identity plate (the one of the Overlays panel: its +//! text or image) or a line of text, at a scale. [`TitleSettings`] is a library preference +//! (`prefs.json`), edited with the `slideshow.titles` command; [`TitleCompose`] draws one screen. +//! +//! **One composer:** a title screen is a slide with no photo: `lightcraft_slideshow::compose` +//! fills it with the title's colour, then the overlay code ([`crate::slide_overlay`]) draws the +//! plate or text with the shared text renderer ([`crate::text`]). Playback, the Settings preview +//! and `slideshow.preview {title}` all go through [`TitleCompose::compose`], so they draw the same +//! pixels. +//! +//! **Timing** is in [`crate::slideshow::Playback`]: each screen stays up for its own duration +//! (default: the slide duration) and fades in and out with the playback fade. +//! +//! **Resolution independence:** the scale is the height of the plate (or of a text line) as a +//! fraction of the slide's shorter side; text wider than the slide is scaled down to fit. +//! +//! **Never crash:** settings are validated by [`TitleSettings::merged`] and clamped by +//! [`TitleSettings::sanitized`]; an unreadable plate image draws the colour alone (and +//! `slideshow.titles` reports it). + +use std::sync::Arc; + +use lightcraft_raster::Rgba8; +use lightcraft_slideshow::{Layout, Rect, SlideSettings, hex, parse_color}; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::export::Anchor; +use crate::slide_overlay::{IdentityPlate, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SIZE_MIN, SlideOverlays, TEXT_MAX_CHARS}; +pub use crate::slideshow::Title; + +/// Widest a title's plate or text may be (fraction of the slide's width): wider is scaled down. +pub const FIT_WIDTH: f64 = 0.9; + +/// What a title screen shows on its colour. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum TitleContent { + /// The colour alone. + None, + /// The identity plate of the Overlays panel (its text or image). + #[default] + IdentityPlate, + /// This screen's own text. + Text, +} + +impl TitleContent { + pub const ALL: [TitleContent; 3] = [TitleContent::None, TitleContent::IdentityPlate, TitleContent::Text]; + + pub fn id(self) -> &'static str { + match self { + TitleContent::None => "none", + TitleContent::IdentityPlate => "identityPlate", + TitleContent::Text => "text", + } + } + + fn parse(s: &str) -> Option<TitleContent> { + TitleContent::ALL.into_iter().find(|c| c.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// One title screen. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TitleScreen { + pub enabled: bool, + /// The screen's background colour. + pub color: [u8; 3], + pub content: TitleContent, + /// The text drawn with [`TitleContent::Text`]. + pub text: String, + /// Draw the plate or text in [`Self::text_color`] (off: in the identity plate's colour). + /// An image plate keeps its own colours. + pub override_color: bool, + pub text_color: [u8; 3], + /// Height of the plate (or of a text line), fraction of the slide's shorter side. + pub scale: f64, + /// Seconds the screen stays up (`None`: the slide duration). + pub duration: Option<f64>, +} + +impl TitleScreen { + fn new(text: &str) -> Self { + TitleScreen { + enabled: false, + color: [0, 0, 0], + content: TitleContent::IdentityPlate, + text: text.into(), + override_color: false, + text_color: [255, 255, 255], + scale: 0.1, + duration: None, + } + } +} + +/// The intro and ending screens. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct TitleSettings { + pub intro: TitleScreen, + pub ending: TitleScreen, +} + +impl Default for TitleSettings { + fn default() -> Self { + TitleSettings { intro: TitleScreen::new("Welcome"), ending: TitleScreen::new("The End") } + } +} + +impl Default for TitleScreen { + fn default() -> Self { + TitleScreen::new("") + } +} + +fn cut(s: &mut String) { + if let Some((i, _)) = s.char_indices().nth(TEXT_MAX_CHARS) { + s.truncate(i); + } +} + +const SCREEN_KEYS: &str = "enabled, color, content, text, overrideColor, textColor, scale, duration"; + +impl TitleScreen { + fn sanitized(mut self) -> Self { + cut(&mut self.text); + self.scale = if self.scale.is_finite() { self.scale.clamp(SIZE_MIN, PLATE_SIZE_MAX) } else { 0.1 }; + self.duration = self.duration.filter(|d| d.is_finite()).map(|d| d.clamp(crate::slideshow::SLIDE_MIN, crate::slideshow::SLIDE_MAX)); + self + } + + pub fn to_json(&self) -> Value { + let mut o = Map::new(); + o.insert("enabled".into(), json!(self.enabled)); + o.insert("color".into(), json!(hex(self.color))); + o.insert("content".into(), json!(self.content.id())); + o.insert("text".into(), json!(self.text)); + o.insert("overrideColor".into(), json!(self.override_color)); + o.insert("textColor".into(), json!(hex(self.text_color))); + o.insert("scale".into(), json!(self.scale)); + o.insert("duration".into(), json!(self.duration)); + Value::Object(o) + } + + fn merge(&mut self, ctx: &str, v: &Value) -> Result<(), String> { + let obj = v.as_object().ok_or_else(|| format!("`{ctx}` must be an object, not {v}"))?; + let num = |k: &str, v: &Value| match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{ctx}.{k}` must be a finite number, not {v}")), + }; + let boolean = |k: &str, v: &Value| v.as_bool().ok_or_else(|| format!("`{ctx}.{k}` must be true or false, not {v}")); + let colour = |k: &str, v: &Value| parse_color(v).ok_or_else(|| format!("`{ctx}.{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")); + for (k, v) in obj { + match k.as_str() { + "enabled" => self.enabled = boolean(k, v)?, + "color" => self.color = colour(k, v)?, + "content" => { + self.content = v + .as_str() + .and_then(TitleContent::parse) + .ok_or_else(|| format!("`{ctx}.content` must be \"none\", \"identityPlate\" or \"text\", not {v}"))? + } + "text" => { + let mut s = v.as_str().ok_or_else(|| format!("`{ctx}.text` must be text, not {v}"))?.to_string(); + cut(&mut s); + self.text = s; + } + "overrideColor" => self.override_color = boolean(k, v)?, + "textColor" => self.text_color = colour(k, v)?, + "scale" => self.scale = num(k, v)?, + "duration" => { + self.duration = match v { + Value::Null => None, + _ => Some(num(k, v).map_err(|_| format!("`{ctx}.duration` must be seconds or null (the slide duration), not {v}"))?), + } + } + _ => return Err(format!("unknown key `{ctx}.{k}` ({SCREEN_KEYS})")), + } + } + Ok(()) + } +} + +impl TitleSettings { + pub fn screen(&self, t: Title) -> &TitleScreen { + match t { + Title::Intro => &self.intro, + Title::Ending => &self.ending, + } + } + + /// Every value in range (non-finite numbers take the defaults). + pub fn sanitized(mut self) -> Self { + self.intro = self.intro.sanitized(); + self.ending = self.ending.sanitized(); + self + } + + pub fn to_json(&self) -> Value { + json!({"intro": self.intro.to_json(), "ending": self.ending.to_json()}) + } + + /// These settings with the keys `p` names changed, validated and clamped; a wrong type or an + /// unknown key is an error and nothing changes. + pub fn merged(&self, p: &Value) -> Result<Self, String> { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "intro" => s.intro.merge(k, v)?, + "ending" => s.ending.merge(k, v)?, + _ => return Err(format!("unknown setting `{k}` (intro, ending)")), + } + } + Ok(s.sanitized()) + } + + /// How long each screen that is on stays up, given the slide duration (for + /// [`crate::slideshow::Playback::with_titles`]): (intro, ending). + pub fn durations(&self, slide: f64) -> (Option<f64>, Option<f64>) { + let d = |s: &TitleScreen| s.enabled.then(|| s.duration.unwrap_or(slide)); + (d(&self.intro), d(&self.ending)) + } + + /// Problems worth telling the user about: a screen showing an identity plate image that + /// can't be read, or one with nothing to show. + pub fn warnings(&self, overlays: &OverlaySettings) -> Vec<String> { + let mut out = Vec::new(); + let plate = &overlays.identity_plate; + for t in Title::ALL { + let s = self.screen(t); + if !s.enabled { + continue; + } + match s.content { + TitleContent::IdentityPlate if !plate.image.trim().is_empty() && crate::export::watermark_logo(plate.image.trim()).is_none() => { + out.push(format!("{} screen: identity plate image {} can't be read; only the colour shows", t.id(), plate.image.trim())) + } + TitleContent::IdentityPlate if plate.image.trim().is_empty() && plate.text.trim().is_empty() => { + out.push(format!("{} screen: the identity plate has no text or image; only the colour shows", t.id())) + } + TitleContent::Text if s.text.trim().is_empty() => out.push(format!("{} screen: no text; only the colour shows", t.id())), + _ => {} + } + } + out + } +} + +/// How to draw one title screen at a size: its colour and its plate or text, resolved from the +/// library's settings (no session needed, so it runs on a render worker). +#[derive(Clone, Debug)] +pub struct TitleCompose { + pub title: Title, + /// The screen as a slide: its colour as the backdrop, nothing else. + pub settings: SlideSettings, + /// The plate or text, as a centred identity plate (`None`: the colour alone). + pub overlays: Option<Arc<SlideOverlays>>, + pub width: usize, + pub height: usize, +} + +impl TitleCompose { + /// Screen `title` of `titles` at `width × height` (a bigger one is drawn at the composer's + /// [`lightcraft_slideshow::MAX_SIDE`]), + /// with `overlays`' identity plate when the screen shows it. + pub fn new(titles: &TitleSettings, overlays: &OverlaySettings, title: Title, width: usize, height: usize) -> Self { + let s = titles.screen(title).clone().sanitized(); + let settings = SlideSettings { backdrop_color: s.color, ..SlideSettings::default() }.sanitized(); + let plate = &overlays.identity_plate; + let centre = Placement { anchor: Anchor::Center, offset_x: 0.0, offset_y: 0.0, relative_to: RelativeTo::Slide }; + let colour = |own: [u8; 3]| if s.override_color { s.text_color } else { own }; + let shown = match s.content { + TitleContent::None => None, + TitleContent::IdentityPlate => Some(IdentityPlate { + enabled: true, + text: plate.text.clone(), + image: plate.image.clone(), + size: s.scale, + color: colour(plate.color), + opacity: plate.opacity, + place: centre, + behind: false, + }), + TitleContent::Text => Some(IdentityPlate { + enabled: true, + text: s.text.clone(), + image: String::new(), + size: s.scale, + color: colour(plate.color), + opacity: 1.0, + place: centre, + behind: false, + }), + }; + let mut compose = TitleCompose { title, settings, overlays: None, width, height }; + if let Some(p) = shown.filter(|p| !p.text.trim().is_empty() || !p.image.trim().is_empty()) { + let resolved = + |p: IdentityPlate| Arc::new(SlideOverlays::resolve(&OverlaySettings { identity_plate: p, ..OverlaySettings::default() }, None)); + compose.overlays = Some(resolved(p.clone())); + // a long text (or a wide logo) at a large scale: shrink it to fit the slide's width + if let Some(b) = compose.plate_box() { + let max = FIT_WIDTH * compose.layout().slide.width(); + if b.width() > max && b.width() > 0.0 { + let size = (p.size * max / b.width()).max(SIZE_MIN); + compose.overlays = Some(resolved(IdentityPlate { size, ..p })); + } + } + } + compose + } + + fn layout(&self) -> Layout { + lightcraft_slideshow::layout(&self.settings, 1.0, self.width, self.height) + } + + /// Where the plate or text is on the screen (`None`: nothing is drawn but the colour). + pub fn plate_box(&self) -> Option<Rect> { + let o = self.overlays.as_ref()?; + o.boxes(&self.layout()).into_iter().next().map(|(_, r)| r) + } + + /// What makes the screen's pixels differ (for render keys). + pub fn key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + "title".hash(&mut h); + self.title.hash(&mut h); + self.settings.pixel_key().hash(&mut h); + self.overlays.as_ref().map(|o| o.key()).hash(&mut h); + (self.width, self.height).hash(&mut h); + h.finish() + } + + /// The screen's pixels. + pub fn compose(&self) -> Rgba8 { + let empty = Rgba8::new(0, 0); + let (mut img, l) = lightcraft_slideshow::compose_with(&empty, None, &self.settings, self.width, self.height, &mut |_, _| {}); + if let Some(o) = &self.overlays { + o.draw_front(&mut img, &l); + } + img + } + + /// [`Self::compose`] as a render result (for a frontend's render queue): request `request_id`, + /// under this screen's key. Title screens show no photo (`photo` is 0). + pub fn result(&self, request_id: u64) -> crate::media::RenderResult { + let image = self.compose(); + let histogram = lightcraft_raster::Histogram::of_srgb8(&image); + crate::media::RenderResult { + request_id, + source_key: None, + photo: lightcraft_catalog::PhotoId(0), + level: crate::media::SourceLevel::Preview, + key: self.key(), + rendered: Ok(lightcraft_pipeline::Rendered { image, histogram, deep: None }), + loaded: None, + quick: None, + display: None, + gpu_fallback: None, + } + } +} + +impl crate::Session { + /// Title screen `title` at `width × height` with the library's titles and identity plate. + pub fn title_compose(&self, title: Title, width: usize, height: usize) -> Arc<TitleCompose> { + Arc::new(TitleCompose::new(&self.slide_titles, &self.slide_overlays, title, width, height)) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn at(img: &Rgba8, x: usize, y: usize) -> [u8; 3] { + let p = img.get(x, y); + [p[0], p[1], p[2]] + } + + /// The bounding box of the pixels that differ from `bg` (x0, y0, x1, y1). + fn ink(img: &Rgba8, bg: [u8; 3]) -> Option<(usize, usize, usize, usize)> { + let mut b: Option<(usize, usize, usize, usize)> = None; + for y in 0..img.height { + for x in 0..img.width { + let c = at(img, x, y); + if c.iter().zip(bg).any(|(a, b)| a.abs_diff(b) > 40) { + b = Some(match b { + None => (x, y, x + 1, y + 1), + Some((x0, y0, x1, y1)) => (x0.min(x), y0.min(y), x1.max(x + 1), y1.max(y + 1)), + }); + } + } + } + b + } + + fn text_titles(scale: f64) -> TitleSettings { + TitleSettings::default() + .merged(&json!({"intro": {"enabled": true, "color": "#203040", "content": "text", "text": "Holiday", "overrideColor": true, "textColor": "#ffcc00", "scale": scale}})) + .unwrap() + } + + #[test] + fn a_text_title_is_centred_in_its_colours_and_scales_with_the_slide() { + let o = OverlaySettings::default(); + let t = text_titles(0.2); + let mut boxes = Vec::new(); + for (w, h) in [(400, 250), (800, 500)] { + let c = TitleCompose::new(&t, &o, Title::Intro, w, h); + let img = c.compose(); + assert_eq!((img.width, img.height), (w, h)); + assert_eq!(at(&img, 2, 2), [0x20, 0x30, 0x40], "the title's colour"); + let (x0, y0, x1, y1) = ink(&img, [0x20, 0x30, 0x40]).expect("the text is drawn"); + // centred (glyph ink sits inside the line box, so allow some slack vertically) + let (cx, cy) = ((x0 + x1) as f64 / 2.0, (y0 + y1) as f64 / 2.0); + assert!((cx - w as f64 / 2.0).abs() < w as f64 * 0.03, "centred across: {cx} of {w}"); + assert!((cy - h as f64 / 2.0).abs() < h as f64 * 0.06, "centred down: {cy} of {h}"); + let mid = at(&img, (x0..x1).find(|x| at(&img, *x, (y0 + y1) / 2) != [0x20, 0x30, 0x40]).unwrap_or(x0), (y0 + y1) / 2); + assert!(mid[0] > 150 && mid[2] < 120, "drawn in the text colour: {mid:?}"); + boxes.push(((x1 - x0) as f64 / w as f64, (y1 - y0) as f64 / h as f64)); + let b = c.plate_box().unwrap(); + assert!((b.height() - 0.2 * h as f64).abs() < 2.0, "the scale is the line height: {b:?}"); + } + let ((w1, h1), (w2, h2)) = (boxes[0], boxes[1]); + assert!((w1 - w2).abs() < 0.02 && (h1 - h2).abs() < 0.02, "proportional at two sizes: {boxes:?}"); + // a bigger scale draws bigger text + let big = ink(&TitleCompose::new(&text_titles(0.3), &o, Title::Intro, 800, 500).compose(), [0x20, 0x30, 0x40]).unwrap(); + assert!((big.2 - big.0) as f64 / 800.0 > w2 * 1.3); + } + + #[test] + fn the_identity_plate_shows_in_its_own_colour_unless_overridden() { + let o = OverlaySettings::default() + .merged(&json!({"identityPlate": {"text": "Studio", "color": "#00ff00", "opacity": 1, "anchor": "topLeft"}})) + .unwrap(); + assert!(!o.identity_plate.enabled, "the overlay plate itself may be off"); + let t = TitleSettings::default().merged(&json!({"ending": {"enabled": true, "color": "#000000", "scale": 0.15}})).unwrap(); + let img = TitleCompose::new(&t, &o, Title::Ending, 600, 400).compose(); + let (x0, y0, x1, y1) = ink(&img, [0, 0, 0]).expect("the plate is drawn"); + assert!(x0 > 100 && x1 < 500 && y0 > 120 && y1 < 280, "centred, not at the overlay's anchor: {:?}", (x0, y0, x1, y1)); + let greens = (y0..y1).flat_map(|y| (x0..x1).map(move |x| (x, y))).filter(|&(x, y)| { + let c = at(&img, x, y); + c[1] > 200 && c[0] < 60 + }); + assert!(greens.count() > 100, "in the plate's colour"); + let over = t.merged(&json!({"ending": {"overrideColor": true, "textColor": "#ff0000"}})).unwrap(); + let img = TitleCompose::new(&over, &o, Title::Ending, 600, 400).compose(); + let reds = img.data.iter().filter(|p| p[0] > 200 && p[1] < 60).count(); + assert!(reds > 100, "overridden"); + // the colour alone + let none = t.merged(&json!({"ending": {"content": "none", "color": "#123456"}})).unwrap(); + let c = TitleCompose::new(&none, &o, Title::Ending, 64, 40); + assert!(c.plate_box().is_none()); + assert!(c.compose().data.iter().all(|p| p[..3] == [0x12, 0x34, 0x56])); + } + + #[test] + fn a_missing_plate_image_draws_the_colour_and_is_reported() { + let o = OverlaySettings::default().merged(&json!({"identityPlate": {"image": "/no/such/logo.png"}})).unwrap(); + let t = TitleSettings::default().merged(&json!({"intro": {"enabled": true, "color": "#336699"}})).unwrap(); + let c = TitleCompose::new(&t, &o, Title::Intro, 120, 80); + assert!(c.compose().data.iter().all(|p| p[..3] == [0x33, 0x66, 0x99])); + let w = t.warnings(&o); + assert!(w.iter().any(|w| w.contains("intro") && w.contains("can't be read")), "{w:?}"); + let t = t.merged(&json!({"intro": {"content": "text", "text": " "}})).unwrap(); + assert!(t.warnings(&o).iter().any(|w| w.contains("no text"))); + } + + #[test] + fn long_text_is_scaled_down_to_fit_the_width() { + let t = TitleSettings::default() + .merged(&json!({"intro": {"enabled": true, "content": "text", "text": "A very long title that would never fit across", "scale": 0.5}})) + .unwrap(); + let c = TitleCompose::new(&t, &OverlaySettings::default(), Title::Intro, 800, 450); + let b = c.plate_box().unwrap(); + assert!(b.width() <= 800.0 * FIT_WIDTH + 2.0 && b.x0 >= 0.0, "{b:?}"); + } + + #[test] + fn settings_are_validated_clamped_and_round_trip() { + let d = TitleSettings::default(); + assert!(!d.intro.enabled && !d.ending.enabled, "off by default"); + assert_eq!(d.durations(4.0), (None, None)); + for bad in [ + json!({"intro": {"enabled": 1}}), + json!({"intro": {"color": "#12"}}), + json!({"ending": {"content": "video"}}), + json!({"ending": {"scale": "big"}}), + json!({"ending": {"duration": "long"}}), + json!({"intro": {"sparkle": true}}), + json!({"intro": []}), + json!({"credits": {}}), + json!({"INTRO": {}}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"intro": {"enabled": true, "scale": 9, "duration": 1e9}, "ending": {"enabled": true, "scale": -1, "duration": 0}})) + .unwrap(); + assert_eq!((s.intro.scale, s.ending.scale), (PLATE_SIZE_MAX, SIZE_MIN)); + assert_eq!((s.intro.duration, s.ending.duration), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN))); + assert_eq!(s.durations(4.0), (Some(crate::slideshow::SLIDE_MAX), Some(crate::slideshow::SLIDE_MIN))); + let s = s.merged(&json!({"intro": {"duration": null}})).unwrap(); + assert_eq!(s.durations(4.0).0, Some(4.0), "null: the slide duration"); + assert_eq!(d.merged(&s.to_json()).unwrap(), s, "the JSON form reads back"); + let back: TitleSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + let partial: TitleSettings = serde_json::from_str(r#"{"ending": {"enabled": true}}"#).unwrap(); + assert!(partial.ending.enabled && !partial.intro.enabled); + // NaN / ∞ from a hand-edited file + let s = TitleSettings { intro: TitleScreen { scale: f64::NAN, duration: Some(f64::INFINITY), ..d.intro.clone() }, ..d.clone() }.sanitized(); + assert_eq!((s.intro.scale, s.intro.duration), (0.1, None)); + // any size composes + for (w, h) in [(0, 0), (1, 100_000), (usize::MAX, 3)] { + let c = TitleCompose::new(&text_titles(0.5), &OverlaySettings::default(), Title::Intro, w, h); + let img = c.compose(); + assert!(img.width >= 1 && img.height >= 1 && img.width <= lightcraft_slideshow::MAX_SIDE); + } + } +} diff --git a/crates/engine/src/slideshow.rs b/crates/engine/src/slideshow.rs new file mode 100644 index 000000000..dbee505af --- /dev/null +++ b/crates/engine/src/slideshow.rs @@ -0,0 +1,921 @@ +//! Slideshow playback: the settings of Lightroom Classic's Playback panel and the timeline that +//! plays the photos in view (durations, fades, random order, repeat, pause, stepping), between an +//! optional intro and ending screen (the Titles panel, [`crate::slide_title`]). +//! +//! No UI here: [`Playback`] is a pure state machine driven with injected times (seconds on any +//! monotonic clock), so the desktop app, a later Slideshow module and tests run the same timeline. +//! The frontend asks [`Playback::frame`] what to draw (the current slide, and while a transition +//! runs the previous one and its progress) and [`Playback::upcoming`] which slides to prepare; it +//! tells [`Playback::tick`] which slides are ready, so a transition never starts on a slide that +//! has not been rendered yet (the current one is held instead). +//! +//! The settings are a library preference (`prefs.json`, see [`crate::library`]), read and changed +//! with the `slideshow.playback` command. + +use lightcraft_catalog::PhotoId; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Shortest and longest time a slide stays up (seconds). +pub const SLIDE_MIN: f64 = 0.5; +pub const SLIDE_MAX: f64 = 120.0; +/// Longest fade (seconds); a fade is never longer than the slide duration. +pub const FADE_MAX: f64 = 10.0; + +/// How playback runs (Lightroom Classic's Playback panel, without music and pan & zoom). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct PlaybackSettings { + /// Seconds from the start of one slide's transition to the start of the next one's (the + /// fade in is part of it). + pub slide_duration: f64, + /// Seconds a transition takes (0 = cut). + pub fade_duration: f64, + /// Fade through this colour (out to it, then in from it) instead of crossfading. + pub fade_color: Option<[u8; 3]>, + /// Shuffle the photos (a new order for every pass when repeating). + pub random_order: bool, + /// Start over after the last slide; off: playback ends there. + pub repeat: bool, + /// Render the next slides before they are due. + pub prepare_previews: bool, +} + +impl Default for PlaybackSettings { + fn default() -> Self { + PlaybackSettings { slide_duration: 4.0, fade_duration: 1.0, fade_color: None, random_order: false, repeat: true, prepare_previews: true } + } +} + +impl PlaybackSettings { + /// The settings with every value in range (a non-finite duration takes the default; the fade + /// is never longer than the slide). + pub fn sanitized(mut self) -> Self { + let d = PlaybackSettings::default(); + self.slide_duration = if self.slide_duration.is_finite() { self.slide_duration.clamp(SLIDE_MIN, SLIDE_MAX) } else { d.slide_duration }; + let fade = if self.fade_duration.is_finite() { self.fade_duration } else { d.fade_duration }; + self.fade_duration = fade.clamp(0.0, FADE_MAX).min(self.slide_duration); + self + } + + pub fn to_json(&self) -> Value { + json!({ + "slideDuration": self.slide_duration, + "fadeDuration": self.fade_duration, + "fadeColor": self.fade_color.map(hex), + "randomOrder": self.random_order, + "repeat": self.repeat, + "preparePreviews": self.prepare_previews, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error; nothing changes then. + pub fn merged(&self, p: &Value) -> std::result::Result<Self, String> { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + for (k, v) in obj { + match k.as_str() { + "slideDuration" => s.slide_duration = finite(k, v)?, + "fadeDuration" => s.fade_duration = finite(k, v)?, + "fadeColor" => s.fade_color = color(v)?, + "randomOrder" => s.random_order = boolean(k, v)?, + "repeat" => s.repeat = boolean(k, v)?, + "preparePreviews" => s.prepare_previews = boolean(k, v)?, + other => { + return Err(format!("unknown setting `{other}` (slideDuration, fadeDuration, fadeColor, randomOrder, repeat, preparePreviews)")); + } + } + } + // a fade longer than a slide asked for together with a shorter slide: the slide wins + Ok(s.sanitized()) + } +} + +fn finite(k: &str, v: &Value) -> std::result::Result<f64, String> { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number of seconds, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> std::result::Result<bool, String> { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +/// `null` / `""` / `"none"` = crossfade; `"#rrggbb"` or `[r, g, b]` (0–255) = fade through it. +fn color(v: &Value) -> std::result::Result<Option<[u8; 3]>, String> { + let bad = || format!("`fadeColor` must be \"#rrggbb\", [r, g, b] or null, not {v}"); + match v { + Value::Null => Ok(None), + Value::String(s) if s.is_empty() || s.eq_ignore_ascii_case("none") => Ok(None), + Value::String(s) => { + let h = s.strip_prefix('#').unwrap_or(s); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return Err(bad()); + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()).ok_or_else(bad); + Ok(Some([byte(0)?, byte(2)?, byte(4)?])) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = x.as_u64().filter(|n| *n <= 255).ok_or_else(bad)? as u8; + } + Ok(Some(c)) + } + _ => Err(bad()), + } +} + +/// A title screen (Lightroom Classic's Titles panel): the intro before the first slide, the +/// ending after the last one. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum Title { + Intro, + Ending, +} + +impl Title { + pub const ALL: [Title; 2] = [Title::Intro, Title::Ending]; + + pub fn id(self) -> &'static str { + match self { + Title::Intro => "intro", + Title::Ending => "ending", + } + } + + pub fn parse(s: &str) -> Option<Title> { + Title::ALL.into_iter().find(|t| t.id().eq_ignore_ascii_case(s.trim())) + } +} + +/// One slide of the timeline: a photo, or a title screen (not a photo: it shows no photo, never +/// moves the selection and isn't counted in "n of N"). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Item { + Title(Title), + Photo(PhotoId), +} + +impl From<PhotoId> for Item { + fn from(p: PhotoId) -> Item { + Item::Photo(p) + } +} + +impl Item { + pub const INTRO: Item = Item::Title(Title::Intro); + pub const ENDING: Item = Item::Title(Title::Ending); + + /// The photo this slide shows (`None` for a title screen). + pub fn photo(self) -> Option<PhotoId> { + match self { + Item::Photo(p) => Some(p), + Item::Title(_) => None, + } + } + + pub fn title(self) -> Option<Title> { + match self { + Item::Title(t) => Some(t), + Item::Photo(_) => None, + } + } +} + +/// What a tick did. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Tick { + /// Nothing changed (the slide is up, paused, or a transition is running). + Idle, + /// The next slide is due but not ready: the current one is held. + Waiting(Item), + /// A new slide came up (its transition starts now). + Advanced(Item), + /// The last slide was shown and repeat is off. + Ended, +} + +/// What to draw at a moment of playback. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Frame { + pub current: Item, + /// The slide being faded out, while a transition runs. + pub from: Option<Item>, + /// How far the transition is (0–1; 1 when there is none). + pub progress: f32, + pub fade_color: Option<[u8; 3]>, +} + +/// Where the timeline is: the intro, the photos, or the ending. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Phase { + Intro, + Photos, + Ending, +} + +/// One run of a slideshow over a fixed list of photos, with an optional intro and ending. +/// +/// **Titles:** the intro plays once, before the first photo; the ending once, after the last photo +/// when repeat is off (with repeat on the show only ends when it is stopped, so the ending never +/// comes up; passes follow each other without titles between them). Random order shuffles the +/// photos only: titles keep their places. +#[derive(Clone, Debug)] +pub struct Playback { + settings: PlaybackSettings, + photos: Vec<PhotoId>, + /// This pass's order (indices into `photos`). + order: Vec<usize>, + /// Index into `order` of the photo up now (the last one while the ending is up). + pos: usize, + phase: Phase, + pass: u64, + seed: u64, + /// How long the intro and the ending stay up (`None`: no such screen). + intro: Option<f64>, + ending: Option<f64>, + /// When the current slide came up (its transition started). + started: f64, + /// The slide fading out (until the transition ends). + from: Option<Item>, + /// Paused at this time. + paused_at: Option<f64>, + ended: bool, + /// The next slide was due but not ready at the last tick. + held: Option<Item>, +} + +/// SplitMix64: a small, well-mixed generator for the shuffle (deterministic per seed). +fn splitmix(state: &mut u64) -> u64 { + *state = state.wrapping_add(0x9e37_79b9_7f4a_7c15); + let mut z = *state; + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z = (z ^ (z >> 27)).wrapping_mul(0x94d0_49bb_1331_11eb); + z ^ (z >> 31) +} + +/// Fisher–Yates shuffle of `v` with `seed`. +fn shuffle(v: &mut [usize], seed: u64) { + let mut st = seed; + for i in (1..v.len()).rev() { + let j = (splitmix(&mut st) % (i as u64 + 1)) as usize; + v.swap(i, j); + } +} + +impl Playback { + /// Start playing `photos` (the ones in view, in view order) at `start` (the selected photo; + /// the first one when absent or not among them), at time `now`. `seed` fixes the random order. + /// `None` when there is nothing to show. + pub fn new(photos: Vec<PhotoId>, start: Option<PhotoId>, settings: PlaybackSettings, seed: u64, now: f64) -> Option<Self> { + if photos.is_empty() { + return None; + } + let settings = settings.sanitized(); + let n = photos.len(); + let first = start.and_then(|s| photos.iter().position(|p| *p == s)).unwrap_or(0); + let order: Vec<usize> = if settings.random_order { + // the starting photo first, the others shuffled + let mut rest: Vec<usize> = (0..n).filter(|i| *i != first).collect(); + shuffle(&mut rest, seed); + std::iter::once(first).chain(rest).collect() + } else { + // from the starting photo to the end, then the ones before it: every photo once + (first..n).chain(0..first).collect() + }; + let now = if now.is_finite() { now } else { 0.0 }; + Some(Playback { + settings, + photos, + order, + pos: 0, + phase: Phase::Photos, + pass: 0, + seed, + intro: None, + ending: None, + started: now, + from: None, + paused_at: None, + ended: false, + held: None, + }) + } + + /// With an intro and / or an ending screen, each up for its duration in seconds (clamped like + /// a slide's; a non-finite one takes the slide duration). Call it right after [`Self::new`]: + /// the show then starts at the intro. + pub fn with_titles(mut self, intro: Option<f64>, ending: Option<f64>) -> Self { + let slide = self.settings.slide_duration; + let dur = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { slide }; + self.intro = intro.map(dur); + self.ending = ending.map(dur); + if self.intro.is_some() && self.pass == 0 && self.pos == 0 && self.phase == Phase::Photos { + self.phase = Phase::Intro; + } + self + } + + pub fn settings(&self) -> &PlaybackSettings { + &self.settings + } + + /// The order of pass `pass` (after the first), given the order of the pass before it. + fn order_after(&self, pass: u64, prev: &[usize]) -> Vec<usize> { + let mut order = prev.to_vec(); + if self.settings.random_order { + order.sort_unstable(); + shuffle(&mut order, self.seed ^ pass.wrapping_mul(0xa076_1d64_78bd_642f)); + // never the same photo twice in a row across passes + if order.len() > 1 && order.first() == prev.last() { + order.swap(0, 1); + } + } + order + } + + fn photo_at(&self, order: &[usize], pos: usize) -> Option<PhotoId> { + order.get(pos).and_then(|i| self.photos.get(*i)).copied() + } + + fn item_at(&self, phase: Phase, order: &[usize], pos: usize) -> Option<Item> { + match phase { + Phase::Intro => Some(Item::INTRO), + Phase::Ending => Some(Item::ENDING), + Phase::Photos => self.photo_at(order, pos).map(Item::Photo), + } + } + + /// The place after (`phase`, `pos`, `pass`); `order` is that pass's order and becomes the next + /// pass's when it wraps. `None`: the show ends there. + fn following(&self, phase: Phase, pos: usize, pass: u64, order: &mut Vec<usize>) -> Option<(Phase, usize, u64)> { + match phase { + Phase::Intro => Some((Phase::Photos, 0, pass)), + Phase::Photos if pos + 1 < order.len() => Some((Phase::Photos, pos + 1, pass)), + Phase::Photos if self.settings.repeat => { + *order = self.order_after(pass + 1, order); + Some((Phase::Photos, 0, pass + 1)) + } + Phase::Photos if self.ending.is_some() => Some((Phase::Ending, pos, pass)), + Phase::Photos | Phase::Ending => None, + } + } + + /// The slide up now. + pub fn current(&self) -> Item { + self.item_at(self.phase, &self.order, self.pos).or_else(|| self.photos.first().copied().map(Item::Photo)).unwrap_or(Item::Photo(PhotoId(0))) + } + + /// The photo up now (`None` while a title screen is up). + pub fn current_photo(&self) -> Option<PhotoId> { + self.current().photo() + } + + /// (photo number from 1, photos per pass). Titles aren't photos: (0, N) while the intro is up, + /// (N, N) while the ending is; [`Self::title`] says which. + pub fn position(&self) -> (usize, usize) { + let n = self.order.len(); + match self.phase { + Phase::Intro => (0, n), + Phase::Photos => (self.pos + 1, n), + Phase::Ending => (n, n), + } + } + + /// The title screen up now, if one is. + pub fn title(&self) -> Option<Title> { + self.current().title() + } + + pub fn is_paused(&self) -> bool { + self.paused_at.is_some() + } + + pub fn is_ended(&self) -> bool { + self.ended + } + + /// The slide that was due but not ready at the last tick (the current one is held for it). + pub fn waiting_for(&self) -> Option<Item> { + self.held + } + + /// The clock as playback sees it: stopped while paused. + fn clock(&self, now: f64) -> f64 { + let now = if now.is_finite() { now } else { self.started }; + self.paused_at.map_or(now, |p| p.min(now)) + } + + /// How long `item` stays up (seconds). + pub fn duration(&self, item: Item) -> f64 { + match item { + Item::Title(Title::Intro) => self.intro.unwrap_or(self.settings.slide_duration), + Item::Title(Title::Ending) => self.ending.unwrap_or(self.settings.slide_duration), + Item::Photo(_) => self.settings.slide_duration, + } + } + + /// How long the transition to `item` takes: the fade, never longer than the slide. + fn fade_to(&self, item: Item) -> f64 { + self.settings.fade_duration.min(self.duration(item)) + } + + /// When the next slide is due. + pub fn due(&self) -> f64 { + self.started + self.duration(self.current()) + } + + /// Seconds until the next slide is due (as of `now`; frozen while paused). + pub fn remaining(&self, now: f64) -> f64 { + (self.due() - self.clock(now)).max(0.0) + } + + /// The next `n` slides in playing order (titles included; into the next pass when repeating; + /// never the one up now twice). + pub fn upcoming(&self, n: usize) -> Vec<Item> { + let mut out = Vec::new(); + let mut order = self.order.clone(); + let (mut phase, mut pos, mut pass) = (self.phase, self.pos, self.pass); + let current = self.current(); + while out.len() < n { + let Some(next) = self.following(phase, pos, pass, &mut order) else { break }; + (phase, pos, pass) = next; + match self.item_at(phase, &order, pos) { + Some(it) if it != current && !out.contains(&it) => out.push(it), + _ => break, + } + } + out + } + + /// Move one slide on (`Advanced`), or end (`Ended`) past the last one without repeat. + fn advance(&mut self) -> Tick { + let mut order = std::mem::take(&mut self.order); + let next = self.following(self.phase, self.pos, self.pass, &mut order); + self.order = order; + match next { + Some((phase, pos, pass)) => { + (self.phase, self.pos, self.pass) = (phase, pos, pass); + Tick::Advanced(self.current()) + } + None => { + self.ended = true; + Tick::Ended + } + } + } + + /// Advance the timeline to `now`. `ready(slide)` says whether a slide can be shown: the + /// transition to the next slide waits until it is. + pub fn tick(&mut self, now: f64, ready: impl Fn(Item) -> bool) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = self.clock(now); + if self.from.is_some() && now >= self.started + self.fade_to(self.current()) { + self.from = None; + } + if self.paused_at.is_some() { + return Tick::Idle; + } + // a slide's time starts once it can be shown (the first one, one stepped to with ←/→) + if self.from.is_none() && !ready(self.current()) { + self.started = now; + return Tick::Idle; + } + if now < self.due() { + return Tick::Idle; + } + let Some(next) = self.upcoming(1).first().copied() else { + if self.settings.repeat { + // a single photo repeating: nothing to change to + self.started = now; + return Tick::Idle; + } + self.ended = true; + return Tick::Ended; + }; + if !ready(next) { + self.held = Some(next); + return Tick::Waiting(next); + } + self.held = None; + let old = self.current(); + let t = self.advance(); + if let Tick::Advanced(new) = t { + self.started = now; + self.from = (self.fade_to(new) > 0.0).then_some(old); + } + t + } + + /// What to draw at `now`. + pub fn frame(&self, now: f64) -> Frame { + let now = self.clock(now); + let current = self.current(); + let fade = self.fade_to(current); + let (from, progress) = match self.from { + Some(f) if fade > 0.0 && now < self.started + fade => (Some(f), ((now - self.started) / fade).clamp(0.0, 1.0) as f32), + _ => (None, 1.0), + }; + Frame { current, from, progress, fade_color: self.settings.fade_color } + } + + /// Pause or resume at `now`; resuming keeps the time the slide had left. Returns whether it + /// is paused now. + pub fn toggle_pause(&mut self, now: f64) -> bool { + let now = if now.is_finite() { now } else { self.started }; + match self.paused_at.take() { + Some(p) => { + // the slide (and a transition in progress) carries on where it stopped + self.started += (now - p).max(0.0); + } + None => self.paused_at = Some(now), + } + self.paused_at.is_some() + } + + /// Step `delta` slides (←/→) at `now`: a cut, and the new slide gets its full time (paused + /// playback stays paused). Past the last slide (the ending, when there is one) without repeat + /// playback ends; before the first it stays there (the intro, when there is one: in the first + /// pass ← from the first photo goes back to it). + pub fn step(&mut self, delta: i32, now: f64) -> Tick { + if self.ended { + return Tick::Ended; + } + let now = if now.is_finite() { now } else { self.started }; + // (more steps than slides would only go round again) + let slides = self.order.len().saturating_add(2).min(u32::MAX as usize) as u32; + let steps = delta.unsigned_abs().min(slides); + let mut t = Tick::Idle; + if delta > 0 { + for _ in 0..steps { + t = self.advance(); + if t == Tick::Ended { + return t; + } + } + } else { + for _ in 0..steps { + match self.phase { + // (the ending keeps the last photo's place) + Phase::Ending => self.phase = Phase::Photos, + Phase::Photos if self.pos > 0 => self.pos -= 1, + Phase::Photos if self.intro.is_some() && self.pass == 0 => self.phase = Phase::Intro, + Phase::Photos if self.settings.repeat => self.pos = self.order.len().saturating_sub(1), + Phase::Photos | Phase::Intro => {} + } + } + t = Tick::Advanced(self.current()); + } + self.from = None; + self.held = None; + self.started = now; + if self.paused_at.is_some() { + self.paused_at = Some(now); + } + t + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn ids(n: u64) -> Vec<PhotoId> { + (1..=n).map(PhotoId).collect() + } + + fn settings(slide: f64, fade: f64) -> PlaybackSettings { + PlaybackSettings { slide_duration: slide, fade_duration: fade, ..Default::default() } + } + + fn ph(n: u64) -> Item { + Item::Photo(PhotoId(n)) + } + + fn all_ready(_: Item) -> bool { + true + } + + #[test] + fn timeline_durations_and_fade_progress() { + let mut p = Playback::new(ids(3), Some(PhotoId(2)), settings(4.0, 1.0), 1, 10.0).unwrap(); + assert_eq!(p.current(), ph(2), "starts at the selected photo"); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(13.9, all_ready), Tick::Idle); + assert_eq!(p.frame(13.9).from, None); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(ph(3))); + let f = p.frame(14.25); + assert_eq!((f.current, f.from), (ph(3), Some(ph(2)))); + assert!((f.progress - 0.25).abs() < 1e-6, "{f:?}"); + assert!((p.frame(14.5).progress - 0.5).abs() < 1e-6); + assert_eq!(p.tick(15.0, all_ready), Tick::Idle); + assert_eq!(p.frame(15.0).from, None, "the fade is over after fadeDuration"); + // the slide period counts from its transition: next at 18 + assert_eq!(p.tick(17.99, all_ready), Tick::Idle); + assert_eq!(p.tick(18.0, all_ready), Tick::Advanced(ph(1)), "wraps to the photos before the start"); + assert_eq!(p.position(), (3, 3)); + } + + #[test] + fn fade_zero_is_a_cut_and_fade_color_is_reported() { + let mut p = Playback::new(ids(2), None, settings(2.0, 0.0), 1, 0.0).unwrap(); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(2.0).from, None); + let s = PlaybackSettings { fade_color: Some([255, 0, 0]), ..settings(2.0, 1.0) }; + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + p.tick(2.0, all_ready); + assert_eq!(p.frame(2.5).fade_color, Some([255, 0, 0])); + } + + #[test] + fn arrows_cut_immediately_and_restart_the_slide_time() { + let mut p = Playback::new(ids(4), None, settings(4.0, 2.0), 1, 0.0).unwrap(); + p.tick(4.0, all_ready); + assert!(p.frame(4.5).from.is_some(), "fading"); + assert_eq!(p.step(1, 4.5), Tick::Advanced(ph(3))); + assert_eq!(p.frame(4.5), Frame { current: ph(3), from: None, progress: 1.0, fade_color: None }, "a cut"); + assert_eq!(p.tick(8.4, all_ready), Tick::Idle, "full time from the step"); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(2))); + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(1))); + // repeat on: before the first is the last + assert_eq!(p.step(-1, 6.0), Tick::Advanced(ph(4))); + assert_eq!(p.remaining(6.0), 4.0); + } + + #[test] + fn random_order_is_deterministic_per_seed_and_covers_every_photo_once_per_pass() { + let photos = ids(20); + let run = |seed: u64| { + let s = PlaybackSettings { random_order: true, ..settings(1.0, 0.0) }; + let mut p = Playback::new(photos.clone(), Some(PhotoId(5)), s, seed, 0.0).unwrap(); + let mut seen = vec![p.current_photo().unwrap()]; + for k in 1..60 { + p.tick(k as f64, all_ready); + seen.push(p.current_photo().unwrap()); + } + seen + }; + let a = run(42); + assert_eq!(a, run(42), "same seed, same order"); + assert_ne!(a, run(43), "another seed, another order"); + assert_eq!(a[0], PhotoId(5), "starts at the selected photo"); + assert_ne!(a[..20].to_vec(), photos, "shuffled"); + for pass in a.chunks(20).take(2) { + let mut sorted = pass.to_vec(); + sorted.sort(); + assert_eq!(sorted, photos, "every photo once per pass"); + } + assert_ne!(a[..20], a[20..40], "a new order for the next pass"); + assert!(a.windows(2).all(|w| w[0] != w[1]), "never the same photo twice in a row"); + } + + #[test] + fn repeat_off_stops_after_the_last_slide() { + let s = PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(3), Some(PhotoId(2)), s.clone(), 1, 0.0).unwrap(); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(2), vec![], "nothing after the last"); + assert_eq!(p.tick(3.0, all_ready), Tick::Ended); + assert!(p.is_ended()); + // → past the last one ends it too + let mut p = Playback::new(ids(2), None, s, 1, 0.0).unwrap(); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(2))); + assert_eq!(p.step(1, 0.6), Tick::Ended); + // with repeat: it goes on + let mut p = Playback::new(ids(2), None, settings(1.0, 0.0), 1, 0.0).unwrap(); + for k in 1..10 { + assert!(matches!(p.tick(k as f64, all_ready), Tick::Advanced(_))); + } + } + + #[test] + fn pause_and_resume_keep_the_remaining_time() { + let mut p = Playback::new(ids(3), None, settings(4.0, 1.0), 1, 0.0).unwrap(); + assert!(p.toggle_pause(3.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle, "paused: nothing moves"); + assert_eq!(p.remaining(100.0), 1.0); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(100.9, all_ready), Tick::Idle); + assert_eq!(p.tick(101.0, all_ready), Tick::Advanced(ph(2)), "1 s left after resuming"); + // pausing mid-fade freezes the fade + p.toggle_pause(101.25); + assert!((p.frame(150.0).progress - 0.25).abs() < 1e-6); + p.toggle_pause(150.0); + assert!((p.frame(150.5).progress - 0.75).abs() < 1e-6); + } + + #[test] + fn a_transition_waits_for_the_next_slide_to_be_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 1.0), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![ph(2), ph(3)], "what to prepare"); + let not_two = |id: Item| id != ph(2); + assert_eq!(p.tick(2.0, not_two), Tick::Waiting(ph(2))); + assert_eq!(p.frame(3.0), Frame { current: ph(1), from: None, progress: 1.0, fade_color: None }, "held, not flashed"); + assert_eq!(p.waiting_for(), Some(ph(2))); + assert_eq!(p.tick(3.5, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.frame(3.5).from, Some(ph(1)), "the fade starts once it is ready"); + assert!((p.frame(4.0).progress - 0.5).abs() < 1e-6, "and runs its full length"); + } + + #[test] + fn a_slide_s_time_starts_once_it_is_ready() { + let mut p = Playback::new(ids(3), None, settings(2.0, 0.5), 1, 0.0).unwrap(); + let none = |_: Item| false; + assert_eq!(p.tick(1.0, none), Tick::Idle); + assert_eq!(p.tick(5.0, none), Tick::Idle, "the first slide isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 2.0); + assert_eq!(p.tick(6.0, all_ready), Tick::Idle); + assert_eq!(p.tick(7.0, all_ready), Tick::Advanced(ph(2)), "2 s from when it could be shown"); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = PlaybackSettings::default(); + for bad in [ + json!({"slideDuration": "fast"}), + json!({"fadeDuration": null}), + json!({"repeat": 1}), + json!({"fadeColor": "#12345"}), + json!({"fadeColor": "#ééé"}), + json!({"fadeColor": "#+f+f+f"}), + json!({"fadeColor": "-12345"}), + json!({"fadeColor": [300, 0, 0]}), + json!({"fadeColor": [1, 2]}), + json!({"speed": 2}), + json!([1, 2]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d.merged(&json!({"slideDuration": 1e300, "fadeDuration": -5})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MAX, 0.0)); + let s = d.merged(&json!({"slideDuration": 0.0, "fadeDuration": 9})).unwrap(); + assert_eq!((s.slide_duration, s.fade_duration), (SLIDE_MIN, SLIDE_MIN), "the fade fits in the slide"); + let s = d.merged(&json!({"fadeColor": "#FF8000"})).unwrap(); + assert_eq!(s.fade_color, Some([255, 128, 0])); + assert_eq!(s.to_json()["fadeColor"], "#ff8000"); + assert_eq!(d.merged(&json!({"fadeColor": [1, 2, 3]})).unwrap().fade_color, Some([1, 2, 3])); + // NaN / inf (from a hand-edited file or a caller in-process) take the defaults + let s = PlaybackSettings { slide_duration: f64::NAN, fade_duration: f64::INFINITY, ..d.clone() }.sanitized(); + assert_eq!((s.slide_duration, s.fade_duration), (d.slide_duration, d.fade_duration)); + // and the timeline never panics on hostile times + let mut p = Playback::new(ids(2), None, s, 0, f64::NAN).unwrap(); + p.tick(f64::NAN, all_ready); + p.tick(f64::INFINITY, all_ready); + p.toggle_pause(f64::NEG_INFINITY); + p.step(i32::MIN, f64::NAN); + let _ = p.frame(f64::NAN); + assert!(Playback::new(vec![], None, d, 0, 0.0).is_none()); + } + + #[test] + fn a_single_photo_repeats_without_changing() { + let mut p = Playback::new(ids(1), None, settings(1.0, 0.5), 1, 0.0).unwrap(); + assert_eq!(p.upcoming(2), vec![]); + assert_eq!(p.tick(5.0, all_ready), Tick::Idle); + assert_eq!(p.frame(5.0).from, None); + } + + fn titled(n: u64, s: PlaybackSettings, intro: Option<f64>, ending: Option<f64>) -> Playback { + Playback::new(ids(n), None, s, 1, 0.0).unwrap().with_titles(intro, ending) + } + + #[test] + fn titles_play_once_around_the_photos_for_their_own_durations() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(3.0)); + assert_eq!(p.current(), Item::INTRO, "the intro comes first"); + assert_eq!((p.position(), p.title(), p.current_photo()), ((0, 3), Some(Title::Intro), None), "titles aren't photos"); + assert_eq!(p.upcoming(2), vec![ph(1), ph(2)]); + assert_eq!(p.tick(1.99, all_ready), Tick::Idle); + assert_eq!(p.tick(2.0, all_ready), Tick::Advanced(ph(1)), "the intro's own 2 s"); + let f = p.frame(2.5); + assert_eq!((f.current, f.from), (ph(1), Some(Item::INTRO)), "the intro fades out like a slide"); + assert!((f.progress - 0.5).abs() < 1e-6); + assert_eq!(p.position(), (1, 3)); + assert_eq!(p.tick(6.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(10.0, all_ready), Tick::Advanced(ph(3))); + assert_eq!(p.upcoming(2), vec![Item::ENDING], "the ending is prepared like a slide"); + assert_eq!(p.tick(14.0, all_ready), Tick::Advanced(Item::ENDING)); + assert_eq!((p.position(), p.title()), ((3, 3), Some(Title::Ending))); + assert_eq!(p.frame(14.5).from, Some(ph(3)), "the ending fades in"); + assert_eq!(p.tick(16.99, all_ready), Tick::Idle); + assert_eq!(p.tick(17.0, all_ready), Tick::Ended, "the ending's own 3 s, then the show ends"); + // without titles nothing changes + let mut p = titled(2, PlaybackSettings { repeat: false, ..settings(1.0, 0.0) }, None, None); + assert_eq!(p.current(), ph(1)); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(2))); + assert_eq!(p.tick(2.0, all_ready), Tick::Ended); + } + + #[test] + fn a_fade_never_outlasts_a_short_title() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 2.0) }; + let mut p = titled(1, s, Some(0.5), Some(0.5)); + assert_eq!(p.tick(0.5, all_ready), Tick::Advanced(ph(1))); + assert!((p.frame(1.5).progress - 0.5).abs() < 1e-6, "the photo fades in over the full 2 s"); + assert_eq!(p.tick(4.5, all_ready), Tick::Advanced(Item::ENDING)); + assert!((p.frame(4.75).progress - 0.5).abs() < 1e-6, "into the 0.5 s ending: a 0.5 s fade"); + assert_eq!(p.frame(5.0).from, None); + assert_eq!(p.tick(5.0, all_ready), Tick::Ended); + } + + #[test] + fn with_repeat_the_intro_plays_once_and_the_ending_never() { + let mut p = titled(2, settings(1.0, 0.0), Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + for k in 1..12 { + p.tick(k as f64, all_ready); + seen.push(p.current()); + assert!(!p.upcoming(3).contains(&Item::ENDING), "no ending while repeating"); + } + assert_eq!(seen[0], Item::INTRO); + assert!(seen[1..].iter().all(|i| i.photo().is_some()), "passes follow each other without titles: {seen:?}"); + assert_eq!(seen[1..5], [ph(1), ph(2), ph(1), ph(2)]); + } + + #[test] + fn random_order_shuffles_the_photos_and_keeps_the_titles_in_place() { + for seed in [1, 2, 3] { + let s = PlaybackSettings { random_order: true, repeat: false, ..settings(1.0, 0.0) }; + let mut p = Playback::new(ids(10), None, s, seed, 0.0).unwrap().with_titles(Some(1.0), Some(1.0)); + let mut seen = vec![p.current()]; + let mut k = 1.0; + while p.tick(k, all_ready) != Tick::Ended { + seen.push(p.current()); + k += 1.0; + } + assert_eq!(seen.len(), 12, "{seen:?}"); + assert_eq!((seen[0], seen[11]), (Item::INTRO, Item::ENDING), "seed {seed}"); + let mut photos: Vec<PhotoId> = seen[1..11].iter().filter_map(|i| i.photo()).collect(); + assert_ne!(photos, ids(10), "shuffled"); + photos.sort(); + assert_eq!(photos, ids(10), "every photo once, between the titles"); + } + } + + #[test] + fn arrows_step_across_the_titles() { + let s = PlaybackSettings { repeat: false, ..settings(4.0, 1.0) }; + let mut p = titled(3, s, Some(2.0), Some(2.0)); + assert_eq!(p.step(-1, 0.5), Tick::Advanced(Item::INTRO), "← on the intro stays there"); + assert_eq!(p.step(1, 0.5), Tick::Advanced(ph(1))); + assert_eq!(p.step(-1, 0.6), Tick::Advanced(Item::INTRO), "← from the first photo: back to the intro"); + assert_eq!(p.remaining(0.6), 2.0, "with its full time"); + assert_eq!(p.step(3, 1.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.position(), (3, 3)); + assert_eq!(p.step(-1, 1.0), Tick::Advanced(ph(3)), "← from the ending: the last photo"); + assert_eq!(p.step(1, 1.0), Tick::Advanced(Item::ENDING)); + assert_eq!(p.step(1, 1.0), Tick::Ended, "→ past the ending ends the show"); + assert_eq!(p.step(1, 2.0), Tick::Ended); + // repeating: ← from the first photo of a later pass wraps to the last, not the intro + let mut p = titled(3, settings(4.0, 0.0), Some(2.0), None); + assert_eq!(p.step(4, 0.0), Tick::Advanced(ph(1)), "intro, 1, 2, 3, then 1 again"); + assert_eq!(p.step(-1, 0.0), Tick::Advanced(ph(3))); + assert_eq!(p.step(i32::MAX, 0.0), Tick::Advanced(p.current())); + assert_eq!(p.step(i32::MIN, f64::NAN), Tick::Advanced(p.current())); + } + + #[test] + fn a_title_pauses_and_waits_like_a_slide() { + let mut p = titled(2, settings(4.0, 1.0), Some(3.0), None); + let not_intro = |i: Item| i != Item::INTRO; + assert_eq!(p.tick(5.0, not_intro), Tick::Idle, "the intro isn't rendered yet: its clock waits"); + assert_eq!(p.remaining(5.0), 3.0); + assert!(p.toggle_pause(6.0)); + assert_eq!(p.tick(100.0, all_ready), Tick::Idle); + assert_eq!(p.remaining(100.0), 2.0, "paused on the intro"); + assert!(!p.toggle_pause(100.0)); + assert_eq!(p.tick(101.9, all_ready), Tick::Idle); + let not_one = |i: Item| i != ph(1); + assert_eq!(p.tick(102.0, not_one), Tick::Waiting(ph(1)), "the first photo isn't ready: the intro is held"); + assert_eq!(p.frame(102.5).current, Item::INTRO); + assert_eq!(p.tick(103.0, all_ready), Tick::Advanced(ph(1))); + } + + #[test] + fn hostile_title_durations_are_clamped() { + let p = titled(2, settings(4.0, 1.0), Some(f64::NAN), Some(1e300)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (4.0, SLIDE_MAX)); + let p = titled(2, settings(4.0, 1.0), Some(-1.0), Some(f64::NEG_INFINITY)); + assert_eq!((p.duration(Item::INTRO), p.duration(Item::ENDING)), (SLIDE_MIN, 4.0)); + // a single photo between titles, repeating: the photo holds after the intro + let mut p = titled(1, settings(1.0, 0.0), Some(1.0), Some(1.0)); + assert_eq!(p.upcoming(3), vec![ph(1)]); + assert_eq!(p.tick(1.0, all_ready), Tick::Advanced(ph(1))); + assert_eq!(p.upcoming(3), vec![]); + assert_eq!(p.tick(9.0, all_ready), Tick::Idle); + assert_eq!(Title::parse(" Ending "), Some(Title::Ending)); + assert_eq!(Title::parse("credits"), None); + } +} diff --git a/crates/engine/src/slideshow_export.rs b/crates/engine/src/slideshow_export.rs new file mode 100644 index 000000000..e91c1ed34 --- /dev/null +++ b/crates/engine/src/slideshow_export.rs @@ -0,0 +1,760 @@ +//! Slideshow export (Lightroom Classic's Export PDF / Export JPEG / Export Video): the photos in +//! view as their composed slides (layout, overlays: [`crate::slide`]), between the intro and +//! ending screens that are on ([`crate::slide_title`]), written as +//! +//! - **PDF**: one page per slide, the page the slide's size in points (1 px = 1 pt), its picture a +//! JPEG; each page carries the slide duration and a fade transition for viewers' presentation +//! mode, and "full screen" asks the viewer to open in it (`/PageMode /FullScreen`); +//! - **JPEG sequence**: `<name>-001.jpg`, `<name>-002.jpg`… in a folder; +//! - **video**: AV1 in MP4 or WebM ([`lightcraft_codecs::video`], our own muxers), the timeline +//! sampled at 24/25/30/60 fps: each slide held for its duration, fades rendered as blended +//! frames (crossfade, or through the fade colour). No audio track. +//! +//! The slides play once in view order (random order and repeat are playback choices, not +//! export ones). The slide aspect comes from Layout's aspect preview; "screen" exports 16:9. +//! +//! [`prepare`] reads the session (cheap: render jobs, no pixels) and [`Prepared::run`] does the +//! work on any thread, with progress and a cancel flag. Files are written as `<file>.part` and +//! renamed when everything is written, so a cancelled or failed export leaves nothing behind. A +//! slide whose photo can't be rendered (file gone, unreadable) is skipped with a warning. + +use std::io::{BufWriter, Write}; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +use lightcraft_catalog::PhotoId; +use lightcraft_codecs::video::{Av1Encoder, Container, VideoConfig, VideoWriter}; +use lightcraft_pipeline::{OutputDepth, OutputSpace}; +use lightcraft_raster::Rgba8; +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value, json}; + +use crate::media::RenderJob; +use crate::originals::OriginalGuard; +use crate::slide_title::TitleCompose; +use crate::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; +use crate::{Session, export::ExportFormat, export::ExportOptions}; + +/// The longest video exported (seconds): 2 hours. +pub const MAX_SECONDS: f64 = 2.0 * 3600.0; +/// The most slides an export takes. +pub const MAX_SLIDES: usize = 10_000; +/// Long side of PDF pages and JPEGs, in pixels. +pub const SIZE_MIN: usize = 64; +pub const SIZE_MAX: usize = lightcraft_slideshow::MAX_SIDE; +/// Video frame height, in pixels. +pub const VIDEO_MIN: usize = 64; +pub const VIDEO_MAX: usize = 2160; +/// Frame rates offered. +pub const FPS: [u32; 4] = [24, 25, 30, 60]; + +/// What an export writes. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum Format { + Pdf, + Jpeg, + Video, +} + +impl Format { + pub fn id(self) -> &'static str { + match self { + Format::Pdf => "pdf", + Format::Jpeg => "jpeg", + Format::Video => "video", + } + } +} + +/// The export's settings (the dialog's state, and `slideshow.export`'s params without `path`). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct ExportSettings { + pub format: Format, + /// PDF / JPEG: the long side in pixels; video: the frame height (720 = 720p). + pub size: usize, + /// JPEG quality (PDF pages, JPEG files) or video quality, 1–100. + pub quality: u8, + /// Video frames per second. + pub fps: u32, + /// Video container: "mp4" or "webm". + pub container: String, + /// PDF: ask the viewer to open in full screen. + pub full_screen: bool, + /// Video: rav1e speed preset, 1 (slow, smaller) – 10 (fast). + pub speed: u8, +} + +impl Default for ExportSettings { + fn default() -> Self { + ExportSettings { format: Format::Pdf, size: 1920, quality: 90, fps: 30, container: "mp4".into(), full_screen: true, speed: 10 } + } +} + +impl ExportSettings { + /// The defaults for `format` (sizes: PDF/JPEG 1920 px, video 720p; video quality 75). + pub fn for_format(format: Format) -> Self { + match format { + Format::Video => ExportSettings { format, size: 720, quality: 75, ..Default::default() }, + _ => ExportSettings { format, ..Default::default() }, + } + } + + /// The file extension the export's path takes (none: a JPEG sequence goes in a folder). + pub fn extension(&self) -> Option<&'static str> { + match self.format { + Format::Pdf => Some("pdf"), + Format::Jpeg => None, + Format::Video => Some(if self.container == "webm" { "webm" } else { "mp4" }), + } + } +} + +/// `slideshow.export`'s params, checked: settings, path, file name prefix and photos. +#[derive(Clone, Debug, PartialEq)] +pub struct Request { + pub settings: ExportSettings, + pub path: String, + /// JPEG sequence: the files' name before `-001` (default: the folder's name). + pub name: Option<String>, + pub ids: Option<Vec<PhotoId>>, +} + +const KEYS: &str = "format, path, size, quality, fps, container, fullScreen, speed, name, ids, wait"; + +fn uint(k: &str, v: &Value, lo: u64, hi: u64) -> Result<u64, String> { + match v.as_u64() { + Some(n) if (lo..=hi).contains(&n) => Ok(n), + _ => Err(format!("`{k}` must be a whole number from {lo} to {hi}, not {v}")), + } +} + +impl Request { + pub fn parse(p: &Value) -> Result<Request, String> { + let o: &Map<String, Value> = p.as_object().ok_or("params must be an object")?; + if let Some(k) = o.keys().find(|k| !KEYS.split(", ").any(|x| x == k.as_str())) { + return Err(format!("unknown key `{k}` (known: {KEYS})")); + } + let format = match o.get("format").and_then(Value::as_str).map(str::trim) { + Some("pdf") => Format::Pdf, + Some("jpeg" | "jpg") => Format::Jpeg, + Some("video") => Format::Video, + _ => return Err(format!("`format` must be \"pdf\", \"jpeg\" or \"video\", not {}", o.get("format").unwrap_or(&Value::Null))), + }; + let mut s = ExportSettings::for_format(format); + let path = o.get("path").and_then(Value::as_str).map(str::trim).filter(|x| !x.is_empty()).ok_or("missing `path`")?.to_string(); + if let Some(v) = o.get("size").filter(|v| !v.is_null()) { + // "720p" / "1080p" for video + let v = match v.as_str().map(|x| x.trim().trim_end_matches(['p', 'P'])) { + Some(x) => x.parse::<u64>().map(Value::from).map_err(|_| format!("`size` must be a number of pixels, not {v}"))?, + None => v.clone(), + }; + s.size = match format { + Format::Video => uint("size", &v, VIDEO_MIN as u64, VIDEO_MAX as u64)? as usize, + _ => uint("size", &v, SIZE_MIN as u64, SIZE_MAX as u64)? as usize, + }; + } + if let Some(v) = o.get("quality").filter(|v| !v.is_null()) { + s.quality = uint("quality", v, 1, 100)? as u8; + } + if let Some(v) = o.get("fps").filter(|v| !v.is_null()) { + let f = + v.as_u64().filter(|f| FPS.iter().any(|x| u64::from(*x) == *f)).ok_or_else(|| format!("`fps` must be 24, 25, 30 or 60, not {v}"))?; + s.fps = f as u32; + } + if let Some(v) = o.get("container").filter(|v| !v.is_null()) { + let c = v.as_str().and_then(Container::parse).ok_or_else(|| format!("`container` must be \"mp4\" or \"webm\", not {v}"))?; + s.container = c.id().into(); + } + if let Some(v) = o.get("fullScreen").filter(|v| !v.is_null()) { + s.full_screen = v.as_bool().ok_or_else(|| format!("`fullScreen` must be true or false, not {v}"))?; + } + if let Some(v) = o.get("speed").filter(|v| !v.is_null()) { + s.speed = uint("speed", v, 1, 10)? as u8; + } + if let Some(v) = o.get("wait").filter(|v| !v.is_null() && !v.is_boolean()) { + return Err(format!("`wait` must be true or false, not {v}")); + } + let name = match o.get("name") { + None | Some(Value::Null) => None, + Some(Value::String(n)) => { + let n = n.trim(); + if n.is_empty() + || n.chars().count() > 120 + || n.chars().any(|c| c.is_control() || matches!(c, '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|')) + { + return Err(format!("`name` must be 1–120 characters without path separators or reserved characters, not {n:?}")); + } + Some(n.to_string()) + } + Some(v) => return Err(format!("`name` must be text, not {v}")), + }; + if name.is_some() && format != Format::Jpeg { + return Err("`name` is for the JPEG sequence only".into()); + } + let ids = match o.get("ids") { + None | Some(Value::Null) => None, + Some(Value::Array(a)) => { + let ids: Option<Vec<PhotoId>> = a.iter().map(|v| v.as_u64().map(PhotoId)).collect(); + Some(ids.ok_or("`ids` must be an array of photo ids")?) + } + Some(v) => return Err(format!("`ids` must be an array of photo ids, not {v}")), + }; + Ok(Request { settings: s, path, name, ids }) + } +} + +/// What a slide shows. +enum Source { + Photo { name: String, job: Box<RenderJob> }, + Title { title: Title, compose: Arc<TitleCompose> }, +} + +struct Slide { + source: Source, + /// Seconds it stays up, its fade in included. + duration: f64, + /// Seconds the transition into it takes (none for the first). + fade: f64, +} + +impl Slide { + fn label(&self) -> String { + match &self.source { + Source::Photo { name, .. } => name.clone(), + Source::Title { title, .. } => title.id().to_string(), + } + } + + /// The slide's pixels, `w × h`. + fn render(self, w: usize, h: usize) -> Result<Rgba8, String> { + let img = match self.source { + Source::Photo { job, .. } => job.run().rendered?.image, + Source::Title { compose, .. } => compose.compose(), + }; + Ok(fit(img, w, h)) + } +} + +/// `img` at exactly `w × h`: as it is when it already is (the composer's output), else centred +/// on black (a slide bigger than the composer draws, or a surprise from a renderer). +fn fit(img: Rgba8, w: usize, h: usize) -> Rgba8 { + if img.width == w && img.height == h && img.data.len() == w * h { + return img; + } + let mut out = Rgba8::filled(w, h, [0, 0, 0, 255]); + let (cw, ch) = (img.width.min(w), img.height.min(h)); + let (dx, dy) = ((w - cw) / 2, (h - ch) / 2); + let (sx, sy) = ((img.width - cw) / 2, (img.height - ch) / 2); + for y in 0..ch { + let (Some(src), Some(dst)) = + (img.data.get((sy + y) * img.width + sx..(sy + y) * img.width + sx + cw), out.data.get_mut((dy + y) * w + dx..(dy + y) * w + dx + cw)) + else { + continue; + }; + dst.copy_from_slice(src); + } + out +} + +/// An export ready to run: everything it needs from the session. +pub struct Prepared { + pub request: Request, + slides: Vec<Slide>, + pub width: usize, + pub height: usize, + fade_color: Option<[u8; 3]>, + guard: OriginalGuard, + /// Problems found while preparing (photos not in the library). + pub warnings: Vec<String>, +} + +/// The size of a slide whose long side is `long` at `ratio` (width / height). +fn sized(ratio: f64, long: usize) -> (usize, usize) { + let short = |l: usize, r: f64| ((l as f64 / r).round() as usize).max(1); + if ratio >= 1.0 { (long, short(long, ratio)) } else { (short(long, 1.0 / ratio), long) } +} + +/// Read the session: the slides, their render jobs and durations, the output size. +pub fn prepare(s: &mut Session, p: &Value) -> Result<Prepared, String> { + let request = Request::parse(p)?; + let settings = &request.settings; + let ratio = s.slide_layout.aspect.ratio(16.0 / 9.0); + let (width, height) = match settings.format { + Format::Video => { + let h = settings.size & !1; + ((((h as f64 * ratio).round() as usize) & !1).max(16), h) + } + _ => sized(ratio, settings.size), + }; + let ids = match &request.ids { + Some(ids) => ids.clone(), + None => s.visible_cloned(), + }; + if ids.is_empty() { + return Err("no photos in view to export".into()); + } + if ids.len() > MAX_SLIDES { + return Err(format!("a slideshow export takes at most {MAX_SLIDES} photos; this view has {}", ids.len())); + } + let play = s.slideshow.clone().sanitized(); + let (intro, ending) = s.slide_titles.durations(play.slide_duration); + let clamp = |d: f64| if d.is_finite() { d.clamp(SLIDE_MIN, SLIDE_MAX) } else { play.slide_duration }; + let layout = s.slide_layout.clone().sanitized(); + let mut warnings = Vec::new(); + let mut slides = Vec::with_capacity(ids.len() + 2); + let title = |s: &Session, t: Title, d: f64| Slide { + source: Source::Title { title: t, compose: s.title_compose(t, width, height) }, + duration: clamp(d), + fade: 0.0, + }; + if let Some(d) = intro { + slides.push(title(s, Title::Intro, d)); + } + for id in ids { + let Some(photo) = s.catalog.photo(id).cloned() else { + warnings.push(format!("photo {} is not in the library; skipped", id.0)); + continue; + }; + let aspect = s.slide_photo_aspect(id).unwrap_or(1.5); + let (pw, ph) = lightcraft_slideshow::photo_size(&layout, aspect, width, height); + let job = match s.export_job(id, pw.max(8), ph.max(8), OutputSpace::Srgb, OutputDepth::U8) { + Ok(job) => job, + Err(e) => { + warnings.push(format!("{}: {e}; skipped", photo.file_name)); + continue; + } + }; + let compose = s.slide_compose(&layout, width, height, Some(id)); + slides.push(Slide { + source: Source::Photo { name: photo.file_name.clone(), job: Box::new(job.with_slide(compose)) }, + duration: play.slide_duration, + fade: 0.0, + }); + } + if !slides.iter().any(|x| matches!(x.source, Source::Photo { .. })) { + return Err("none of the photos can be exported".into()); + } + if let Some(d) = ending { + slides.push(title(s, Title::Ending, d)); + } + // the transition into each slide after the first: the fade, never longer than the slide + for x in slides.iter_mut().skip(1) { + x.fade = play.fade_duration.min(x.duration).max(0.0); + } + if settings.format == Format::Video { + let total: f64 = slides.iter().map(|x| x.duration).sum(); + if total > MAX_SECONDS { + return Err(format!( + "the slideshow lasts {} min; video export is limited to {} min (shorten the slides or export fewer photos)", + (total / 60.0).ceil(), + MAX_SECONDS / 60.0 + )); + } + if !lightcraft_codecs::video::AV1_AVAILABLE { + return Err("video export (AV1) is not available in this build".into()); + } + } + s.check_write_target(&request.path)?; + Ok(Prepared { request, slides, width, height, fade_color: play.fade_color, guard: s.original_guard(), warnings }) +} + +/// What an export wrote. +#[derive(Clone, Debug, PartialEq)] +pub struct Outcome { + pub json: Value, +} + +/// Progress: (done, total, the slide being worked on). `total` is slides (PDF, JPEG) or frames +/// at the nominal rate (video). +pub type Progress<'a> = &'a dyn Fn(u64, u64, &str); + +const CANCELLED: &str = "Slideshow export cancelled"; + +/// A file written as `<path>.part` and renamed over `path` when complete; dropped before that, +/// the part file is removed. +struct PartFile { + part: PathBuf, + target: PathBuf, + done: bool, +} + +impl PartFile { + fn new(target: &Path) -> Result<(PartFile, std::fs::File), String> { + if let Some(dir) = target.parent().filter(|d| !d.as_os_str().is_empty()) { + std::fs::create_dir_all(dir).map_err(|e| format!("{}: {e}", dir.display()))?; + } + let mut name = target.file_name().ok_or_else(|| format!("{}: not a file name", target.display()))?.to_os_string(); + name.push(".part"); + let part = target.with_file_name(name); + let f = std::fs::File::create(&part).map_err(|e| format!("{}: {e}", part.display()))?; + Ok((PartFile { part, target: target.to_path_buf(), done: false }, f)) + } + + fn commit(mut self, guard: &OriginalGuard) -> Result<(), String> { + guard.check(&self.target)?; + std::fs::rename(&self.part, &self.target).map_err(|e| format!("{}: {e}", self.target.display()))?; + self.done = true; + Ok(()) + } +} + +impl Drop for PartFile { + fn drop(&mut self) { + if !self.done { + let _ = std::fs::remove_file(&self.part); + } + } +} + +/// Blend `a` → `b` at `k` (0 = a, 1 = b), or through `colour` (out to it, then in from it). +fn blend(a: &Rgba8, b: &Rgba8, k: f32, colour: Option<[u8; 3]>) -> Vec<[u8; 4]> { + let mix = |x: u8, y: u8, t: f32| (f32::from(x) + (f32::from(y) - f32::from(x)) * t).round().clamp(0.0, 255.0) as u8; + match colour { + Some(c) => { + let (img, veil) = if k < 0.5 { (a, k * 2.0) } else { (b, 2.0 - k * 2.0) }; + img.data.iter().map(|p| [mix(p[0], c[0], veil), mix(p[1], c[1], veil), mix(p[2], c[2], veil), 255]).collect() + } + None => a.data.iter().zip(&b.data).map(|(p, q)| [mix(p[0], q[0], k), mix(p[1], q[1], k), mix(p[2], q[2], k), 255]).collect(), + } +} + +impl Prepared { + /// Slides to export (titles included). + pub fn len(&self) -> usize { + self.slides.len() + } + + pub fn is_empty(&self) -> bool { + self.slides.is_empty() + } + + /// Seconds the slideshow lasts. + pub fn seconds(&self) -> f64 { + self.slides.iter().map(|x| x.duration).sum() + } + + /// Units of progress: slides, or frames at the nominal rate for video. + pub fn total(&self) -> u64 { + match self.request.settings.format { + Format::Video => (self.seconds() * f64::from(self.request.settings.fps)).round() as u64, + _ => self.slides.len() as u64, + } + } + + /// Write the export. `cancel` is checked between slides and frames. + pub fn run(self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + match self.request.settings.format { + Format::Pdf => self.run_pdf(progress, cancel), + Format::Jpeg => self.run_jpeg(progress, cancel), + Format::Video => self.run_video(progress, cancel), + } + } + + fn jpeg(&self, img: &Rgba8) -> Result<Vec<u8>, String> { + crate::export::encode_image(img, &ExportOptions { format: ExportFormat::Jpeg, quality: self.request.settings.quality, ..Default::default() }) + } + + fn summary(&self, format: Format, slides: usize, skipped: usize, warnings: Vec<String>) -> Value { + json!({ + "format": format.id(), + "path": self.request.path, + "width": self.width, + "height": self.height, + "slides": slides, + "skipped": skipped, + "warnings": warnings, + }) + } + + fn run_pdf(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let target = PathBuf::from(&self.request.path); + let (part, file) = PartFile::new(&target)?; + let mut pdf = crate::pdf::Pdf::new(BufWriter::new(file))?; + let (w, h) = (self.width, self.height); + let total = self.slides.len() as u64; + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let slides = std::mem::take(&mut self.slides); + for (i, slide) in slides.into_iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(i as u64, total, &label); + let (duration, fade) = (slide.duration, slide.fade); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let jpg = self.jpeg(&img)?; + // presentation mode: how long the page stays, and the fade into it + let mut extra = format!(" /Dur {duration:.2}"); + if fade > 0.0 { + extra += &format!(" /Trans << /S /Fade /D {fade:.2} >>"); + } + pdf.image_page(&jpg, w, h, (w as f64, h as f64), &extra)?; + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let pages = pdf.pages(); + if pages == 0 { + return Err("no slide could be rendered".into()); + } + let bytes = pdf.len(); + let catalog = if self.request.settings.full_screen { " /PageMode /FullScreen" } else { "" }; + pdf.finish(catalog)?.into_inner().map_err(|e| format!("PDF: {}", e.error()))?.sync_all().map_err(|e| format!("PDF: {e}"))?; + part.commit(&self.guard)?; + progress(total, total, ""); + let mut out = self.summary(Format::Pdf, pages, skipped, warnings); + out["pages"] = json!(pages); + out["bytes"] = json!(bytes); + out["fullScreen"] = json!(self.request.settings.full_screen); + Ok(Outcome { json: out }) + } + + fn run_jpeg(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let dir = PathBuf::from(&self.request.path); + let prefix = self + .request + .name + .clone() + .or_else(|| dir.file_name().map(|n| n.to_string_lossy().trim().to_string()).filter(|n| !n.is_empty())) + .unwrap_or_else(|| "Slideshow".into()); + let digits = self.slides.len().to_string().len().max(3); + let (w, h) = (self.width, self.height); + let total = self.slides.len() as u64; + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let mut parts = Vec::new(); + let mut bytes = 0usize; + let slides = std::mem::take(&mut self.slides); + for (i, slide) in slides.into_iter().enumerate() { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(i as u64, total, &label); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let jpg = self.jpeg(&img)?; + let target = dir.join(format!("{prefix}-{:0digits$}.jpg", parts.len() + 1)); + self.guard.check(&target)?; + let (part, mut f) = PartFile::new(&target)?; + f.write_all(&jpg).map_err(|e| format!("{}: {e}", part.part.display()))?; + f.sync_all().map_err(|e| format!("{}: {e}", part.part.display()))?; + bytes = bytes.saturating_add(jpg.len()); + parts.push(part); + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + if parts.is_empty() { + return Err("no slide could be rendered".into()); + } + let files: Vec<String> = parts.iter().map(|p| p.target.to_string_lossy().to_string()).collect(); + // all written: now they take their names (a failure here removes the rest) + for part in parts { + part.commit(&self.guard)?; + } + progress(total, total, ""); + let mut out = self.summary(Format::Jpeg, files.len(), skipped, warnings); + out["files"] = json!(files); + out["bytes"] = json!(bytes); + Ok(Outcome { json: out }) + } + + fn run_video(mut self, progress: Progress, cancel: &AtomicBool) -> Result<Outcome, String> { + let st = self.request.settings.clone(); + let container = Container::parse(&st.container).unwrap_or(Container::Mp4); + let (w, h) = (self.width, self.height); + let fps = st.fps; + let mut enc = Av1Encoder::new(&VideoConfig { width: w as u32, height: h as u32, fps, quality: st.quality, speed: st.speed }) + .map_err(|e| e.to_string())?; + let target = PathBuf::from(&self.request.path); + let (part, file) = PartFile::new(&target)?; + let mut mux = VideoWriter::new(BufWriter::new(file), container, w as u32, h as u32, fps).map_err(|e| e.to_string())?; + let total = self.total(); + let mut warnings = std::mem::take(&mut self.warnings); + let mut skipped = 0; + let mut slides_done = 0; + let mut samples = 0u64; + let mut keys = 0u64; + // frames emitted so far, and the slideshow time (seconds) of the slides kept + let mut frames = 0u64; + let mut clock = 0.0f64; + let mut prev: Option<Rgba8> = None; + let mut emit = |mux: &mut VideoWriter<BufWriter<std::fs::File>>, + enc: &mut Av1Encoder, + pixels: &[[u8; 4]], + periods: u64, + key: bool| + -> Result<(), String> { + let out = enc.push(pixels, periods, key).map_err(|e| e.to_string())?; + for s in out { + if let Some(c) = enc.config_record() { + mux.set_config(c); + } + keys += u64::from(s.key); + samples += 1; + mux.add(&s).map_err(|e| e.to_string())?; + } + Ok(()) + }; + let fade_color = self.fade_color; + let slides = std::mem::take(&mut self.slides); + for slide in slides { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let label = slide.label(); + progress(frames, total, &label); + let (duration, fade) = (slide.duration, slide.fade); + let img = match slide.render(w, h) { + Ok(img) => img, + Err(e) => { + warnings.push(format!("{label}: {e}; skipped")); + skipped += 1; + continue; + } + }; + let start = (clock * f64::from(fps)).round() as u64; + clock += duration; + let end = ((clock * f64::from(fps)).round() as u64).max(start + 1); + let n = end - start; + let fade_frames = match &prev { + Some(_) => ((fade * f64::from(fps)).round() as u64).min(n), + None => 0, + }; + if let Some(a) = &prev { + for j in 0..fade_frames { + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + let k = (j + 1) as f32 / (fade_frames + 1) as f32; + emit(&mut mux, &mut enc, &blend(a, &img, k, fade_color), 1, false)?; + frames += 1; + progress(frames, total, &label); + } + } + if n > fade_frames { + // a held slide is a key frame: clean of the slide before it, and a place to seek to + emit(&mut mux, &mut enc, &img.data, n - fade_frames, true)?; + frames += n - fade_frames; + } + slides_done += 1; + prev = Some(img); + } + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + if slides_done == 0 { + return Err("no slide could be rendered".into()); + } + progress(frames, total, "AV1"); + for s in enc.finish().map_err(|e| e.to_string())? { + if let Some(c) = enc.config_record() { + mux.set_config(c); + } + keys += u64::from(s.key); + samples += 1; + mux.add(&s).map_err(|e| e.to_string())?; + } + let periods = mux.periods(); + let file = mux.finish().map_err(|e| e.to_string())?; + let file = file.into_inner().map_err(|e| format!("video: {}", e.error()))?; + file.sync_all().map_err(|e| format!("video: {e}"))?; + let bytes = file.metadata().map(|m| m.len()).unwrap_or(0); + drop(file); + if cancel.load(Ordering::Relaxed) { + return Err(CANCELLED.into()); + } + part.commit(&self.guard)?; + progress(total, total, ""); + let mut out = self.summary(Format::Video, slides_done, skipped, warnings); + out["container"] = json!(container.id()); + out["codec"] = json!("av1"); + out["fps"] = json!(fps); + out["frames"] = json!(periods); + out["samples"] = json!(samples); + out["keyFrames"] = json!(keys); + out["seconds"] = json!(periods as f64 / f64::from(fps)); + out["bytes"] = json!(bytes); + out["audio"] = json!(false); + Ok(Outcome { json: out }) + } +} + +/// A slideshow export running in the background (`slideshow.export`; `slideshow.exportStatus`). +#[derive(Debug, Default)] +pub struct ExportRun { + /// Its row in the activity stack (`activity.cancel {id}` stops it). + pub task_id: u64, + pub format: String, + pub path: String, + pub total: std::sync::atomic::AtomicU64, + pub done: std::sync::atomic::AtomicU64, + pub cancel: Arc<AtomicBool>, + pub finished: AtomicBool, + /// The front end has told the user how it ended. + pub reported: AtomicBool, + pub result: std::sync::Mutex<Option<Result<Value, String>>>, +} + +impl ExportRun { + pub fn running(&self) -> bool { + !self.finished.load(Ordering::Relaxed) + } + + pub fn result(&self) -> Option<Result<Value, String>> { + self.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clone() + } + + pub fn json(&self) -> Value { + let mut out = json!({ + "taskId": self.task_id, + "format": self.format, + "path": self.path, + "running": self.running(), + "done": self.done.load(Ordering::Relaxed), + "total": self.total.load(Ordering::Relaxed), + "cancelled": self.cancel.load(Ordering::Relaxed), + }); + match self.result() { + Some(Ok(v)) => out["result"] = v, + Some(Err(e)) => out["error"] = json!(e), + None => {} + } + out + } +} + +/// Run `prepared` to the end, recording progress and the outcome in `run` (and the row). +pub fn run_to_end(prepared: Prepared, run: &ExportRun, task: &crate::activity::TaskGuard) { + let handle = task.handle(); + let progress = |done: u64, total: u64, detail: &str| { + run.done.store(done, Ordering::Relaxed); + run.total.store(total, Ordering::Relaxed); + handle.progress(done, total); + handle.detail(detail); + }; + // a panic in a renderer or the encoder is an error, never a crash (the part file goes with it) + let r = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| prepared.run(&progress, &run.cancel))) + .unwrap_or_else(|_| Err("slideshow export failed unexpectedly".into())) + .map(|o| o.json); + *run.result.lock().unwrap_or_else(std::sync::PoisonError::into_inner) = Some(r); + run.finished.store(true, Ordering::Relaxed); +} diff --git a/crates/engine/src/slideshow_templates/cinema-wide.json b/crates/engine/src/slideshow_templates/cinema-wide.json new file mode 100644 index 000000000..d8660d604 --- /dev/null +++ b/crates/engine/src/slideshow_templates/cinema-wide.json @@ -0,0 +1,19 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Cinema Wide", + "description": "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.", + "playback": {"slideDuration": 6, "fadeDuration": 1.5, "fadeColor": "#000000", "randomOrder": false, "repeat": false}, + "layout": { + "aspect": "16:9", "linkMargins": false, "marginLeft": 0, "marginRight": 0, "marginTop": 0.09, "marginBottom": 0.09, + "zoomToFill": true, "backdropColor": "#000000" + }, + "overlays": { + "texts": [ + {"text": "{title}", "size": 0.03, "color": "#f0ece4", "opacity": 0.9, "anchor": "bottomLeft", "offsetX": 0.04, "offsetY": -0.025, "relativeTo": "slide", "shadow": false} + ] + }, + "titles": { + "ending": {"enabled": true, "color": "#000000", "content": "text", "text": "The End", "overrideColor": true, "textColor": "#e8e2d6", "scale": 0.07, "duration": 3} + } +} diff --git a/crates/engine/src/slideshow_templates/edge-to-edge.json b/crates/engine/src/slideshow_templates/edge-to-edge.json new file mode 100644 index 000000000..ba2943342 --- /dev/null +++ b/crates/engine/src/slideshow_templates/edge-to-edge.json @@ -0,0 +1,10 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Edge to Edge", + "description": "Every photo cropped to fill the whole screen, with slow crossfades.", + "playback": {"slideDuration": 6, "fadeDuration": 2, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": {"zoomToFill": true}, + "overlays": {}, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/gallery-wall.json b/crates/engine/src/slideshow_templates/gallery-wall.json new file mode 100644 index 000000000..7703795a5 --- /dev/null +++ b/crates/engine/src/slideshow_templates/gallery-wall.json @@ -0,0 +1,20 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Gallery Wall", + "description": "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.", + "playback": {"slideDuration": 5, "fadeDuration": 1.2, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": { + "linkMargins": false, "marginLeft": 0.1, "marginRight": 0.1, "marginTop": 0.08, "marginBottom": 0.16, + "strokeBorder": true, "strokeColor": "#ffffff", "strokeWidth": 0.012, + "castShadow": true, "shadowOpacity": 0.35, "shadowOffset": 0.012, "shadowRadius": 0.02, "shadowAngle": -60, + "backdropColor": "#dcd9d4" + }, + "overlays": { + "ratingStars": {"enabled": true, "color": "#55524d", "size": 0.03, "anchor": "bottomRight", "offsetX": 0, "offsetY": 0.075, "relativeTo": "photo"}, + "texts": [ + {"text": "{caption}", "size": 0.032, "color": "#3c3a36", "opacity": 1, "anchor": "bottomLeft", "offsetX": 0, "offsetY": 0.08, "relativeTo": "photo", "shadow": false} + ] + }, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/plain-black.json b/crates/engine/src/slideshow_templates/plain-black.json new file mode 100644 index 000000000..83085cedb --- /dev/null +++ b/crates/engine/src/slideshow_templates/plain-black.json @@ -0,0 +1,10 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Plain Black", + "description": "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.", + "playback": {"slideDuration": 4, "fadeDuration": 1, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": {}, + "overlays": {}, + "titles": {} +} diff --git a/crates/engine/src/slideshow_templates/shooting-data.json b/crates/engine/src/slideshow_templates/shooting-data.json new file mode 100644 index 000000000..4e95fcddf --- /dev/null +++ b/crates/engine/src/slideshow_templates/shooting-data.json @@ -0,0 +1,18 @@ +{ + "format": "lightcraft.slideshowTemplate", + "version": 1, + "name": "Shooting Data", + "description": "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.", + "playback": {"slideDuration": 5, "fadeDuration": 0.8, "fadeColor": null, "randomOrder": false, "repeat": true}, + "layout": { + "linkMargins": false, "marginLeft": 0.05, "marginRight": 0.05, "marginTop": 0.05, "marginBottom": 0.15, + "backdropColor": "#1d2126", "colorWash": true, "washColor": "#3a4550", "washOpacity": 0.7, "washAngle": 90 + }, + "overlays": { + "texts": [ + {"text": "{exposure} ISO {iso} {focal}", "size": 0.034, "color": "#e6e8ea", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.062, "relativeTo": "slide", "shadow": false}, + {"text": "{camera} {lens}", "size": 0.026, "color": "#9aa4ae", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.024, "relativeTo": "slide", "shadow": false} + ] + }, + "titles": {} +} diff --git a/crates/engine/src/sync.rs b/crates/engine/src/sync.rs index 4342ec6ad..6f236796e 100644 --- a/crates/engine/src/sync.rs +++ b/crates/engine/src/sync.rs @@ -38,7 +38,7 @@ //! not cancelled midway. use std::collections::HashMap; -use std::path::Path; +use std::path::{Path, PathBuf}; use std::sync::Arc; use lightcraft_catalog::{Catalog, Filter, Op, Photo, PhotoId, Sort, Source}; @@ -203,18 +203,37 @@ pub fn scan_with(input: SyncInput, progress: &SyncProgress) -> FolderChanges { let namings = crate::sidecar::namings_in(&labels, naming, &ids); let photos: Vec<(Arc<Photo>, SidecarNaming)> = ids.iter().zip(namings).filter_map(|(id, naming)| labels.photo(*id).map(|p| (Arc::clone(p), naming))).collect(); + // The folder is walked once, and its listing answers for the library's photos in it: on a + // network share that is one request for a folder's names where it was several for each + // photo. Then both halves wait on the disk at once: the new files are probed (on their own + // threads) beside the checks of the photos, which ask the disk only what the listing can't say. + // (how many photos there are to check is known before the walk: the count shows while it runs) + progress.to_check.store(photos.len(), std::sync::atomic::Ordering::Relaxed); + let mut listing = Listing::default(); + let (files, _) = crate::import::expand_noting( + std::slice::from_ref(&folder), + scan.skip(), + crate::walk::Limits::default(), + Some(&mut |dir, names, complete| listing.note(dir, names, complete)), + ); + let listing = &listing; // (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)); + let (out, checks) = (crate::import::scan_files(scan, files, &progress.files), check_photos(&photos, &labels, listing, 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); + let checks = sc.spawn(move || check_photos(photos, labels, listing, progress)); + let out = crate::import::scan_files(scan, files, &progress.files); // (a check that panicked counts as nothing found; the scan still reports the rest) (out, checks.join().unwrap_or_default()) }); + log::debug!( + "synchronize: {} folders listed; the disk was asked {} times about the library's photos", + listing.folders.len(), + listing.asked.load(std::sync::atomic::Ordering::Relaxed) + ); 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 }; @@ -335,11 +354,136 @@ enum Check { News, } -fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { +/// The listings of the folders a scan walked: what is in each, by name. It answers "is this file +/// there" for the library's photos and their sidecars without asking the disk, which on a +/// network share is a round trip for every name (and a photo without a sidecar took five). +/// +/// It answers only what holds on any file system. A name is there when the folder lists exactly +/// that name ([`asks_the_disk`] aside). It is not there when the folder was listed to its end, +/// every name in the folder is ASCII, the name asked about is plain ASCII ([`plain_name`]), and +/// no name in the folder is that name in another case. Anything else is not answered, and the +/// disk is asked as before: a name that differs in case only, a link, a folder that was not +/// walked or not to its end, and every name that is not listed in a folder that holds a name +/// outside ASCII. (File systems hold names outside ASCII equal in more ways than can be listed +/// here: by case, where the capital sharp s is `SS`; by how accents are written; and some pass +/// over marks altogether.) +/// +/// Where the listing and a look-up would differ, the listing is the folder as the scan saw it: +/// a file it lists is there though it could not be opened by its path (a folder that may be +/// listed but not searched, a path longer than the system takes), and a share that drops after +/// the walk does not turn every photo the listing answers for into a missing one. +#[derive(Default)] +pub(crate) struct Listing { + folders: HashMap<PathBuf, Listed>, + /// How often the disk was asked after all (for the tests, and the log). + pub(crate) asked: std::sync::atomic::AtomicUsize, +} + +/// One folder's listing. +struct Listed { + /// name → what it is. + names: HashMap<String, crate::walk::EntryKind>, + /// The names, lower-cased (only kept for a folder whose names are all ASCII): what a plain + /// name asked about could be in another case. + lower: std::collections::HashSet<String>, + /// Every entry was read, and every name is ASCII: a plain name that is not listed, in any + /// case, is not there. + sure: bool, +} + +/// Whether the disk is asked about `name` whatever the listing holds: a name Windows takes for +/// a device (`NUL`, `aux.jpg`: there in every folder, or not openable where it is listed). +fn asks_the_disk(name: &str) -> bool { + let stem = name.split('.').next().unwrap_or_default().trim_end_matches(' ').to_ascii_lowercase(); + // (`COM` and `LPT` with any one digit, the superscript ones Windows also takes among them) + let numbered = ["com", "lpt"].iter().any(|port| { + let mut rest = stem.strip_prefix(port).unwrap_or_default().chars(); + matches!((rest.next(), rest.next()), (Some(d), None) if d.is_ascii_digit() || ['\u{b9}', '\u{b2}', '\u{b3}'].contains(&d)) + }); + numbered || ["con", "prn", "aux", "nul", "conin$", "conout$", "clock$"].contains(&stem.as_str()) +} + +/// Whether `name` can only be found under itself in some case: ASCII, and nothing in it that a +/// file system resolves another way (a short name with `~`, a stream after `:`, dots or spaces +/// at its end, which Windows drops). +fn plain_name(name: &str) -> bool { + name.is_ascii() && !name.contains(['~', ':']) && !name.ends_with(['.', ' ']) && !name.is_empty() +} + +impl Listing { + /// Take the listing of `folder` from the walk ([`crate::walk::files_in_noting`]). + pub(crate) fn note(&mut self, folder: &Path, entries: Vec<(std::ffi::OsString, crate::walk::EntryKind)>, complete: bool) { + let mut sure = complete; + let mut names = HashMap::with_capacity(entries.len()); + let mut lower = std::collections::HashSet::new(); + for (name, kind) in entries { + match name.into_string() { + Ok(name) => { + sure &= name.is_ascii(); + lower.insert(name.to_ascii_lowercase()); + names.insert(name, kind); + } + // (a name that is no text is not one a photo's path can be compared with) + Err(_) => sure = false, + } + } + if !sure { + lower.clear(); + } + self.folders.insert(folder.to_path_buf(), Listed { names, lower, sure }); + } + + /// What the listing of `path`'s folder says of it: its kind when it is listed under exactly + /// this name, `Some(None)` when it is surely not there, `None` when the listing can't say. + fn kind(&self, path: &Path) -> Option<Option<crate::walk::EntryKind>> { + let name = path.file_name()?.to_str()?; + // (the path ends in that name: no `/.` or separator after it, which the disk would resolve) + if !path.to_str()?.ends_with(name) { + return None; + } + if asks_the_disk(name) { + return None; + } + let folder = self.folders.get(path.parent()?)?; + if let Some(kind) = folder.names.get(name) { + return Some(Some(*kind)); + } + (folder.sure && plain_name(name) && !folder.lower.contains(&name.to_ascii_lowercase())).then_some(None) + } + + /// `Path::exists`, where the listing can say. + pub(crate) fn exists(&self, path: &Path) -> Option<bool> { + match self.kind(path)? { + Some(crate::walk::EntryKind::Other) => None, + Some(_) => Some(true), + None => Some(false), + } + } + + /// `Path::is_file`, where the listing can say. + pub(crate) fn is_file(&self, path: &Path) -> Option<bool> { + match self.kind(path)? { + Some(crate::walk::EntryKind::Other) => None, + Some(kind) => Some(kind == crate::walk::EntryKind::File), + None => Some(false), + } + } + + /// The listing's answer, or the disk's where it has none. + fn or_ask(&self, answer: Option<bool>, ask: impl FnOnce() -> bool) -> bool { + answer.unwrap_or_else(|| { + self.asked.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + ask() + }) + } +} + +fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog, listing: &Listing) -> Check { let Some(file) = crate::cmd::missing::checked_path(p) else { return Check::Nothing }; - if !Path::new(file).exists() { + let file = Path::new(file); + if !listing.or_ask(listing.exists(file), || file.exists()) { Check::Missing - } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming) { + } else if p.copy_of.is_none() && sidecar_has_news(labels, p, naming, listing) { Check::News } else { Check::Nothing @@ -348,12 +492,12 @@ fn check_photo(p: &Photo, naming: SidecarNaming, labels: &Catalog) -> Check { /// [`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<Photo>, SidecarNaming)], labels: &Catalog, progress: &SyncProgress) -> Vec<Check> { +fn check_photos(photos: &[(Arc<Photo>, SidecarNaming)], labels: &Catalog, listing: &Listing, progress: &SyncProgress) -> Vec<Check> { 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); + let c = check_photo(p, naming, labels, listing); progress.checked.fetch_add(1, Relaxed); c }; @@ -421,10 +565,17 @@ impl Session { /// 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 { +fn sidecar_has_news(labels: &Catalog, p: &Photo, naming: SidecarNaming, listing: &Listing) -> 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 }; + // ([`crate::sidecar::find_sidecar`], each name looked up in the listing before the disk) + // (a name the listing holds is read for its date next, and one that can't be read is passed + // over for the next name, as a name that is no file was) + let found = crate::sidecar::sidecar_candidates(path, naming).find_map(|c| { + let there = listing.or_ask(listing.is_file(&c), || c.is_file()); + let meta = there.then(|| std::fs::metadata(&c).ok()).flatten().filter(std::fs::Metadata::is_file)?; + Some((c, meta)) + }); + let Some((file, meta)) = found else { return false }; if meta.len() > MAX_SIDECAR_BYTES { return false; } @@ -708,3 +859,7 @@ impl SyncCommit { self.report } } + +#[cfg(test)] +#[path = "sync/tests_listing.rs"] +mod tests_listing; diff --git a/crates/engine/src/sync/tests_listing.rs b/crates/engine/src/sync/tests_listing.rs new file mode 100644 index 000000000..d4664dd15 --- /dev/null +++ b/crates/engine/src/sync/tests_listing.rs @@ -0,0 +1,192 @@ +//! Synchronize Folder answers "is the photo's file there" and "has it a sidecar" from the +//! listing of the folder its scan has just made, instead of asking the disk about every photo. +//! +//! On a network share each question is a round trip (about 5 ms a file on a hard disk over SMB, +//! measured), and a photo without a sidecar took five of them; the listing has every name. +//! The listing answers only what it can be sure of on any file system: where a name could match +//! another way (case, letters outside ASCII, a link), the disk is asked as before. +use super::*; +use crate::walk::EntryKind::{File, Folder, Other}; + +fn listing(folders: &[(&str, &[(&str, crate::walk::EntryKind)], bool)]) -> Listing { + let mut l = Listing::default(); + for (dir, names, complete) in folders { + l.note(Path::new(dir), names.iter().map(|(n, k)| (std::ffi::OsString::from(n), *k)).collect(), *complete); + } + l +} + +/// Given the listing of a folder, when it is asked about a name, then it says "there" for a +/// name it lists, "not there" for one nothing in the folder could be taken for, and nothing +/// (ask the disk) wherever a file system could see it otherwise. +#[test] +fn the_listing_answers_only_what_it_is_sure_of() { + let l = listing(&[ + ( + "/pics/trip", + &[("IMG_1.CR3", File), ("IMG_1.xmp", File), ("img_2.jpg", File), ("sub", Folder), ("link.jpg", Other), (".hidden.xmp", File)], + true, + ), + ("/pics/cut", &[("IMG_1.CR3", File)], false), + ("/pics/names", &[("IMG_1.CR3", File), ("Größe.JPG", File)], true), + // (the capital sharp s, which a file system that folds case takes for `SS`) + ("/pics/sharp", &[("IMG_1.CR3", File), ("STRA\u{1e9e}E.jpg", File)], true), + ("/pics/devices", &[("aux.jpg", File), ("COM\u{b9}.jpg", File), ("IMG_1.CR3", File)], true), + ]); + let (there, gone, ask) = (Some(true), Some(false), None); + for (path, exists, is_file) in [ + ("/pics/trip/IMG_1.CR3", there, there), + ("/pics/trip/IMG_1.xmp", there, there), + ("/pics/trip/.hidden.xmp", there, there), + ("/pics/trip/sub", there, gone), + ("/pics/trip/IMG_9.CR3", gone, gone), + ("/pics/trip/IMG_1.XMP", ask, ask), + ("/pics/trip/IMG_2.JPG", ask, ask), + ("/pics/trip/link.jpg", ask, ask), + ("/pics/trip/SUB", ask, ask), + // a folder the walk did not list to its end, or not at all + ("/pics/cut/IMG_1.CR3", there, there), + ("/pics/cut/IMG_2.CR3", ask, ask), + ("/pics/other/IMG_1.CR3", ask, ask), + // a folder that holds a name outside ASCII: what it lists is there, and nothing is surely not there (file + // systems hold such names equal in more ways than one) + ("/pics/names/IMG_1.CR3", there, there), + ("/pics/names/Größe.JPG", there, there), + ("/pics/names/IMG_2.CR3", ask, ask), + ("/pics/names/größe.JPG", ask, ask), + ("/pics/trip/Größe.JPG", ask, ask), + ("/pics/sharp/strasse.jpg", ask, ask), + ("/pics/sharp/STRASSE.xmp", ask, ask), + ("/pics/sharp/IMG_2.CR3", ask, ask), + // names Windows resolves another way: a short name, a stream, dots or spaces at the end, a device + ("/pics/trip/IMG_10~1.CR3", ask, ask), + ("/pics/trip/IMG_1.CR3::$DATA", ask, ask), + ("/pics/trip/IMG_1.CR3.", ask, ask), + ("/pics/trip/IMG_1.CR3 ", ask, ask), + ("/pics/devices/aux.jpg", ask, ask), + ("/pics/devices/NUL", ask, ask), + ("/pics/devices/nul.xmp", ask, ask), + ("/pics/devices/COM1 .jpg", ask, ask), + ("/pics/devices/COM\u{b9}.jpg", ask, ask), + ("/pics/trip/con", ask, ask), + ("/pics/trip/PRN.txt", ask, ask), + ("/pics/trip/com2.jpg", ask, ask), + ("/pics/trip/LPT9.xmp", ask, ask), + ("/pics/trip/lpt0", ask, ask), + ("/pics/trip/nul.tar.gz", ask, ask), + ("/pics/trip/CONIN$", ask, ask), + ("/pics/trip/com10.jpg", gone, gone), + ("/pics/trip/computer.jpg", gone, gone), + ("/pics/devices/IMG_1.CR3", there, there), + ("/pics/devices/auxiliary.jpg", ask, ask), + ("/pics/trip/auxiliary.jpg", gone, gone), + // a path that does not end in its file's name is the disk's to resolve + ("/pics/trip/IMG_1.CR3/", ask, ask), + ("/pics/trip/IMG_1.CR3/.", ask, ask), + ("/pics/trip/", ask, ask), + ("", ask, ask), + ] { + assert_eq!((l.exists(Path::new(path)), l.is_file(Path::new(path))), (exists, is_file), "{path:?}"); + } + // a name that is no text: nothing in its folder is surely not there + #[cfg(unix)] + { + use std::os::unix::ffi::OsStringExt; + let mut l = Listing::default(); + l.note( + Path::new("/pics/raw"), + vec![(std::ffi::OsString::from("IMG_1.CR3"), File), (std::ffi::OsString::from_vec(vec![0x66, 0xff, 0x6f]), File)], + true, + ); + assert_eq!((l.exists(Path::new("/pics/raw/IMG_1.CR3")), l.exists(Path::new("/pics/raw/IMG_2.CR3"))), (there, ask)); + } +} + +fn photo(c: &mut Catalog, path: &Path) -> Arc<Photo> { + let id = c.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + c.apply(lightcraft_catalog::Op::AddPhoto { + photo: Box::new(Photo::new(id, Source::File { path: path.to_string_lossy().to_string() }, &name, "JPEG", 6, 4, "2020-01-01T00:00:00")), + }) + .unwrap(); + Arc::clone(c.photo(id).unwrap()) +} + +/// Given a folder on disk whose photos are there or gone, with sidecars under every name a +/// sidecar can have, when each photo is checked with the folder's listing, then the answer is +/// the one the disk gives; and for photos with plain names the disk was asked nothing but the +/// sidecars' dates. +#[test] +fn a_check_from_the_listing_is_the_check_from_the_disk() { + let dir = std::env::temp_dir().join(format!("lc-listing-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let news = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmp:Rating="5"/></rdf:RDF></x:xmpmeta>"#; + for name in [ + "plain.jpg", + "stem.jpg", + "stem.xmp", + "full.jpg", + "full.jpg.xmp", + "upper.jpg", + "upper.XMP", + "sub/deep.jpg", + "sub/deep.xmp", + "Größe.jpg", + "Größe.xmp", + "dir.jpg.xmp", + ] { + std::fs::write(dir.join(name), if name.ends_with("mp") || name.ends_with("MP") { news.as_bytes() } else { b"jpeg" }).unwrap(); + } + let mut c = Catalog::new(); + let photos: Vec<Arc<Photo>> = + ["plain.jpg", "stem.jpg", "full.jpg", "upper.jpg", "sub/deep.jpg", "Größe.jpg", "gone.jpg", "sub/gone.jpg", "dir.jpg"] + .iter() + .map(|n| photo(&mut c, &dir.join(n))) + .collect(); + // (`dir.jpg` is gone, though a sidecar of its name is there) + let mut l = Listing::default(); + let walked = + crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| l.note(d, names, complete)); + assert_eq!(walked.files.len(), 12); + let nothing = Listing::default(); + for naming in [SidecarNaming::Stem, SidecarNaming::Full] { + for p in &photos { + let from_disk = check_photo(p, naming, &c, &nothing); + assert_eq!(check_photo(p, naming, &c, &l), from_disk, "{:?} {naming:?}", p.file_name); + } + } + let expected = [Check::Nothing, Check::News, Check::News, Check::News, Check::News, Check::News, Check::Missing, Check::Missing, Check::Missing]; + assert_eq!(photos.iter().map(|p| check_photo(p, SidecarNaming::Stem, &c, &l)).collect::<Vec<_>>(), expected); + + // what the disk was asked about the same photos, with the listing and without: fewer questions with it + let asked = |l: &Listing| { + for p in &photos { + check_photo(p, SidecarNaming::Stem, &c, l); + } + l.asked.load(std::sync::atomic::Ordering::Relaxed) + }; + let mut with = Listing::default(); + crate::walk::files_in_noting(&dir, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| with.note(d, names, complete)); + let (with, without) = (asked(&with), asked(&Listing::default())); + // (without: each photo's file, and the names of its sidecar up to the one that is there: 20 questions where + // names are found in any case, 21 where they are not) + assert!(without >= 20, "{without}"); + // (with: this folder holds `Größe.*`, so what it does not list is still asked about: the nine names of the + // sidecar `plain.jpg` has not (`.xmp`, `.XMP` and `.Xmp` of `plain`, `plain.jpg` and `plain.JPG`), the three + // `full.jpg` has not, `upper.xmp`, and the two photos that are gone. What it lists is not asked about, nor + // anything in `sub`, whose names are all ASCII) + assert_eq!(with, 15, "asked {with} times with the listing, {without} without"); + + // a folder of plain names only: the disk is asked nothing at all + let sub = dir.join("sub"); + let mut only_plain = Listing::default(); + crate::walk::files_in_noting(&sub, None, crate::walk::Limits::default(), |_| true, &mut |d, names, complete| only_plain.note(d, names, complete)); + let mut c2 = Catalog::new(); + for (name, expected) in [("deep.jpg", Check::News), ("gone.jpg", Check::Missing), ("gone.jpg.xmp", Check::Missing)] { + let p = photo(&mut c2, &sub.join(name)); + assert_eq!(check_photo(&p, SidecarNaming::Stem, &c2, &only_plain), expected, "{name}"); + } + assert_eq!(only_plain.asked.load(std::sync::atomic::Ordering::Relaxed), 0, "every answer came from the listing"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 4e5e73850..400135ce0 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -6,6 +6,39 @@ fn demo() -> Session { Session::with_demo() } +#[test] +fn nested_catalog_operations_invalidate_sources_after_success_and_on_undo_redo() { + use crate::media::SourceLevel; + use lightcraft_catalog::{Op, PhotoId}; + let mut s = demo(); + let id = s.active().unwrap(); + let image = std::sync::Arc::new(lightcraft_raster::Rgb32f::new(1, 1)); + let cache = |s: &mut Session| { + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + s.media.insert(id, level, image.clone()); + } + }; + cache(&mut s); + // An invalid operation must not throw away usable sources. + assert!(s.apply_catalog(Op::Batch { ops: vec![Op::RemovePhoto { id }, Op::RemovePhoto { id: PhotoId(u64::MAX) }] }).is_err()); + assert!(s.catalog.photo(id).is_some(), "the failed batch restores the removed photo"); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(s.media.has_source(id, level), "the failed batch keeps usable sources"); + } + s.commit("Remove", Op::Batch { ops: vec![Op::Batch { ops: vec![Op::RemovePhoto { id }] }] }).unwrap(); + for level in [SourceLevel::Thumb, SourceLevel::Preview, SourceLevel::Full] { + assert!(!s.media.has_source(id, level)); + } + cache(&mut s); + s.undo_step().unwrap(); + assert!(s.catalog.photo(id).is_some()); + assert!(!s.media.has_source(id, SourceLevel::Full), "undo AddPhoto must invalidate the source too"); + cache(&mut s); + s.redo_step().unwrap(); + assert!(s.catalog.photo(id).is_none()); + assert!(!s.media.has_source(id, SourceLevel::Full)); +} + fn active_dev(s: &Session) -> lightcraft_develop::DevelopSettings { (*s.develop_of(s.active().unwrap()).unwrap()).clone() } @@ -280,6 +313,90 @@ fn permanently_deleting_explicit_targets_keeps_the_surviving_selection() { assert_eq!(s.catalog.to_snapshot(), snapshot); } +/// `mask.brushStroke {connect: true}` (a ⇧-click) starts from the last point of the mask's last stroke: one straight +/// stroke when the brush is unchanged, pieces whose size, feather and flow step from the last stroke's to the new +/// values when it changed, and a plain dab when there is nothing to connect to. +#[test] +fn connected_brush_strokes_start_where_the_last_one_ended() { + use lightcraft_develop::MaskShape; + use lightcraft_geom::Point; + let mut s = demo(); + let id = s.active().unwrap(); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], false, "nothing to connect to: a dab"); + let strokes = |s: &Session| { + let d = s.develop_of(id).unwrap(); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + strokes.clone() + }; + assert_eq!(strokes(&s)[0].points.len(), 1); + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let line = strokes(&s); + assert_eq!(line.len(), 2); + assert_eq!(line[1].points, vec![Point::new(0.2, 0.5), Point::new(0.6, 0.5)]); + // a bigger, softer brush: the segment tapers from the last stroke's values to the new ones + let r = s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "feather": 80, "connect": true})).unwrap(); + assert_eq!(r["connected"], true); + let all = strokes(&s); + let taper = &all[2..]; + assert!(taper.len() > 4, "{} pieces", taper.len()); + assert_eq!(taper[0].points[0], Point::new(0.6, 0.5), "from the last stroke's end"); + assert_eq!(*taper.last().unwrap().points.last().unwrap(), Point::new(0.6, 0.9), "to the click"); + assert!(taper.windows(2).all(|w| w[1].size >= w[0].size && w[1].feather >= w[0].feather && w[0].points[1] == w[1].points[0])); + assert!((taper.last().unwrap().size - 0.04).abs() < 1e-12 && (taper.last().unwrap().feather - 80.0).abs() < 1e-9); + assert!(taper[0].size > 0.02 && taper[0].size < 0.04); + // all of it is one undo step + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(strokes(&s).len(), 2); +} + +#[test] +fn brush_strokes_keep_pen_pressure_and_refuse_bad_lists() { + let mut s = demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": [0.25, 7.0]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert_eq!(strokes[0].pressure, vec![0.25, 1.0], "kept, clamped to 0..1"); + for bad in [json!([0.5, 0.5, 0.5]), json!("hard"), json!([0.5, "x"])] { + assert!(s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]], "pressure": bad})).is_err(), "{bad}"); + } + s.execute("mask.brushStroke", &json!({"points": [[0.4, 0.4]]})).unwrap(); + let lightcraft_develop::MaskShape::Brush { strokes } = &active_dev(&s).masks[0].components[0].shape else { panic!("a brush") }; + assert!(strokes[1].pressure.is_empty(), "a mouse stroke has no pressure data"); + let json = serde_json::to_string(&strokes[1]).unwrap(); + assert!(!json.contains("pressure"), "and stores none: {json}"); +} + +/// A ⇧-click line carries pen pressure: the last dab's at its start and the click's at its end, stepping along a +/// tapered line; with no pressure on either side there is none. +#[test] +fn connected_brush_strokes_carry_pen_pressure() { + use lightcraft_develop::MaskShape; + let mut s = demo(); + let id = s.active().unwrap(); + let strokes = |s: &Session| { + let d = s.develop_of(id).unwrap(); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + strokes.clone() + }; + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02, "pressure": [0.4]})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "pressure": [1.0], "connect": true})).unwrap(); + assert_eq!(strokes(&s)[1].pressure, vec![0.4, 1.0]); + s.execute("mask.brushStroke", &json!({"points": [[0.6, 0.9]], "size": 0.04, "pressure": [0.2], "connect": true})).unwrap(); + let taper = &strokes(&s)[2..]; + assert!(taper.iter().all(|t| t.pressure.len() == 2)); + assert!((taper[0].pressure[0] - 1.0).abs() < 1e-6 && (taper.last().unwrap().pressure[1] - 0.2).abs() < 1e-6); + assert!(taper.windows(2).all(|w| w[1].pressure[0] <= w[0].pressure[0])); + // a mouse: no pressure anywhere + let mut m = demo(); + m.execute("mask.brushStroke", &json!({"points": [[0.2, 0.5]], "size": 0.02})).unwrap(); + m.execute("mask.brushStroke", &json!({"points": [[0.6, 0.5]], "size": 0.02, "connect": true})).unwrap(); + let d = m.develop_of(m.active().unwrap()).unwrap(); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert!(strokes[1].pressure.is_empty()); +} + #[test] fn masks_crop_and_render() { let mut s = demo(); @@ -424,6 +541,32 @@ fn degenerate_crops_render_with_spots_and_masks() { } } +/// `crop.rotateAspect` on a crop of no size took its aspect from 0/0 = NaN and stored a NaN crop +/// rectangle; it now falls back to the image's own aspect, a finite crop inside the image. +#[test] +fn rotate_aspect_of_degenerate_crop_stays_finite() { + for rect in [[0.5, 0.5, 0.5, 0.5], [0.2, 0.2, 0.2, 0.8], [0.2, 0.2, 0.8, 0.2]] { + let mut s = demo(); + s.execute("crop.set", &json!({"rect": rect})).unwrap(); + s.execute("crop.rotateAspect", &json!({})).unwrap(); + let r = active_dev(&s).crop.geometry.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?}: {r:?}"); + assert!(r.width() > 0.0 && r.height() > 0.0, "{rect:?}: {r:?}"); + assert!(r.x0 >= 0.0 && r.y0 >= 0.0 && r.x1 <= 1.0 && r.y1 <= 1.0, "{rect:?}: {r:?}"); + } +} + +/// `crop.set` with a huge rect shrank it by a scale of 0 (`inf * 0`) and stored a NaN crop. +#[test] +fn crop_set_huge_rect_stays_finite() { + for rect in [[0.0, 0.0, 1e308, 1e308], [-1e308, -1e308, 1e308, 1e308]] { + let mut s = demo(); + let _ = s.execute("crop.set", &json!({"rect": rect})); + let r = active_dev(&s).crop.geometry.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite() && (-1e-6..=1.0 + 1e-6).contains(v)), "{rect:?}: {r:?}"); + } +} + /// `mask.move` with a delta near `i64::MAX` overflowed `index + delta` (a panic wherever overflow /// checks are on); extreme deltas and indices now move the mask to the bottom or the top. #[test] @@ -1486,3 +1629,56 @@ fn oversized_spots_render() { } } } + +/// Rotate turns the crop with the image: a crop of the left half becomes the top half, and turning back restores it. +#[test] +fn rotate_carries_the_crop_with_the_image() { + use lightcraft_geom::Rect; + let mut s = demo(); + let id = s.active().unwrap(); + let mut d = active_dev(&s); + let left_half = Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + d.crop.geometry.rect = left_half; + d.crop.aspect = Some((2, 3)); + s.set_develop(id, d, "Crop").unwrap(); + s.execute("photo.rotateRight", &json!({})).unwrap(); + let c = active_dev(&s).crop; + assert_eq!(c.geometry.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 0.5 }); + assert_eq!(c.aspect, Some((3, 2)), "a locked aspect turns too"); + s.execute("photo.rotateLeft", &json!({})).unwrap(); + let c = active_dev(&s).crop; + assert_eq!(c.geometry.rect, left_half); + assert_eq!(c.aspect, Some((2, 3))); +} + +/// A call that names its photos with a malformed `id` or `ids` (a string, a mixed list) is refused and changes +/// nothing; it used to act on the selection, or on the valid part of the list (issue #1506). Leaving the targets +/// out still means the selection. +#[test] +fn malformed_photo_targets_are_refused_not_widened() { + use lightcraft_catalog::PhotoId; + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": [ids[0], ids[1]]})).unwrap(); + let rating = |s: &Session, i: u64| s.catalog.photo(PhotoId(i)).unwrap().rating; + let before: Vec<u8> = ids.iter().map(|i| rating(&s, *i)).collect(); + for bad in [ + json!({"rating": 5, "id": "5"}), + json!({"rating": 5, "ids": [ids[2], "x"]}), + json!({"rating": 5, "ids": ids[2].to_string()}), + json!({"rating": 5, "id": -1}), + ] { + let err = s.execute("photo.rate", &bad).unwrap_err(); + assert!(matches!(&err, crate::EngineError::BadParams { cmd, .. } if cmd == "photo.rate"), "{bad}: {err}"); + assert_eq!(ids.iter().map(|i| rating(&s, *i)).collect::<Vec<_>>(), before, "{bad} changed a rating"); + } + let snapshot = s.catalog.to_snapshot(); + assert!(s.execute("photo.deletePermanently", &json!({"id": "5"})).is_err()); + assert_eq!(s.catalog.to_snapshot(), snapshot, "nothing deleted"); + // no targets, or a null one: the selection + s.execute("photo.rate", &json!({"rating": 4, "id": null})).unwrap(); + assert_eq!((rating(&s, ids[0]), rating(&s, ids[1]), rating(&s, ids[2])), (4, 4, before[2])); + // a preset's id is not a photo target + let preset = s.presets.first().unwrap().id.clone(); + s.execute("preset.apply", &json!({"id": preset})).unwrap(); +} diff --git a/crates/engine/src/tests_album_remove.rs b/crates/engine/src/tests_album_remove.rs new file mode 100644 index 000000000..041eb3812 --- /dev/null +++ b/crates/engine/src/tests_album_remove.rs @@ -0,0 +1,105 @@ +//! Removing photos from an album changes the album only (issue #1805): the photos stay in the +//! library, in their library folder and in the folder view of their folder on disk. Deleting a +//! photo (`photo.delete`) still removes it from all of them. + +use std::path::{Path, PathBuf}; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::{Selection, Session}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-album-remove-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png(p: &Path, seed: u8) { + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, seed, 255]); + let b = + crate::export::encode_image(&img, &crate::export::ExportOptions { format: crate::export::ExportFormat::Png, ..Default::default() }).unwrap(); + std::fs::write(p, b).unwrap(); +} + +/// A folder of three files imported into a new album "Trip": (session, folder, album id, photos). +fn imported(tag: &str) -> (Session, String, u64, Vec<PhotoId>) { + let dir = temp_dir(tag); + for (i, n) in ["a.png", "b.png", "c.png"].iter().enumerate() { + png(&dir.join(n), i as u8 * 40); + } + let folder = dir.to_string_lossy().to_string(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [folder], "mode": "add", "albumName": "Trip"})).unwrap(); + let album = r["album"].as_u64().expect("the import made the album"); + let mut ids: Vec<PhotoId> = r["imported"].as_array().unwrap().iter().filter_map(|v| v.as_u64()).map(PhotoId).collect(); + ids.sort(); + assert_eq!(ids.len(), 3); + (s, folder, album, ids) +} + +/// How many photos the library folder view, the folder (disk) view and the album show. +fn counts(s: &mut Session, folder: &str, album: u64) -> (u64, u64, u64) { + let lib = s.execute("library.source", &json!({"kind": "libraryFolder", "path": folder})).unwrap()["count"].as_u64().unwrap(); + let disk = s.execute("library.browse", &json!({"path": folder})).unwrap()["photos"].as_u64().unwrap(); + let disk_visible = s.visible_cloned().len() as u64; + assert_eq!(disk, disk_visible, "browse reports what it shows"); + let al = s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap()["count"].as_u64().unwrap(); + (lib, disk, al) +} + +/// The reporter's steps: a folder added to an album, a photo removed in the album, then the folder +/// view: it still shows every photo of the folder. +#[test] +fn removing_from_an_album_keeps_the_photo_in_its_folder() { + let (mut s, folder, album, ids) = imported("keep"); + assert_eq!(counts(&mut s, &folder, album), (3, 3, 3)); + + // in the album (its source on screen), remove one photo; no `id`: the album on screen + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.selection = Selection::single(ids[1]); + let r = s.execute("album.removePhotos", &json!({})).unwrap(); + assert_eq!(r["removed"], 1, "{r}"); + assert_eq!(r["album"], "Trip", "{r}"); + assert!(!s.catalog.photo(ids[1]).unwrap().deleted, "still in the library"); + assert_eq!(counts(&mut s, &folder, album), (3, 3, 2), "(library folder, folder on disk, album)"); + + // removing what isn't in the album changes nothing (and makes no undo step) + let undo = s.undo.len(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + let r = s.execute("album.removePhotos", &json!({"ids": [ids[1].0]})).unwrap(); + assert_eq!(r["removed"], 0, "{r}"); + assert_eq!(s.undo.len(), undo); + let _ = std::fs::remove_dir_all(&folder); +} + +/// Delete (to Recently Deleted) is the way out of the library: the photo leaves its folder views +/// and the album with it. +#[test] +fn deleting_a_photo_still_removes_it_everywhere() { + let (mut s, folder, album, ids) = imported("delete"); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.selection = Selection::single(ids[0]); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(s.catalog.photo(ids[0]).unwrap().deleted); + assert_eq!(counts(&mut s, &folder, album), (2, 2, 2), "(library folder, folder on disk, album)"); + let n = s.execute("library.source", &json!({"kind": "recentlyDeleted"})).unwrap()["count"].as_u64().unwrap(); + assert_eq!(n, 1); + let _ = std::fs::remove_dir_all(&folder); +} + +/// Without an album on screen and without `id` there is nothing to remove from; smart albums +/// follow their rules. +#[test] +fn remove_from_album_refuses_what_it_cannot_do() { + let (mut s, folder, _album, ids) = imported("refuse"); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.selection = Selection::single(ids[0]); + assert!(s.execute("album.removePhotos", &json!({})).is_err(), "no album on screen"); + let smart = s.execute("album.createSmart", &json!({"name": "All", "rules": {}})).unwrap()["id"].as_u64().unwrap(); + assert!(s.execute("album.removePhotos", &json!({"id": smart})).is_err(), "smart album"); + assert!(s.execute("album.removePhotos", &json!({"id": 9999})).is_err(), "no such album"); + assert!(s.execute("album.removePhotos", &json!({"id": "x"})).is_err(), "bad id"); + let _ = std::fs::remove_dir_all(&folder); +} diff --git a/crates/engine/src/tests_crop_angle.rs b/crates/engine/src/tests_crop_angle.rs index 90a12b2d0..bac941239 100644 --- a/crates/engine/src/tests_crop_angle.rs +++ b/crates/engine/src/tests_crop_angle.rs @@ -107,6 +107,33 @@ fn a_straighten_slider_drag_fits_live_and_is_one_undo_step() { assert!(close(geometry(&s), start)); } +#[test] +fn turning_a_crop_dragged_in_from_a_corner_keeps_that_crop() { + // issue #1794: lock the aspect, drag a corner in, then rotate: the crop went back to the photo's + // size, because the dragged crop still touches two edges and stops fitting at once + let mut s = Session::with_demo(); + let (w, h) = dims(&s); + s.execute("crop.aspect", &json!({"aspect": "original"})).unwrap(); + s.execute("crop.drag", &json!({"handle": "bottomRight", "from": [1.0, 1.0], "to": [0.6, 0.6]})).unwrap(); + let made = geometry(&s).rect_px(w, h); + assert!(made.width() < 0.7 * w, "the drag cropped: {made:?}"); + s.execute("develop.beginInteraction", &json!({"label": "Crop"})).unwrap(); + for angle in [0.25, 1.0, 2.5, 5.0, -3.0] { + s.execute("crop.straighten", &json!({"angle": angle})).unwrap(); + let g = geometry(&s); + let r = g.rect_px(w, h); + assert!(g.is_within_image(w, h), "{angle}°: inside the photo"); + assert!(r.width() <= made.width() + 1e-6, "{angle}°: no larger than the crop made, got {r:?} from {made:?}"); + assert!((r.aspect() - made.aspect()).abs() < 1e-6, "{angle}°: the locked aspect holds"); + } + s.execute("develop.endInteraction", &json!({})).unwrap(); + // the first small turn only trims it + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("crop.straighten", &json!({"angle": 0.25})).unwrap(); + let r = geometry(&s).rect_px(w, h); + assert!(r.width() > 0.97 * made.width(), "0.25° keeps nearly all of it: {r:?} from {made:?}"); +} + #[test] fn a_crop_shrunk_by_straightening_grows_back_towards_0() { // issue #1388: slide the Straighten slider out and back, in one drag or in several diff --git a/crates/engine/src/tests_denoise.rs b/crates/engine/src/tests_denoise.rs index 1cba9f648..cc9ad2eed 100644 --- a/crates/engine/src/tests_denoise.rs +++ b/crates/engine/src/tests_denoise.rs @@ -705,6 +705,7 @@ fn a_photo_whose_file_changed_has_no_picture_until_it_is_made_again() { file_size: p.file_size + 1, content_hash: Some("ffffffffffffffffffffffffffffffff".into()), preview_only: None, + preview_checked: String::new(), }, ) .unwrap(); diff --git a/crates/engine/src/tests_export.rs b/crates/engine/src/tests_export.rs index 592f7349d..0ac8df804 100644 --- a/crates/engine/src/tests_export.rs +++ b/crates/engine/src/tests_export.rs @@ -236,6 +236,69 @@ fn library_with_jpeg(tag: &str) -> (Session, lightcraft_catalog::PhotoId, std::p (s, id, dir, src, bytes) } +fn preset_export_preserves_originals(command: &str, selection: serde_json::Value, tag: &str) { + let (mut s, _, dir, src, original) = library_with_jpeg(tag); + let sidecar = src.with_extension("xmp"); + let appended_sidecar = dir.join("IMG_1.jpg.xmp"); + std::fs::write(&sidecar, b"existing sidecar").unwrap(); + std::fs::write(&appended_sidecar, b"appended sidecar").unwrap(); + std::fs::create_dir_all(dir.join("sub")).unwrap(); + let mut targets = vec![src.clone(), dir.join("sub/../IMG_1.jpg"), sidecar.clone()]; + targets.push(appended_sidecar.clone()); + #[cfg(unix)] + { + let alias = dir.join("alias.jpg"); + std::os::unix::fs::symlink(&src, &alias).unwrap(); + targets.push(alias); + let alias = dir.join("alias.xmp"); + std::os::unix::fs::symlink(&sidecar, &alias).unwrap(); + targets.push(alias); + } + for path in targets { + let mut params = selection.clone(); + params["path"] = json!(path.to_string_lossy()); + let error = s.execute(command, &params).unwrap_err().to_string(); + assert!(error.contains("never writes over an original"), "{error}"); + assert_eq!(std::fs::read(&src).unwrap(), original); + assert_eq!(std::fs::read(&sidecar).unwrap(), b"existing sidecar"); + assert_eq!(std::fs::read(&appended_sidecar).unwrap(), b"appended sidecar"); + } + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("preset.export", json!({"ids": ["lc.warm-glow"]}), "preset-export-guard"); +} + +#[test] +fn curve_preset_export_never_overwrites_originals_or_sidecars() { + preset_export_preserves_originals("curve.exportPresets", json!({"names": ["Linear"]}), "curve-export-guard"); +} + +#[test] +fn preset_exports_preserve_previous_files_on_write_failure() { + let dir = temp_dir("preset-export-failure"); + let mut s = Session::new(); + for (command, selection, file) in + [("preset.export", json!({"ids": ["lc.warm-glow"]}), "look.lcpreset"), ("curve.exportPresets", json!({"names": ["Linear"]}), "curve.lccurve")] + { + let path = dir.join(file); + let mut params = selection; + params["path"] = json!(path.to_string_lossy()); + s.execute(command, &params).unwrap(); + let previous = std::fs::read(&path).unwrap(); + { + let _fault = lightcraft_catalog::safe_file::fail_writes_after(4); + assert!(s.execute(command, &params).is_err(), "{command} must report the failed write"); + } + assert_eq!(std::fs::read(&path).unwrap(), previous, "{command} preserves the prior export"); + assert_eq!(s.execute(command, &params).unwrap()["count"], 1, "replacement still succeeds"); + } + assert_eq!(std::fs::read_dir(&dir).unwrap().count(), 2, "no temporary files remain"); + let _ = std::fs::remove_dir_all(dir); +} + fn disk_batch( s: &mut Session, id: lightcraft_catalog::PhotoId, @@ -516,6 +579,30 @@ fn remove_location_holds_for_original_and_dng_exports() { let _ = std::fs::remove_dir_all(&dir); } +/// `metadata: "NONE"` as `app.export` sends it: the JPEG carries no location or camera data +/// (it used to be read as `all` and carry both). +#[test] +fn metadata_none_in_any_case_exports_no_metadata() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + s.execute("photo.setMeta", &json!({"ids": [id.0], "gps": [48.85, 2.35], "city": "Paris"})).unwrap(); + let export = |s: &mut Session, extra: serde_json::Value| { + let mut p = json!({"format": "jpeg", "longEdge": 64}); + for (k, v) in extra.as_object().unwrap() { + p[k] = v.clone(); + } + lightcraft_meta::extract(&export_photo(s, id, &ExportOptions::from_params(&p).unwrap(), 1).unwrap().bytes) + }; + let all = export(&mut s, json!({})); + assert!(all.gps.is_some() && all.city.is_some(), "the default keeps everything: {all:?}"); + for v in ["none", "NONE", "None"] { + let m = export(&mut s, json!({"metadata": v})); + assert!(m.gps.is_none() && m.city.is_none() && m.model.is_none(), "{v}: {m:?}"); + } + let m = export(&mut s, json!({"metadata": "ALL", "removeLocation": true})); + assert!(m.gps.is_none() && m.city.is_none(), "{m:?}"); +} + /// An original raw whose own metadata holds GPS is refused under "Remove location info". #[test] fn remove_location_refuses_a_raw_that_embeds_gps() { @@ -874,3 +961,97 @@ mod apple_photos { assert_eq!(s.commands().into_iter().find(|c| c.id == "export.addToPhotos").unwrap().enabled, cfg!(target_os = "macos")); } } + +/// Issue #956: "Same folder as original photo" puts each photo's export next to its own original +/// (plus the subfolder), so one batch from two folders lands in two folders. The conflict policy +/// and the original guard work per folder as they do for a specific folder. +#[test] +fn same_folder_as_original_exports_next_to_each_photo() { + use crate::export::{Conflict, Destination, DestinationMode}; + let dir = temp_dir("export-same-folder"); + let (a, b) = (dir.join("A"), dir.join("B")); + // two different photos (the same bytes would be imported once) + let jpeg = |blue: u8| { + let img = lightcraft_raster::Rgba8::from_fn(48, 32, |x, y| [(x * 5) as u8, (y * 7) as u8, blue, 255]); + crate::export::encode_image(&img, &ExportOptions::default()).unwrap() + }; + let originals = [(&a, jpeg(120)), (&b, jpeg(30))]; + for (d, bytes) in &originals { + std::fs::create_dir_all(d).unwrap(); + std::fs::write(d.join("IMG_1.jpg"), bytes).unwrap(); + } + let mut s = Session::new().with_fs(); + let paths = [a.join("IMG_1.jpg").to_string_lossy().to_string(), b.join("IMG_1.jpg").to_string_lossy().to_string()]; + s.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + assert_eq!(ids.len(), 2); + // the folder of a specific-folder export is not used + let to = Destination { dir: dir.join("unused").to_string_lossy().to_string(), exact: None }; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let exists = |p: &str| std::path::Path::new(p).exists(); + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + let written: Vec<&str> = files.iter().map(|f| f["path"].as_str().unwrap()).collect(); + let mut want = vec![a.join("Exports/IMG_1.jpg").to_string_lossy().to_string(), b.join("Exports/IMG_1.jpg").to_string_lossy().to_string()]; + let mut got: Vec<String> = written.iter().map(|p| p.to_string()).collect(); + want.sort(); + got.sort(); + assert_eq!(got, want); + assert!(!dir.join("unused").exists(), "nothing written to the specific folder"); + // again: each folder's own collision gets the next free name + let files = crate::export::export_batch(&mut s, &ids, &o, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("Exports/IMG_1-2.jpg")), "{files:?}"); + // no subfolder: next to the original, which is never written over + let beside = ExportOptions { subfolder: String::new(), conflict: Conflict::Overwrite, ..o.clone() }; + let err = crate::export::export_batch(&mut s, &ids, &beside, &to, &mut crate::export::write_file, &exists).unwrap_err(); + assert!(err.contains("original of IMG_1.jpg"), "{err}"); + let unique = ExportOptions { conflict: Conflict::Unique, ..beside }; + let files = crate::export::export_batch(&mut s, &ids, &unique, &to, &mut crate::export::write_file, &exists).unwrap(); + assert!(files.iter().all(|f| f["path"].as_str().unwrap().ends_with("IMG_1-2.jpg")), "{files:?}"); + for (d, bytes) in &originals { + assert_eq!(&std::fs::read(d.join("IMG_1.jpg")).unwrap(), bytes, "the original is untouched"); + assert!(d.join("IMG_1-2.jpg").exists()); + } + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #956: a photo with no folder on disk (a demo photo) or whose folder is gone gets its own +/// error in "Same folder as original photo" mode instead of a file written somewhere else. +#[test] +fn same_folder_as_original_refuses_photos_without_a_folder() { + use crate::export::{Destination, DestinationMode}; + let o = ExportOptions { destination_mode: DestinationMode::SameAsOriginal, subfolder: "Exports".into(), ..Default::default() }; + let to = Destination { dir: "out".into(), exact: None }; + let mut s = Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().take(2).map(|p| p.id).collect(); + let mut written = Vec::new(); + // the writer lives in a block of its own, so its borrow of `written` has ended by the time + // the assertion below reads it + let files = { + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { + written.push(p.to_string()); + Ok(()) + }; + let err = crate::export::export_batch(&mut s, &ids, &o, &to, &mut write, &|_| false).unwrap_err(); + assert!(err.contains("no folder on disk"), "{err}"); + // without stopping: one error per photo, nothing written + let items = crate::export::prepare_batch(&mut s, &ids, &o).unwrap(); + crate::export::run_batch(items, &o, &to, &mut write, &|_| false, false, &mut |_, _| true).unwrap() + }; + assert_eq!(files.len(), 2); + for (f, id) in files.iter().zip(&ids) { + assert_eq!(f["photo"], id.0, "{f}"); + assert!(f["error"].as_str().unwrap().contains("no folder on disk"), "{f}"); + } + assert!(written.is_empty(), "{written:?}"); + + // a photo on disk whose folder is gone (moved, an unplugged drive) + let (mut s, id, dir, _src, _) = library_with_jpeg("export-same-folder-gone"); + let items = crate::export::prepare_batch(&mut s, &[id], &o).unwrap(); + let gone = dir.to_string_lossy().to_string(); + let exists = |p: &str| p != gone && !p.starts_with(&format!("{gone}/")); + let mut write = |p: &str, _: &[u8]| -> Result<(), String> { Err(format!("wrote {p}")) }; + let files = crate::export::run_batch(items, &o, &to, &mut write, &exists, false, &mut |_, _| true).unwrap(); + let e = files[0]["error"].as_str().unwrap(); + assert!(e.contains("folder is missing") && e.contains(&gone), "{e}"); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_face_models.rs b/crates/engine/src/tests_face_models.rs index a308f20fb..96b8a1b8c 100644 --- a/crates/engine/src/tests_face_models.rs +++ b/crates/engine/src/tests_face_models.rs @@ -179,7 +179,7 @@ fn hostile_ids_unsupported_models_and_odd_folders_are_handled() { /// Settings > Faces downloads; nothing is committed). Without it those tests say so and pass. fn demo_with_yunet() -> Option<Session> { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; let dir = temp("yunet"); @@ -627,3 +627,17 @@ fn recognition_cannot_be_turned_on_without_a_chosen_model() { assert!(s.execute("faces.enable", &json!({"enabled": true})).is_err()); let _ = std::fs::remove_dir_all(&d); } + +#[test] +fn the_cpu_only_switch_is_kept_and_bad_values_are_refused() { + let d = temp("cpu-only"); + let mut s = session(&d); + assert_eq!(s.execute("faces.gpu", &json!({})).unwrap()["cpuOnly"], false); + assert_eq!(s.execute("faces.gpu", &json!({"cpuOnly": true})).unwrap()["cpuOnly"], true); + // kept with the models, so a new session starts on the CPU too + let mut again = session(&d); + assert_eq!(again.execute("faces.models.list", &json!({})).unwrap()["gpu"]["cpuOnly"], true); + assert!(again.execute("faces.gpu", &json!({"cpuOnly": "yes"})).is_err()); + assert_eq!(again.execute("faces.gpu", &json!({"cpuOnly": false})).unwrap()["cpuOnly"], false); + let _ = std::fs::remove_dir_all(d); +} diff --git a/crates/engine/src/tests_face_recognize.rs b/crates/engine/src/tests_face_recognize.rs index aabcb03c3..b24a7bbb9 100644 --- a/crates/engine/src/tests_face_recognize.rs +++ b/crates/engine/src/tests_face_recognize.rs @@ -34,7 +34,7 @@ pub(crate) fn setup(d: &std::path::Path, dim: u64) -> (Session, String) { /// committed) and says whether it did: the parts of a test that need to find faces run only then. pub(crate) fn install_yunet(s: &mut Session) -> bool { let Some(file) = std::env::var_os("LC_YUNET_MODEL") else { - eprintln!("LC_YUNET_MODEL is not set: skipping the face-finding part"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set, so the face-finding part is not checked"); return false; }; let r = s.execute("faces.models.install", &json!({"path": file.to_string_lossy(), "acknowledged": true})); @@ -523,3 +523,98 @@ fn face_scan_row_follows_pending_photos() { assert!(faces_rows(&s).is_empty(), "the row goes when the scan is done"); let _ = std::fs::remove_dir_all(&d); } + +fn face_names(s: &Session, id: PhotoId) -> Vec<Option<String>> { + s.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Every in-library spelling of a person becomes the exact new name, including a change of case only, +/// and a person filter follows it. One undo step puts the old names back. +#[test] +fn rename_person_rewrites_every_spelling_and_undoes() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann")), region(0.5, Some(" ANN ")), region(0.7, Some("Rex"))]); + let mut pet = region(0.2, Some("Ann")); + pet.kind = RegionKind::Pet; + set_regions(&mut s, b, vec![region(0.3, Some("ann")), pet]); + s.execute("library.filter", &json!({"person": "ANN"})).unwrap(); + let undo = s.undo.len(); + let r = s.execute("faces.renamePerson", &json!({"from": " ann ", "to": " Ada "})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone(), r["to"].clone(), r["merged"].clone()), (json!(3), json!(2), json!("Ada"), json!(false))); + assert_eq!(face_names(&s, a), vec![Some("Ada".into()), Some("Ada".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("Ada".into())); + assert_eq!(face_names(&s, b)[1], Some("Ann".into()), "a pet is not a person"); + assert_eq!(s.catalog.people().iter().find(|p| p.name.eq_ignore_ascii_case("ada")).map(|p| p.name.as_str()), Some("Ada")); + assert_eq!(s.filter.person.as_deref(), Some("Ada")); + assert_eq!(s.undo.len(), undo + 1); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Rename Person")); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(face_names(&s, a), vec![Some("Ann".into()), Some(" ANN ".into()), Some("Rex".into())]); + assert_eq!(face_names(&s, b)[0], Some("ann".into())); +} + +/// Renaming onto someone else is refused until merge is asked for; then one undo step restores both people. +#[test] +fn rename_person_refuses_a_merge_until_it_is_asked_for() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + set_regions(&mut s, b, vec![region(0.3, Some("Bea"))]); + let before = (face_names(&s, a), face_names(&s, b), s.undo.len()); + let err = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea"})).unwrap_err(); + assert!(err.to_string().contains("already another person"), "{err}"); + assert_eq!((face_names(&s, a), face_names(&s, b), s.undo.len()), before, "a refusal changes nothing"); + let r = s.execute("faces.renamePerson", &json!({"from": "Ann", "to": "Bea", "merge": true})).unwrap(); + assert_eq!(r["merged"], true); + assert_eq!(s.undo.last().map(|u| u.label.as_str()), Some("Merge People")); + let names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["Bea".to_string()]); + s.execute("edit.undo", &json!({})).unwrap(); + let mut names: Vec<String> = s.catalog.people().into_iter().map(|p| p.name).collect(); + names.sort(); + assert_eq!(names, vec!["Ann".to_string(), "Bea".to_string()]); +} + +/// More faces than `faces.person` lists are still renamed. A bad call, a missing person, or a name that +/// is already exact changes nothing, and deleted or only-browsed photos are not part of the person. +#[test] +fn rename_person_covers_more_than_the_person_query_and_rejects_bad_input() { + let mut s = Session::with_demo(); + let (a, b) = two_photos(&s); + set_regions(&mut s, a, vec![region(0.1, Some("Ann"))]); + let undo = s.undo.len(); + for p in [ + json!({}), + json!({"from": "Ann"}), + json!({"from": " ", "to": "Ada"}), + json!({"from": 1, "to": "Ada"}), + json!({"from": "Ann", "to": ""}), + json!({"from": "Ann", "to": 5}), + json!({"from": "Ann", "to": "a\u{7}b"}), + json!({"from": "Ann", "to": "x".repeat(201)}), + json!({"from": "Nobody", "to": "Ada"}), + json!({"from": "Ann", "to": "Ann"}), + ] { + let err = s.execute("faces.renamePerson", &p).unwrap_err(); + assert!(s.undo.len() == undo, "{p} changed the catalog: {err}"); + } + assert_eq!(face_names(&s, a), vec![Some("Ann".into())]); + let regions: Vec<_> = (0..5001).map(|i| region(0.1, Some(if i == 0 { "ANN" } else { "Ann" }))).collect(); + set_regions(&mut s, a, regions); + set_regions(&mut s, b, vec![region(0.2, Some("Ann"))]); + s.commit("delete", Op::SetDeleted { id: b, deleted: true }).unwrap(); + let listed = s.execute("faces.person", &json!({"name": "Ann"})).unwrap(); + assert_eq!((listed["total"].clone(), listed["confirmed"].as_array().unwrap().len()), (json!(5001), 5000)); + let r = s.execute("faces.renamePerson", &json!({"from": "ann", "to": "Ada"})).unwrap(); + assert_eq!((r["faces"].clone(), r["photos"].clone()), (json!(5001), json!(1)), "the deleted photo is left alone"); + assert!(s.catalog.photo(a).unwrap().meta.regions.iter().all(|r| r.name.as_deref() == Some("Ada"))); + assert_eq!(s.catalog.photo(b).unwrap().meta.regions[0].name.as_deref(), Some("Ann")); + let local = s.catalog.photos().map(|p| p.id).find(|id| *id != a && *id != b).unwrap(); + set_regions(&mut s, local, vec![region(0.1, Some("Ada"))]); + s.commit("browse", Op::SetLocal { id: local, local: true }).unwrap(); + let r = s.execute("faces.renamePerson", &json!({"from": "Ada", "to": "Bea"})).unwrap(); + assert_eq!(r["photos"], 1, "the browsed photo is not another person and is not renamed"); + assert_eq!(s.catalog.photo(a).unwrap().meta.regions[0].name.as_deref(), Some("Bea")); + assert_eq!(s.catalog.photo(local).unwrap().meta.regions[0].name.as_deref(), Some("Ada")); +} diff --git a/crates/engine/src/tests_face_tiles.rs b/crates/engine/src/tests_face_tiles.rs index 3af23f0da..9aa4b05e8 100644 --- a/crates/engine/src/tests_face_tiles.rs +++ b/crates/engine/src/tests_face_tiles.rs @@ -222,7 +222,7 @@ fn face_tiles_never_crash() { assert!(job.run().rendered.is_ok()); let thumbs = s.library.as_ref().unwrap().thumbs_dir(); drop(s); - let entry = thumbs.join(&key[..2]).join(format!("{key}.jpg")); + let entry = thumbs.join(&key[..2]).join(format!("{key}.lcthumb")); assert!(entry.exists(), "the tile was written to the thumbnail cache"); std::fs::write(&entry, b"not a jpeg").unwrap(); let mut s = open(&dir); diff --git a/crates/engine/src/tests_focus_scan.rs b/crates/engine/src/tests_focus_scan.rs new file mode 100644 index 000000000..eadfbdbc2 --- /dev/null +++ b/crates/engine/src/tests_focus_scan.rs @@ -0,0 +1,297 @@ +//! Reading focus points for photos already in the library (`photo.readFocusPoints`, `focus.pump`): header-only reads, +//! batches committed without undo steps, "looked, found nothing" remembered across sessions, and the activity row. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::Session; +use crate::focus_scan::{read_focus, record_contains, record_len}; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-focus-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn sony_arw(mode: u8, samples: usize) -> Vec<u8> { + lightcraft_meta::focus::synthetic_arw(mode, samples) +} + +/// A photo of `camera` at `path` (an import made before focus points were read: none stored). +fn photo(id: u64, path: &Path, camera: &str) -> Photo { + let name = path.file_name().unwrap().to_string_lossy().into_owned(); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into_owned() }, &name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = camera.into(); + p +} + +fn add(s: &mut Session, photos: Vec<Photo>) { + s.execute_fn("test.add", |s| { + for p in photos { + s.apply_system(Op::AddPhoto { photo: Box::new(p) })?; + } + Ok(Value::Null) + }) + .unwrap(); +} + +fn focus(s: &Session, id: u64) -> Option<lightcraft_meta::FocusPoint> { + s.catalog.photo(PhotoId(id)).unwrap().meta.focus.clone() +} + +fn pump_until_idle(s: &mut Session) -> Value { + let started = Instant::now(); + loop { + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + if v["active"] == false { + return v; + } + assert!(started.elapsed() < Duration::from_secs(30), "the job never ended: {v}"); + std::thread::sleep(Duration::from_millis(5)); + } +} + +#[test] +fn the_header_read_reads_a_small_part_of_the_file() { + let dir = temp_dir("header"); + let path = dir.join("a.ARW"); + let file = sony_arw(3, 4_000_000); + std::fs::write(&path, &file).unwrap(); + let r = read_focus(&path.to_string_lossy()).unwrap(); + assert!(r.focus.is_some()); + assert_eq!(r.focus, lightcraft_meta::extract(&file).focus, "the same point as a whole-file read"); + assert!(r.bytes_read <= 32 * 1024 && r.bytes_read * 100 < file.len() as u64, "read {} of {}", r.bytes_read, file.len()); + assert!(read_focus(&dir.join("gone.ARW").to_string_lossy()).is_err()); +} + +/// Fills the missing points; leaves other cameras, other formats and known points alone without opening them; records +/// "looked, found nothing" (manual focus, garbage) but not a file it could not read; makes no undo step. +#[test] +fn fills_missing_points_and_remembers_photos_without_one() { + let dir = temp_dir("fill"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 100_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 100_000)).unwrap(); + let garbage = dir.join("garbage.ARW"); + std::fs::write(&garbage, vec![0x5a; 70_000]).unwrap(); + let heif = dir.join("x.HIF"); + std::fs::write(&heif, sony_arw(3, 1000)).unwrap(); + let known = lightcraft_meta::FocusPoint { + rect: lightcraft_geom::Rect { x0: 0.1, y0: 0.1, x1: 0.2, y1: 0.2 }, + mode: Some("AF-S".into()), + area: None, + tracking: None, + }; + let mut with_point = photo(4, &af, "SONY ILCE-1"); + with_point.meta.focus = Some(known.clone()); + let mut s = Session::new(); + add( + &mut s, + vec![ + photo(1, &af, "SONY ILCE-1"), + photo(2, &manual, "SONY ILCE-1"), + // another body's photo is not opened, even when its file would have a point + photo(3, &af, "Canon EOS R5"), + with_point, + photo(5, &dir.join("offline.ARW"), "SONY ILCE-7SM3"), + photo(6, &garbage, "SONY ILCE-1"), + photo(7, &heif, "SONY ILCE-1"), + ], + ); + let undo = s.undo.len(); + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"], 4, "{r}"); + let last = &r["last"]; + assert_eq!( + (last["done"].as_u64(), last["found"].as_u64(), last["none"].as_u64(), last["unavailable"].as_u64()), + (Some(4), Some(1), Some(2), Some(1)), + "{r}" + ); + assert!(last["bytesRead"].as_u64().unwrap() <= 3 * 32 * 1024, "{r}"); + assert_eq!(focus(&s, 1), lightcraft_meta::extract(&std::fs::read(&af).unwrap()).focus); + assert!(focus(&s, 1).is_some()); + assert_eq!(focus(&s, 1).unwrap().words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert_eq!(focus(&s, 2), None); + assert_eq!(focus(&s, 3), None); + assert_eq!(focus(&s, 4), Some(known), "a stored point is never replaced"); + assert_eq!(focus(&s, 5), None); + assert_eq!(focus(&s, 7), None); + assert!(record_contains(&s, PhotoId(2)) && record_contains(&s, PhotoId(6))); + assert!(!record_contains(&s, PhotoId(5)), "an unreadable file is tried again later"); + assert_eq!(record_len(&s), 2); + assert_eq!(s.undo.len(), undo, "no undo step"); + // asked again: only the offline photo is left + assert_eq!(s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap()["queued"], 1); + // named photos are looked at again, even those recorded as having none + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 3, 4], "wait": true})).unwrap()["queued"], 1); +} + +/// What was read survives a restart: the next session reads nothing again, and a photo whose file was offline is read +/// once it is back. The automatic start (once per library opening, from the pump) picks it up. +#[test] +fn resumes_after_a_restart_without_reading_again() { + let dir = temp_dir("resume"); + let lib = dir.join("library"); + let af = dir.join("af.ARW"); + std::fs::write(&af, sony_arw(3, 50_000)).unwrap(); + let manual = dir.join("manual.ARW"); + std::fs::write(&manual, sony_arw(0, 50_000)).unwrap(); + let later = dir.join("later.ARW"); + { + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + add(&mut s, vec![photo(1, &af, "SONY ILCE-1"), photo(2, &manual, "SONY ILCE-1"), photo(3, &later, "SONY ILCE-1")]); + // the pump starts it by itself + let first = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(first["active"], true, "{first}"); + let v = pump_until_idle(&mut s); + assert_eq!(v["last"]["found"], 1, "{v}"); + assert!(lib.join(crate::focus_scan::RECORD_FILE).exists()); + } + let mut s = Session::new(); + s.open_library(&lib, false).unwrap(); + assert!(focus(&s, 1).is_some(), "the point was saved with the library"); + // the file that was offline is back: only it is read + std::fs::write(&later, sony_arw(3, 50_000)).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert!(record_contains(&s, PhotoId(2)), "the record was read back"); + assert_eq!(v["running"]["total"], 1, "{v}"); + let v = pump_until_idle(&mut s); + assert_eq!((v["last"]["total"].as_u64(), v["last"]["found"].as_u64()), (Some(1), Some(1)), "{v}"); + assert!(focus(&s, 3).is_some()); + assert!(record_contains(&s, PhotoId(2))); + // and it doesn't start again in this opening + assert_eq!(s.execute("focus.pump", &json!({})).unwrap()["active"], false); +} + +/// The job has a row in the activity stack (n of N, cancellable); paused, it reads nothing new; cancelled (the row's ✕), +/// it commits what it read and the row goes away. +#[test] +fn the_activity_row_progresses_pauses_and_cancels() { + let dir = temp_dir("row"); + let mut photos = Vec::new(); + for i in 0..40u64 { + let p = dir.join(format!("{i:03}.ARW")); + std::fs::write(&p, sony_arw(3, 20_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-7SM3")); + } + let mut s = Session::new(); + add(&mut s, photos); + // the pump starts the job by itself, at the pace it is given: paused, while the user drags + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], true); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.kind == "focus").expect("an activity row"); + assert_eq!((row.label.as_str(), row.total, row.cancellable), ("Reading focus points", 40, true)); + // already running: asking again starts nothing new + let r = s.execute("photo.readFocusPoints", &json!({})).unwrap(); + assert_eq!((r["queued"].as_u64(), r["running"].as_bool()), (Some(0), Some(true))); + std::thread::sleep(Duration::from_millis(300)); + let still = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["done"].as_u64().unwrap(); + assert_eq!(still, 0, "paused: nothing read"); + // a few more at a light pace, then cancel + let started = Instant::now(); + let mut done = still; + while done < still + 3 && started.elapsed() < Duration::from_secs(20) { + std::thread::sleep(Duration::from_millis(20)); + done = s.execute("focus.pump", &json!({"pace": "light"})).unwrap()["running"]["done"].as_u64().unwrap_or(40); + } + let row = s.activity.list().into_iter().find(|t| t.kind == "focus").expect("still running"); + assert!(row.done >= 3, "progress shows: {}", row.done); + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + s.activity.cancel(row.id).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert_eq!(v["last"]["cancelled"], true, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus"), "the row is gone"); + let found = s.catalog.photos().filter(|p| p.meta.focus.is_some()).count() as u64; + assert_eq!(found, v["last"]["found"].as_u64().unwrap(), "what was read before the cancel is kept"); + assert!(found < 40); + // the rest are read next time + let r = s.execute("photo.readFocusPoints", &json!({"wait": true})).unwrap(); + assert_eq!(r["queued"].as_u64().unwrap(), 40 - found); + assert_eq!(s.catalog.photos().filter(|p| p.meta.focus.is_some()).count(), 40); +} + +/// Photos deleted, moved or given a point while the job runs are left alone; a library closed mid-run takes the job +/// with it (nothing lands in the next library), and nothing panics. +#[test] +fn changes_under_a_running_job_are_respected() { + let dir = temp_dir("changes"); + let mut photos = Vec::new(); + for i in 0..6u64 { + let p = dir.join(format!("{i}.ARW")); + std::fs::write(&p, sony_arw(3, 10_000)).unwrap(); + photos.push(photo(i + 1, &p, "SONY ILCE-1")); + } + let mut s = Session::new(); + s.open_library(dir.join("lib-a"), false).unwrap(); + add(&mut s, photos); + // started by the pump, paused + assert_eq!(s.execute("focus.pump", &json!({"pace": "pause"})).unwrap()["running"]["total"], 6); + // meanwhile: photo 1 removed, photo 2 relinked elsewhere, photo 3 given a point by the user's Reload, photo 4's file + // replaced by garbage, photo 5's deleted + let mine = + lightcraft_meta::FocusPoint { rect: lightcraft_geom::Rect { x0: 0.5, y0: 0.5, x1: 0.6, y1: 0.6 }, mode: None, area: None, tracking: None }; + s.execute_fn("test.change", |s| { + s.apply_system(Op::RemovePhoto { id: PhotoId(1) })?; + let moved = dir.join("moved.ARW"); + std::fs::copy(dir.join("1.ARW"), &moved).map_err(|e| crate::EngineError::Other(e.to_string()))?; + s.apply_system(Op::Relink { + id: PhotoId(2), + file_name: "moved.ARW".into(), + source: Source::File { path: moved.to_string_lossy().into() }, + format: None, + })?; + let mut meta = s.catalog.photo(PhotoId(3)).unwrap().meta.clone(); + meta.focus = Some(mine.clone()); + s.apply_system(Op::SetMeta { id: PhotoId(3), meta: Box::new(meta) })?; + Ok(Value::Null) + }) + .unwrap(); + std::fs::write(dir.join("3.ARW"), b"not a raw any more").unwrap(); + std::fs::remove_file(dir.join("4.ARW")).unwrap(); + let v = pump_until_idle(&mut s); + assert!(s.catalog.photo(PhotoId(1)).is_none()); + assert_eq!(focus(&s, 2), None, "read at the old path: left for another run"); + assert_eq!(focus(&s, 3), Some(mine)); + assert_eq!(focus(&s, 5), None); + assert!(focus(&s, 6).is_some()); + assert!(v["last"]["done"].as_u64().unwrap() <= 6, "{v}"); + // a library closed mid-run: the job goes with it + s.execute("focus.pump", &json!({"pace": "pause"})).unwrap(); + assert_eq!(s.execute("photo.readFocusPoints", &json!({"ids": [2, 4, 5]})).unwrap()["queued"], 3); + s.open_library(dir.join("lib-b"), false).unwrap(); + let v = s.execute("focus.pump", &json!({"pace": "normal"})).unwrap(); + assert_eq!(v["active"], false, "{v}"); + assert!(s.activity.list().iter().all(|t| t.kind != "focus")); + assert_eq!(s.catalog.photos().count(), 0); +} + +/// Opt-in measurement on real files: `LIGHTCRAFT_FOCUS_FILES=<a text file listing raw paths, one per line>` reads each +/// whole and by header, asserts they give the same focus point, and prints the bytes the header read took. Skipped +/// without it; no user file is part of the repository. +#[test] +fn real_files_header_read_matches_whole_file() { + let Ok(list) = std::env::var("LIGHTCRAFT_FOCUS_FILES") else { return }; + let paths: Vec<String> = std::fs::read_to_string(list).unwrap().lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_string).collect(); + let (mut n, mut with_point, mut bytes, mut max) = (0u64, 0u64, 0u64, 0u64); + for p in &paths { + let whole = lightcraft_meta::extract(&std::fs::read(p).unwrap()).focus; + let r = read_focus(p).unwrap(); + assert_eq!(r.focus, whole, "{p}"); + n += 1; + with_point += u64::from(whole.is_some()); + bytes += r.bytes_read; + max = max.max(r.bytes_read); + } + eprintln!( + "focus header reads: {n} files ({with_point} with a point) agree with whole-file reads; {} bytes each on average, {max} at most", + bytes / n.max(1) + ); +} diff --git a/crates/engine/src/tests_folders.rs b/crates/engine/src/tests_folders.rs index 697c98087..34017fc97 100644 --- a/crates/engine/src/tests_folders.rs +++ b/crates/engine/src/tests_folders.rs @@ -106,6 +106,21 @@ fn removing_a_folder_moves_its_photos_to_recently_deleted_and_undo_brings_them_b assert_eq!(s.visible().len(), 3, "one undo step"); } +#[test] +fn removing_a_folder_drops_its_photos_from_stacks() { + let mut s = session(); + let trip: Vec<_> = + s.catalog.photos().filter(|p| matches!(&p.source, Source::File { path } if path.contains("/pics/trip/"))).map(|p| p.id).collect(); + assert_eq!(trip.len(), 2); + s.execute("library.select", &json!({"ids": [trip[0].0, trip[1].0], "active": trip[0].0})).unwrap(); + s.execute("stack.group", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(trip[0]).unwrap().photos.len(), 2); + s.execute("library.removeFolder", &json!({"path": "/pics/trip"})).unwrap(); + assert!(s.catalog.stack_of(trip[0]).is_none() && s.catalog.stack_of(trip[1]).is_none()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(s.catalog.stack_of(trip[0]).unwrap().photos.len(), 2); +} + #[test] fn removing_a_folder_that_holds_nothing_or_a_whole_disk_is_refused() { let mut s = session(); diff --git a/crates/engine/src/tests_forget_local.rs b/crates/engine/src/tests_forget_local.rs index 14a985cd4..229b262a0 100644 --- a/crates/engine/src/tests_forget_local.rs +++ b/crates/engine/src/tests_forget_local.rs @@ -216,3 +216,46 @@ fn upgraded_catalogs_forget_nothing_at_first() { assert!(local_paths(&s).is_empty(), "the untouched legacy record goes once 30 days passed"); let _ = std::fs::remove_dir_all(&root); } + +/// Issue #1392: files deleted by another app after their folder was browsed in Local stayed in +/// it, failing to load (and logging an error) on every visit. Browsing the folder again forgets +/// the untouched records of files that are gone; a rated one stays (shown as missing), so does a +/// photo added to the library, and nothing on disk is touched. +#[test] +fn browsing_again_forgets_untouched_records_of_deleted_files() { + let root = temp_dir("gone"); + let (a, lib) = (root.join("a"), root.join("lib")); + for (i, n) in ["a1.png", "a2.png", "a3.png", "a4.png"].iter().enumerate() { + png(&a.join(n), i as u8); + } + let clock = Arc::new(Mutex::new("2026-08-01T10:00:00".to_string())); + let mut s = open(&lib, &clock); + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64()), (Some(4), Some(0)), "{r}"); + let id = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a2, a4) = (id(&s, "a2.png"), id(&s, "a4.png")); + s.selection = Selection::single(a2); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + s.execute("photo.addToLibrary", &json!({"ids": [a4.0]})).unwrap(); + look_elsewhere(&mut s); + for n in ["a1.png", "a2.png", "a4.png"] { + std::fs::remove_file(a.join(n)).unwrap(); + } + + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!((r["new"].as_u64(), r["forgotten"].as_u64(), r["photos"].as_u64()), (Some(0), Some(1), Some(3)), "{r}"); + assert_eq!(local_paths(&s), vec!["a2.png", "a3.png"], "the untouched record of a1 is forgotten, the rated a2 stays"); + assert!(s.catalog.photo(a4).is_some_and(|p| !p.local), "a library photo of a deleted file stays"); + assert!(a.join("a3.png").exists()); + assert!(s.undo.is_empty(), "not an undo step"); + // browsing once more changes nothing + let r = s.execute("library.browse", &json!({"path": a.to_string_lossy()})).unwrap(); + assert_eq!(r["forgotten"], 0, "{r}"); + + // journaled: the library opens in the same state + let expect = s.catalog.to_snapshot(); + drop(s); + let s = open(&lib, &clock); + assert_eq!(s.catalog.to_snapshot(), expect); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/tests_history_cursor.rs b/crates/engine/src/tests_history_cursor.rs index 55c265403..ffe300795 100644 --- a/crates/engine/src/tests_history_cursor.rs +++ b/crates/engine/src/tests_history_cursor.rs @@ -98,3 +98,20 @@ fn the_cursor_survives_the_op_log_and_old_catalogs_load_at_the_end() { let old: lightcraft_catalog::Photo = serde_json::from_value(v).unwrap(); assert_eq!((old.history_cursor, old.history_index()), (None, Some(n - 1))); } + +/// A negative `index` must be an error on every indexed command, not element 0 (`-1.0 as usize` +/// saturates to 0): `history.restore` guards it, `version.*` (and the other `index` commands) +/// must too. A caller sending `index: -1` used to delete or restore version 0 silently. +#[test] +fn negative_indices_are_refused() { + let mut s = Session::with_demo(); + let id = s.active().unwrap(); + let versions = |s: &Session| s.catalog.photo(id).unwrap().versions.len(); + s.execute("version.create", &json!({"name": "A"})).unwrap(); + assert_eq!(versions(&s), 1); + assert!(s.execute("version.delete", &json!({"index": -1})).is_err(), "must not delete version 0"); + assert_eq!(versions(&s), 1, "nothing was deleted"); + assert!(s.execute("version.restore", &json!({"index": -1})).is_err()); + assert!(s.execute("version.rename", &json!({"index": -1, "name": "B"})).is_err()); + assert_eq!(s.catalog.photo(id).unwrap().versions[0].name, "A", "version 0 was not renamed"); +} diff --git a/crates/engine/src/tests_hold_view.rs b/crates/engine/src/tests_hold_view.rs new file mode 100644 index 000000000..f7082498c --- /dev/null +++ b/crates/engine/src/tests_hold_view.rs @@ -0,0 +1,200 @@ +//! A view holds the photos it shows until it is refreshed (`library.holdView`): an edit that makes a photo stop +//! matching a smart album's rules, Picks or the filter bar leaves it where it is, and the others where they are. + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::{LibrarySource, Session}; + +/// The demo library with no photo keyworded, showing a smart album of the photos without keywords, at random. +fn unkeyworded() -> Session { + let mut s = Session::with_demo(); + let all: Vec<u64> = s.visible_cloned().iter().map(|p| p.0).collect(); + s.execute("photo.setMeta", &json!({"ids": all, "keywords": []})).unwrap(); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + s +} + +fn hold(s: &mut Session) { + assert_eq!(s.execute("library.holdView", &json!({"on": true})).unwrap()["hold"], true); +} + +fn keyword(s: &mut Session, id: PhotoId) { + s.execute("photo.setMeta", &json!({"ids": [id.0], "addKeywords": ["travel"]})).unwrap(); +} + +#[test] +fn a_keyword_given_in_a_held_view_leaves_the_photo_and_the_order_where_they_are() { + // Given a smart album of the photos without keywords, at random, held + let mut s = unkeyworded(); + hold(&mut s); + let (before, seed) = (s.visible_cloned(), s.sort.seed); + assert!(before.len() > 10); + // When photos in it are given a keyword, one after the other + for at in [3, 7, 0] { + keyword(&mut s, before[at]); + // Then nothing moves: the same photos in the same places, under the same shuffle + assert_eq!(s.visible_cloned(), before, "after a keyword on the photo at {at}"); + assert_eq!(s.sort.seed, seed); + } + // When the view is refreshed, Then they are gone and the others keep their order + s.execute("library.refresh", &json!({})).unwrap(); + let after = s.visible_cloned(); + let expected: Vec<PhotoId> = before.iter().copied().filter(|id| ![before[3], before[7], before[0]].contains(id)).collect(); + assert_eq!(after, expected); + assert_eq!(s.sort.seed, seed, "a refresh is not a reshuffle"); +} + +#[test] +fn a_view_that_is_not_held_follows_every_edit_as_before() { + let mut s = unkeyworded(); + assert!(!s.hold_view, "off unless asked for"); + let before = s.visible_cloned(); + keyword(&mut s, before[3]); + let after = s.visible_cloned(); + assert!(!after.contains(&before[3]), "the photo leaves at once"); + assert_eq!(after, before.iter().copied().filter(|id| *id != before[3]).collect::<Vec<_>>(), "the others keep their order"); +} + +#[test] +fn a_held_view_still_takes_in_what_starts_matching_and_lets_go_of_what_is_deleted() { + let mut s = unkeyworded(); + let all = s.visible_cloned(); + // one photo has a keyword before the view is held: it is not in it + keyword(&mut s, all[5]); + hold(&mut s); + let before = s.visible_cloned(); + assert!(!before.contains(&all[5])); + // its keyword taken off again: it matches, and comes in at its place in the shuffle + s.execute("photo.setMeta", &json!({"ids": [all[5].0], "keywords": []})).unwrap(); + assert_eq!(s.visible_cloned(), all, "back where the shuffle puts it"); + // a photo that was kept after it stopped matching, then deleted, is not kept in the view + keyword(&mut s, all[2]); + assert!(s.visible_cloned().contains(&all[2])); + s.execute("library.select", &json!({"ids": [all[2].0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&all[2]), "Recently Deleted is somewhere else"); +} + +#[test] +fn another_source_filter_sort_or_shuffle_is_another_view() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + // Reshuffle: a new order of what matches now + keyword(&mut s, before[1]); + assert!(s.visible_cloned().contains(&before[1])); + s.execute("library.shuffle", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&before[1]), "a reshuffle refreshes"); + // another sort + let now = s.visible_cloned(); + keyword(&mut s, now[1]); + s.execute("library.sort", &json!({"key": "fileName"})).unwrap(); + assert!(!s.visible_cloned().contains(&now[1]), "another sort refreshes"); + // the filter bar + let now = s.visible_cloned(); + keyword(&mut s, now[1]); + s.execute("library.filter", &json!({"rating": 0, "text": "a"})).unwrap(); + assert!(!s.visible_cloned().contains(&now[1]), "another filter refreshes"); + s.execute("library.filter", &json!({"text": ""})).unwrap(); + // away and back + let now = s.visible_cloned(); + let source = s.source; + keyword(&mut s, now[1]); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + let _ = s.visible_cloned(); + s.source = source; + assert!(!s.visible_cloned().contains(&now[1]), "coming back to it refreshes"); +} + +#[test] +fn picks_and_the_filter_bar_hold_too_and_an_album_lets_go_of_what_is_taken_out_of_it() { + let mut s = Session::with_demo(); + let all = s.visible_cloned(); + // Picks + s.execute("library.select", &json!({"ids": [all[0].0, all[1].0, all[2].0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "pick"})).unwrap(); + s.execute("library.source", &json!({"kind": "picks"})).unwrap(); + hold(&mut s); + let picks = s.visible_cloned(); + assert!(picks.len() >= 3, "{picks:?}"); + s.execute("library.select", &json!({"ids": [picks[1].0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "none"})).unwrap(); + assert_eq!(s.visible_cloned(), picks, "a pick unflagged stays until the view is refreshed"); + s.execute("library.refresh", &json!({})).unwrap(); + assert_eq!(s.visible_cloned(), picks.iter().copied().filter(|id| *id != picks[1]).collect::<Vec<_>>()); + // the filter bar over an album: a rating under the filter's stays, a photo taken out of the album goes + let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec<u64> = all.iter().take(5).map(|p| p.0).collect(); + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": ids})).unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.filter", &json!({"rating": 3})).unwrap(); + let shown = s.visible_cloned(); + assert_eq!(shown.len(), 5); + s.execute("library.select", &json!({"ids": [shown[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1})).unwrap(); + assert_eq!(s.visible_cloned().len(), 5, "rated under the filter: held"); + s.execute("album.removePhotos", &json!({"id": album, "ids": [shown[0].0, shown[1].0]})).unwrap(); + let left = s.visible_cloned(); + assert!(!left.contains(&shown[0]) && !left.contains(&shown[1]), "out of the album is out of the view: {left:?}"); + assert_eq!(left.len(), 3); + assert_eq!(s.source, LibrarySource::Album(lightcraft_catalog::AlbumId(album))); +} + +#[test] +fn turning_the_hold_off_lets_go_at_once() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + keyword(&mut s, before[4]); + assert!(s.visible_cloned().contains(&before[4])); + assert_eq!(s.execute("library.holdView", &json!({})).unwrap()["hold"], false, "toggles without `on`"); + assert!(!s.visible_cloned().contains(&before[4])); +} + +#[test] +fn changing_the_rules_of_the_smart_album_shown_is_another_view() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + let LibrarySource::Album(album) = s.source else { panic!("a smart album is shown") }; + // the album now asks for photos with a keyword: none has one, and none is kept from what it asked before + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "gt", "value": 0}]}}); + s.execute("album.setRules", &json!({"id": album.0, "rules": rules})).unwrap(); + assert!(s.visible_cloned().is_empty(), "the old rules' photos are not held under the new ones"); + // and undoing the change brings the first question back, with what matches it + s.undo_step().unwrap(); + assert_eq!(s.visible_cloned(), before); +} + +#[test] +fn a_source_with_no_filter_over_it_holds_nothing() { + // All Photos, an album or a folder with no filter: nothing an edit does can make a photo stop matching, so + // nothing is kept (and nothing is paid for the hold) + let mut s = Session::with_demo(); + hold(&mut s); + let all = s.visible_cloned(); + keyword(&mut s, all[0]); + assert_eq!(s.visible_cloned(), all); + // a photo deleted from All Photos leaves it, held or not + s.execute("library.select", &json!({"ids": [all[0].0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(!s.visible_cloned().contains(&all[0])); +} + +#[test] +fn asking_for_the_state_it_has_changes_nothing_and_a_word_is_not_a_bool() { + let mut s = unkeyworded(); + hold(&mut s); + let before = s.visible_cloned(); + keyword(&mut s, before[2]); + hold(&mut s); + assert!(s.visible_cloned().contains(&before[2]), "on, asked for again: the held photos stay"); + assert!(s.execute("library.holdView", &json!({"on": "false"})).is_err()); + assert!(s.hold_view, "a refused call changes nothing"); +} diff --git a/crates/engine/src/tests_import.rs b/crates/engine/src/tests_import.rs index ed4cc9162..13f02ce77 100644 --- a/crates/engine/src/tests_import.rs +++ b/crates/engine/src/tests_import.rs @@ -778,6 +778,50 @@ fn assisted_culling_groups_bursts_and_scores_focus() { let _ = std::fs::remove_dir_all(&dir); } +/// Assisted culling: two look-alike shots one second apart across midnight form a burst at the end of +/// every month, including those with fewer than 31 days. +#[test] +fn assisted_culling_groups_bursts_across_month_end() { + let dir = temp_dir("cull-month-end"); + for (name, (w, h)) in [("a.png", (480, 320)), ("b.png", (482, 321))] { + let img = lightcraft_scenes::demo_library()[0].render(w, h); + let data: Vec<[u8; 4]> = img + .data + .iter() + .map(|c| { + let e = |v: f32| (lightcraft_color::transfer::linear_to_srgb(v.clamp(0.0, 1.0)) * 255.0).round() as u8; + [e(c[0]), e(c[1]), e(c[2]), 255] + }) + .collect(); + let png = lightcraft_codecs::encode_png( + &lightcraft_codecs::EncodeImage::rgba8(&lightcraft_raster::Rgba8 { width: img.width, height: img.height, data }), + &Default::default(), + ) + .unwrap(); + std::fs::write(dir.join(name), png).unwrap(); + } + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [dir.to_string_lossy()]})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().filter_map(Value::as_u64).collect(); + let by_name = |s: &Session, n: &str| s.catalog.photos().find(|p| p.file_name == n).unwrap().id; + let (a, b) = (by_name(&s, "a.png"), by_name(&s, "b.png")); + for (before, after) in [ + ("2026-01-31T23:59:59", "2026-02-01T00:00:00"), + ("2026-02-28T23:59:59", "2026-03-01T00:00:00"), + ("2026-04-30T23:59:59", "2026-05-01T00:00:00"), + ("2026-06-30T23:59:59", "2026-07-01T00:00:00"), + ("2026-09-30T23:59:59", "2026-10-01T00:00:00"), + ("2026-11-30T23:59:59", "2026-12-01T00:00:00"), + ("2026-12-31T23:59:59", "2027-01-01T00:00:00"), + ] { + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: a, captured: Some(before.into()) }).unwrap(); + s.commit("t", lightcraft_catalog::Op::SetCaptured { id: b, captured: Some(after.into()) }).unwrap(); + let r = s.execute("photo.analyze", &json!({"ids": ids})).unwrap(); + assert_eq!(r["groups"], 1, "shots 1 s apart ({before} / {after}) must form a burst: {r}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + /// Find Dust Spots: soft dark spots on a smooth sky are found and healed (one undo step). #[test] fn dust_spots_are_found_and_healed() { @@ -917,6 +961,26 @@ fn missing_photos_skip_local_browse_records() { assert_eq!(s.visible_cloned().len(), 4, "the view lists what the count counts"); } +/// `library.browse` follows `library.source`'s rule for the active photo (issue #1447): a library +/// photo the folder doesn't show gives way to the folder's first photo; one it shows stays. +#[test] +fn browsing_a_folder_keeps_or_picks_the_active_photo() { + let dir = temp_dir("browse-active"); + write_png(&dir.join("a.png"), 1); + write_png(&dir.join("b.png"), 2); + let mut s = Session::with_demo().with_fs(); + let library = s.visible_cloned()[0]; + s.execute("library.select", &json!({"ids": [library.0]})).unwrap(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + let vis = s.visible_cloned(); + assert_eq!(vis.len(), 2); + assert_eq!(s.selection.active, Some(vis[0]), "the folder's first photo, not {library:?}"); + s.execute("library.select", &json!({"ids": [vis[1].0]})).unwrap(); + s.execute("library.browse", &json!({"path": dir.to_string_lossy()})).unwrap(); + assert_eq!(s.selection.active, Some(vis[1]), "a photo the folder shows stays active"); + let _ = std::fs::remove_dir_all(&dir); +} + /// Import a file that is in Recently Deleted (issues #298, #706): by default the photo comes /// back with its edits; `onDeleted: skip` leaves it there but the report says it is in the trash; /// `onDeleted: fresh` replaces the trashed record with a new one. @@ -1119,7 +1183,7 @@ fn emptying_trashed_photos_of_one_album_and_stack_leaves_nothing_dangling() { s.execute("edit.undo", &json!({})).unwrap(); assert_eq!(s.catalog.len(), 2, "one undo step brings both back"); assert_eq!(s.catalog.album(lightcraft_catalog::AlbumId(al)).unwrap().photos.len(), 2); - assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_some()); + assert!(s.catalog.stack_of(lightcraft_catalog::PhotoId(ids[0])).is_none(), "soft-delete already left the stack"); assert!(s.catalog.photos().all(|p| p.deleted), "back in Recently Deleted"); let _ = std::fs::remove_dir_all(&src); } @@ -1612,3 +1676,119 @@ fn upper_and_mixed_case_extensions_are_found_by_import_browse_and_sidecar_lookup } let _ = std::fs::remove_dir_all(&src); } +/// Sidecars are found and read whatever their name's case, header or byte-order mark, so a +/// raw's rating and crop never silently stay behind while the file's own IPTC comes in (fork #43). +#[test] +fn raw_sidecar_rating_and_crop_survive_naming_and_encoding_variants() { + use lightcraft_codecs::{ChromaSubsampling, EncodeImage, EncodeMeta, Samples, encode_jpeg}; + let px: Vec<u8> = (0..60 * 40).flat_map(|i| [(i % 251) as u8, 90, 200]).collect(); + let j = encode_jpeg(&EncodeImage::new(60, 40, 3, Samples::U8(&px)), 90, ChromaSubsampling::S444, &EncodeMeta::default()).unwrap(); + let mut nef = b"II*\0\x08\0\0\0\x02\0".to_vec(); + for (tag, v) in [(513u16, 38u32), (514, j.len() as u32)] { + nef.extend_from_slice(&tag.to_le_bytes()); + nef.extend_from_slice(&[4, 0, 1, 0, 0, 0]); + nef.extend_from_slice(&v.to_le_bytes()); + } + nef.extend_from_slice(&[0, 0, 0, 0]); + nef.extend_from_slice(&j); + let body = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"><rdf:Description rdf:about="" xmlns:xmp="http://ns.adobe.com/xap/1.0/" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" xmp:Rating="3" crs:HasCrop="True" crs:CropLeft="0.2" crs:CropTop="0.1" crs:CropRight="0.8" crs:CropBottom="0.9" crs:CropAngle="1.5"/></rdf:RDF></x:xmpmeta>"#; + let cases = [ + ("darktable-full", "DSC_0001.NEF.xmp", body.to_string()), + ("lower-full", "DSC_0001.nef.xmp", body.to_string()), + ("upper-full", "DSC_0001.NEF.XMP", body.to_string()), + ("mixed-stem", "DSC_0001.Xmp", body.to_string()), + ("bom-and-decl", "DSC_0001.xmp", format!("\u{feff}<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!-- written by another app -->\n{body}")), + ]; + for (name, file, text) in cases { + let src = temp_dir(&format!("sc-variant-{name}")); + std::fs::write(src.join("DSC_0001.NEF"), &nef).unwrap(); + std::fs::write(src.join(file), text).unwrap(); + let mut s = Session::new().with_fs(); + let r = s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!((ids(&r, "imported"), r["sidecars"].as_u64()), (1, Some(1)), "{name}: {r}"); + let p = s.catalog.photos().next().unwrap(); + assert_eq!(p.rating, 3, "{name}"); + let c = p.develop.to_json()["crop"]["geometry"].clone(); + assert!((c["rect"]["x0"].as_f64().unwrap_or(0.0) - 0.2).abs() < 1e-6, "{name}: {c}"); + if name == "lower-full" { + // a move takes that sidecar along + let dest = temp_dir("sc-variant-dest"); + let mut s = Session::new().with_fs(); + let args = json!({"paths": [src.to_string_lossy()], "mode": "move", "destination": dest.to_string_lossy(), "organize": "flat"}); + let r = s.execute("library.import", &args).unwrap(); + assert_eq!(s.catalog.photos().next().unwrap().rating, 3, "{r}"); + assert!(std::fs::read_dir(&dest).unwrap().flatten().any(|e| e.file_name().to_string_lossy().to_lowercase().ends_with(".xmp")), "{r}"); + assert!(!src.join(file).exists(), "no sidecar left behind: {r}"); + let _ = std::fs::remove_dir_all(&dest); + } + let _ = std::fs::remove_dir_all(&src); + } +} + +/// Issue #1001: a Lightroom preset that names a profile applies it once the user has imported a +/// profile of that name (before or after the preset), maps the basic Adobe profiles to ours, and +/// reports the ones it can't use. +#[test] +fn presets_apply_the_imported_profile_they_name() { + let src = temp_dir("lookref"); + let preset = |file: &str, name: &str, look: &str| { + let x = format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" + crs:PresetType="Normal" crs:Name="{name}" crs:UUID="{file}" crs:Exposure2012="0" crs:GrainAmount="20"> + <crs:Look><rdf:Description crs:Name="{look}" crs:Amount="0.6" crs:UUID="1234ABCD"> + <crs:Group><rdf:Alt><rdf:li xml:lang="x-default">Film Pack</rdf:li></rdf:Alt></crs:Group> + </rdf:Description></crs:Look> + </rdf:Description></rdf:RDF></x:xmpmeta>"# + ); + std::fs::write(src.join(format!("{file}.xmp")), x).unwrap(); + }; + preset("a1", "Portra Soft", "Portra 400"); + preset("a2", "Mono", "Adobe Monochrome"); + preset("a3", "Artsy", "Artistic 01"); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let by_name = |r: &serde_json::Value, n: &str| r["imported"].as_array().unwrap().iter().find(|i| i["name"] == n).cloned().unwrap(); + assert_eq!(by_name(&r, "Portra Soft")["unmapped"], json!(["Profile “Portra 400” (not imported yet)"]), "{r}"); + assert_eq!(by_name(&r, "Mono")["profile"], json!({"name": "Adobe Monochrome", "id": "lc.mono"})); + assert_eq!(by_name(&r, "Artsy")["unmapped"], json!(["Profile “Artistic 01” (Lightroom's own)"])); + let photo = s.catalog.photos().next().unwrap().id; + s.execute("library.select", &json!({"ids": [photo.0]})).unwrap(); + let pid = |s: &Session, n: &str| s.presets.iter().find(|p| p.name == n).unwrap().id.clone(); + let apply = |s: &mut Session, n: &str| { + let id = pid(s, n); + s.execute("preset.apply", &json!({"id": id})).unwrap(); + s.develop_of(photo).unwrap() + }; + // not imported yet: only grain comes across, the photo keeps its profile + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.grain.amount), ("lc.color", 20.0)); + // the basic Adobe profiles are ours + assert_eq!(apply(&mut s, "Mono").profile.id, "lc.mono"); + s.execute("develop.profile", &json!({"id": "lc.color", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Artsy").profile.id, "lc.color", "Lightroom's own creative profiles aren't ours"); + // importing the profile afterwards makes the preset use it, at the preset's amount + let mut cube = String::from("TITLE \"Portra 400\"\nLUT_3D_SIZE 2\n"); + for b in 0..2 { + for g in 0..2 { + for r in 0..2 { + cube.push_str(&format!("{r} {g} {b}\n")); + } + } + } + std::fs::create_dir_all(src.join("profiles/Film Pack")).unwrap(); + std::fs::write(src.join("profiles/Film Pack/portra.cube"), &cube).unwrap(); + let r = s.execute("profile.import", &json!({"paths": [src.join("profiles").to_string_lossy()]})).unwrap(); + let lut = r["imported"][0]["id"].as_str().unwrap().to_string(); + let d = apply(&mut s, "Portra Soft"); + assert_eq!((d.profile.id.as_str(), d.profile.amount, d.grain.amount), (lut.as_str(), 60.0, 20.0)); + // a preset imported after the profile resolves straight away + preset("a4", "Portra Hard", "portra 400"); + let r = s.execute("preset.import", &json!({"paths": [src.join("a4.xmp").to_string_lossy()]})).unwrap(); + assert_eq!((r["imported"][0]["profile"]["id"].as_str(), r["imported"][0]["unmapped"].clone()), (Some(lut.as_str()), json!([])), "{r}"); + // deleting the profile leaves the photo's own profile alone again + s.execute("profile.deleteImported", &json!({"id": lut})).unwrap(); + s.execute("develop.profile", &json!({"id": "lc.vivid", "amount": 100})).unwrap(); + assert_eq!(apply(&mut s, "Portra Soft").profile.id, "lc.vivid"); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/tests_library.rs b/crates/engine/src/tests_library.rs index 8a2599e5c..5460dc020 100644 --- a/crates/engine/src/tests_library.rs +++ b/crates/engine/src/tests_library.rs @@ -440,6 +440,21 @@ fn scale_100k_library_queries() { m("undo", &mut || { s.execute("edit.undo", &serde_json::json!({})).unwrap(); }); + // a 20k-photo album shown in its own order (capture-date sort, no filter) + let big = s.execute("album.create", &serde_json::json!({"name": "Big"})).unwrap()["id"].as_u64().unwrap(); + let ids: Vec<u64> = (0..n).rev().step_by(5).map(|i| i + 1).collect(); + m("add 20k to an album", &mut || { + s.execute("album.addPhotos", &serde_json::json!({"id": big, "ids": ids})).unwrap(); + }); + m("album of 20k (album order)", &mut || { + s.execute("library.source", &serde_json::json!({"kind": "album", "id": big})).unwrap(); + let _ = s.visible_cloned(); + }); + s.execute("library.source", &serde_json::json!({"kind": "all"})).unwrap(); + let half: Vec<u64> = ids.iter().copied().step_by(2).collect(); + m("remove 10k from the album", &mut || { + s.execute("album.removePhotos", &serde_json::json!({"id": big, "ids": half})).unwrap(); + }); worst.sort_by(|a, b| b.1.total_cmp(&a.1)); eprintln!("slowest: {:?}", &worst[..3]); } @@ -675,3 +690,74 @@ fn an_album_filter_loop_is_refused() { 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); } + +/// Given a library whose thumbnail cache is from before (`.jpg` files in `thumbs`), when it is +/// opened, then the cache is tidied in the background with a row in the activity stack: the +/// thumbnails are taken over under a name that is no picture's, the folder is tagged as a +/// cache, and the next time the library opens there is nothing to tidy and no row. +#[test] +fn a_thumbnail_cache_from_before_is_tidied_in_the_background_when_the_library_opens() { + let dir = temp_dir("tidy-thumbs"); + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + assert!(s.activity.list().is_empty(), "a new library has nothing to tidy"); + s.close_library().unwrap(); + drop(s); + // the cache as an older build left it + let thumbs = dir.join("thumbs"); + let _ = std::fs::remove_dir_all(&thumbs); + let img = lightcraft_raster::Rgba8 { width: 32, height: 20, data: vec![[90, 120, 150, 255]; 640] }; + let jpeg = lightcraft_preview::encode_jpeg(&img).unwrap(); + let names: Vec<String> = (0..2000u16).map(|i| lightcraft_preview::hash_bytes(&i.to_le_bytes()).to_string()).collect(); + for hex in &names { + std::fs::create_dir_all(thumbs.join(&hex[..2])).unwrap(); + std::fs::write(thumbs.join(&hex[..2]).join(format!("{hex}.jpg")), &jpeg).unwrap(); + } + + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + // (two thousand files to rename: the row is there when the library has just opened) + let rows = s.activity.list(); + assert!(rows.iter().any(|r| r.kind == "cache" && r.label == "Tidying the thumbnail cache" && r.cancellable), "{rows:?}"); + let t0 = std::time::Instant::now(); + while !s.activity.list().is_empty() && t0.elapsed() < std::time::Duration::from_secs(20) { + std::thread::sleep(std::time::Duration::from_millis(5)); + } + assert!(s.activity.list().is_empty()); + for hex in &names { + let shard = thumbs.join(&hex[..2]); + assert!(shard.join(format!("{hex}.lcthumb")).is_file() && !shard.join(format!("{hex}.jpg")).exists(), "{hex}"); + } + assert!(thumbs.join("CACHEDIR.TAG").is_file()); + s.close_library().unwrap(); + drop(s); + let mut s = Session::new().with_fs(); + s.open_library(&dir, true).unwrap(); + assert!(s.activity.list().is_empty(), "tidied once"); + drop(s); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn the_hold_of_the_view_is_kept_with_the_library_and_holds_nothing_over_from_another() { + let dir = temp_dir("holdview"); + let mut s = open(&dir, true); + assert!(!s.hold_view, "off in a library that never asked for it"); + s.execute("library.holdView", &json!({"on": true})).unwrap(); + // a filter over the demo photos, and one of them edited out from under it: held + s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap(); + let shown = s.visible_cloned(); + let id = *shown.first().expect("the seeded library has unflagged photos"); + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.flag", &json!({"flag": "pick"})).unwrap(); + assert!(s.visible_cloned().contains(&id)); + s.close_library().unwrap(); + drop(s); + // opened again: the hold is on, and what was held is not (a library opens on what matches) + let mut s = open(&dir, false); + assert!(s.hold_view); + s.execute("library.filter", &json!({"rating": 0, "flag": "none"})).unwrap(); + assert!(!s.visible_cloned().contains(&id)); + s.close_library().unwrap(); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/engine/src/tests_mask_effects.rs b/crates/engine/src/tests_mask_effects.rs new file mode 100644 index 000000000..18572449d --- /dev/null +++ b/crates/engine/src/tests_mask_effects.rs @@ -0,0 +1,293 @@ +//! Mask effect presets end to end: managing them (rename, update, duplicate, delete, stars, +//! groups), applying (several masks, other photos, new masks remembering the effect), saving +//! some sliders, and importing Lightroom / Camera Raw local presets from folders and zips. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-effects-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A Lightroom local preset (synthetic values) in the Lua shape Lightroom writes. +fn lr_local(title: &str, value: &str) -> String { + format!( + "s = {{\n\tid = \"0\",\n\tinternalName = \"{title}\",\n\ttitle = \"{title}\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {{\n{value}\n\t}},\n\tversion = 0,\n}}" + ) +} + +const ZERO: &str = "\t\texposure2012 = 0,\n\t\ttoningHue = 240,\n\t\ttoningSaturation = 0,"; + +fn names(v: &Value) -> Vec<String> { + v["effects"].as_array().unwrap().iter().map(|e| e["name"].as_str().unwrap().to_string()).collect() +} + +fn mask_adjust(s: &Session, id: u32) -> lightcraft_develop::LocalAdjustments { + s.develop_of(s.active().unwrap()).unwrap().masks.iter().find(|m| m.id == id).unwrap().adjust +} + +fn with_effect(s: &mut Session, name: &str, values: Value) { + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": values})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": name})).unwrap(); + s.execute("mask.delete", &json!({})).unwrap(); +} + +#[test] +fn managing_effects() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Lift", json!({"shadows": 30})); + with_effect(&mut s, "Dark", json!({"exposure": -1})); + // rename (not onto another one's name) + assert!(s.execute("mask.renameEffect", &json!({"name": "lift", "to": "dark"})).is_err()); + assert!(s.execute("mask.renameEffect", &json!({"name": "nope", "to": "x"})).is_err()); + let v = s.execute("mask.renameEffect", &json!({"name": "lift", "to": "Lift Shadows"})).unwrap(); + assert_eq!(names(&v), ["Lift Shadows", "Dark"]); + // duplicate: "<name> copy", then "<name> copy 2" + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy"); + let v = s.execute("mask.duplicateEffect", &json!({"name": "Dark"})).unwrap(); + assert_eq!(v["name"], "Dark copy 2"); + assert!(s.execute("mask.duplicateEffect", &json!({"name": "Dark", "to": "dark copy"})).is_err()); + // stars and groups + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(s.mask_effects[2].favorite); + s.execute("mask.favoriteEffect", &json!({"name": "Dark copy"})).unwrap(); + assert!(!s.mask_effects[2].favorite, "toggles"); + s.execute("mask.setEffectGroup", &json!({"names": ["Dark", "Dark copy"], "group": "Burn"})).unwrap(); + s.execute("mask.setEffectGroup", &json!({"name": "Dark copy 2", "group": "burn"})).unwrap(); + assert_eq!(s.mask_effects[3].group, "Burn", "an existing group's spelling wins"); + let v = s.execute("mask.effects", &json!({})).unwrap(); + assert_eq!(v["groups"], json!([{"name": "", "effects": ["Lift Shadows"]}, {"name": "Burn", "effects": ["Dark", "Dark copy", "Dark copy 2"]}])); + s.execute("mask.renameEffectGroup", &json!({"group": "BURN", "to": "Darken"})).unwrap(); + assert!(s.mask_effects.iter().skip(1).all(|e| e.group == "Darken")); + assert!(s.execute("mask.renameEffectGroup", &json!({"group": "Burn", "to": "x"})).is_err()); + // update with the selected mask's sliders + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"exposure": -2, "clarity": 10}})).unwrap(); + s.execute("mask.updateEffect", &json!({"name": "Dark"})).unwrap(); + let dark = &s.mask_effects[1]; + assert_eq!((dark.adjust.exposure, dark.adjust.clarity, dark.group.as_str()), (-2.0, 10.0, "Darken")); + // delete several (the menu asks first) + s.execute("mask.deleteEffect", &json!({"names": ["Dark copy", "Dark copy 2"]})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"names": ["Dark", "gone"]})).is_err(), "nothing deleted when one is missing"); + assert_eq!(s.mask_effects.len(), 2); + for bad in [json!({}), json!({"name": ""}), json!({"name": 3}), json!({"names": [1, 2]})] { + assert!(s.execute("mask.deleteEffect", &bad).is_err(), "{bad}"); + assert!(s.execute("mask.favoriteEffect", &bad).is_err()); + } +} + +#[test] +fn saving_some_sliders_and_amount() { + let mut s = Session::with_demo(); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"shadows": 25, "texture": -10, "amount": 60}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Only", "fields": "changed", "amount": true, "brush": {"size": 0.1, "flow": 40}})).unwrap(); + let e = &s.mask_effects[0]; + assert_eq!(e.fields, ["shadows", "texture"]); + assert!(e.with_amount); + assert_eq!(e.brush.map(|b| (b.size, b.flow)), Some((0.1, 40.0))); + s.execute("mask.saveEffect", &json!({"name": "Pick", "fields": ["exposure", "bogus"]})).unwrap(); + assert_eq!(s.mask_effects[1].fields, ["exposure"]); + for bad in [ + json!({"name": "x", "fields": []}), + json!({"name": "x", "fields": 3}), + json!({"name": "x", "fields": "some"}), + json!({"name": "x", "brush": 7}), + ] { + assert!(s.execute("mask.saveEffect", &bad).is_err(), "{bad}"); + } + // applying a partial effect leaves the other sliders and sets Amount; it brings its brush + s.execute("mask.adjust", &json!({"values": {"shadows": 0, "texture": 0, "clarity": 50, "amount": 100}})).unwrap(); + let mid = s.active_mask.unwrap(); + let r = s.execute("mask.applyEffect", &json!({"name": "only"})).unwrap(); + assert_eq!(r["brush"]["flow"], 40.0); + let a = mask_adjust(&s, mid); + assert_eq!((a.shadows, a.texture, a.clarity, a.amount), (25.0, -10.0, 50.0, 60.0)); + // a mask that changes nothing has nothing to save as "changed" + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert!(s.execute("mask.saveEffect", &json!({"name": "Empty", "fields": "changed"})).is_err()); +} + +#[test] +fn applying_to_several_masks_and_photos_is_one_undo_step() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Bright", json!({"exposure": 1})); + let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + for _ in 0..2 { + s.execute("mask.add", &json!({"kind": "radial", "name": "Face"})).unwrap(); + } + s.execute("mask.add", &json!({"kind": "linear", "name": "Sky"})).unwrap(); + let all: Vec<u32> = s.develop_of(s.active().unwrap()).unwrap().masks.iter().map(|m| m.id).collect(); + let face = all[all.len() - 3]; + s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [face, all[all.len() - 2]], "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, face).exposure, 1.0); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 0.0, "only the masks named"); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": [999]})).is_err()); + assert!(s.execute("mask.applyEffect", &json!({"name": "Bright", "ids": []})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "Bright", "all": true, "remember": false})).unwrap(); + assert_eq!(mask_adjust(&s, *all.last().unwrap()).exposure, 1.0); + // the other selected photo's "Face" mask gets the effect too, in the same undo step + s.execute("library.select", &json!({"ids": ids, "active": ids[1]})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "face"})).unwrap(); + s.execute("mask.add", &json!({"kind": "radial", "name": "Other"})).unwrap(); + s.execute("library.select", &json!({"ids": ids, "active": ids[0]})).unwrap(); + with_effect(&mut s, "Warm", json!({"temp": 20})); + let r = s.execute("mask.applyEffect", &json!({"name": "Warm", "id": face, "photos": "selected", "remember": false})).unwrap(); + assert_eq!(r["photos"], 2); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks.iter().map(|m| m.adjust.temp).collect::<Vec<_>>(), [20.0, 0.0]); + s.execute("edit.undo", &json!({})).unwrap(); + let other = s.develop_of(lightcraft_catalog::PhotoId(ids[1])).unwrap(); + assert_eq!(other.masks[0].adjust.temp, 0.0, "undone on every photo"); + assert_eq!(mask_adjust(&s, face).temp, 0.0); +} + +#[test] +fn new_masks_start_with_the_last_effect_of_their_tool() { + let mut s = Session::with_demo(); + with_effect(&mut s, "Dodge", json!({"exposure": 0.5})); + with_effect(&mut s, "Grad", json!({"highlights": -40})); + s.execute("mask.add", &json!({"kind": "brush"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Dodge"})).unwrap(); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + s.execute("mask.applyEffect", &json!({"name": "Grad"})).unwrap(); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["toolEffects"], json!({"brush": "Dodge", "linear": "Grad"})); + // a new brush mask (painted with no mask selected) starts as Dodge, a new gradient as Grad + s.execute("mask.select", &json!({"id": null})).unwrap(); + s.execute("mask.brushStroke", &json!({"points": [[0.2, 0.2], [0.3, 0.3]]})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.5); + s.execute("mask.add", &json!({"kind": "linear"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).highlights, -40.0); + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + assert_eq!(mask_adjust(&s, s.active_mask.unwrap()).exposure, 0.0, "radial has none"); + // set / clear explicitly; renaming and deleting the effect follow + s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "dodge"})).unwrap(); + assert!(s.execute("mask.toolEffect", &json!({"tool": "radial", "name": "nope"})).is_err()); + s.execute("mask.renameEffect", &json!({"name": "Dodge", "to": "Dodge 2"})).unwrap(); + assert_eq!(s.mask_tool_effects.get("brush").map(String::as_str), Some("Dodge 2")); + s.execute("mask.deleteEffect", &json!({"name": "Dodge 2"})).unwrap(); + assert!(!s.mask_tool_effects.contains_key("brush") && !s.mask_tool_effects.contains_key("radial")); + s.execute("mask.toolEffect", &json!({"tool": "linear"})).unwrap(); + assert!(s.mask_tool_effects.is_empty()); +} + +#[test] +fn lightroom_folder_import_groups_by_dividers_and_round_trips() { + let lib = temp_dir("folder"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(src.join("Kit")).unwrap(); + let files: [(&str, String); 7] = [ + ("01 Burn.lrtemplate", lr_local("01 Burn", "\t\texposure2012 = -0.25,")), + ("10 ----- DETAIL ----- 10.lrtemplate", lr_local("10 ----- DETAIL ----- 10", ZERO)), + ("12 Sky.lrtemplate", lr_local("12 Sky | Cloud", "\t\thighlights2012 = -1,\n\t\tgrain = 0.06,")), + ("20 ----- RETOUCHING ----- 20.lrtemplate", lr_local("20 ----- RETOUCHING ----- 20", ZERO)), + ("21 Skin.lrtemplate", lr_local("21 Skin", "\t\tclarity2012 = -1,\n\t\tsharpness = -0.25,")), + ("Kit/Glow.lrtemplate", lr_local("Glow", "\t\tdehaze = -0.2,")), + ("Look.lrtemplate", "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }".to_string()), + ]; + for (f, text) in &files { + std::fs::write(src.join(f), text).unwrap(); + } + std::fs::write(src.join("broken.lrtemplate"), "s = { type = \"LocalizedAdjustmentPreset\", value = 5 }").unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // an earlier import that kept the dividers as effects + s.mask_effects.push(crate::cmd::mask_effects::MaskEffect { name: "10 ----- DETAIL ----- 10".into(), ..Default::default() }); + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effectDividers"].as_array().unwrap().len(), 2); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{r}"); + let groups: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!( + groups, + [ + ("01 Burn".to_string(), "".to_string()), + ("12 Sky | Cloud".into(), "Detail".into()), + ("21 Skin".into(), "Retouching".into()), + ("Glow".into(), "Kit".into()), + ], + "the old divider effect is gone; folders and dividers give groups" + ); + let sky = &r["effects"].as_array().unwrap().iter().find(|e| e["name"] == "12 Sky | Cloud").unwrap()["unmapped"]; + assert_eq!(sky, &json!(["Grain"])); + // the effects' own import: a dry run changes nothing + let r = s.execute("mask.importEffects", &json!({"path": src.to_string_lossy(), "dryRun": true, "group": "X"})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 4); + assert!(s.mask_effects.iter().all(|e| e.group != "X")); + // export one group, everything; re-import keeps groups, stars and the kept grain + s.execute("mask.favoriteEffect", &json!({"name": "21 Skin"})).unwrap(); + let out = lib.join("backup.lceffect"); + let r = s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "retouching"})).unwrap(); + assert_eq!(r["count"], 1); + assert!(s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy(), "group": "none"})).is_err()); + s.execute("mask.exportEffects", &json!({"path": out.to_string_lossy()})).unwrap(); + let before = s.mask_effects.clone(); + s.execute("mask.deleteEffect", &json!({"names": before.iter().map(|e| e.name.clone()).collect::<Vec<_>>()})).unwrap(); + // through the general import too (File ▸ Import Profiles & Presets…, drag & drop) + let r = s.execute("preset.import", &json!({"paths": [out.to_string_lossy()]})).unwrap(); + assert_eq!(r["effects"].as_array().unwrap().len(), 4); + assert_eq!(s.mask_effects, before, "round trip"); + assert_eq!(s.mask_effects[1].extra.get("grain"), Some(&json!(0.06))); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.mask_effects.len(), 4, "persisted"); + assert!(s.mask_effects[2].favorite); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn zips_and_camera_raw_xmp_presets_import() { + let lib = temp_dir("zip"); + let xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:PresetType="Local" crs:LocalSaturation="-0.5"> + <crs:Name><rdf:Alt><rdf:li xml:lang="x-default">Mute</rdf:li></rdf:Alt></crs:Name> +</rdf:Description></rdf:RDF></x:xmpmeta>"#; + let develop_xmp = r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> +<rdf:Description rdf:about="" xmlns:crs="http://ns.adobe.com/camera-raw-settings/1.0/" crs:Exposure2012="0.5" crs:Name="Bright"/></rdf:RDF></x:xmpmeta>"#; + let brush = lr_local("Brush A", "\t\tshadows2012 = 0.5,"); + let zip = crate::preset_import::tests::zip( + &[("Skin Kit/Brush A.lrtemplate", brush.as_bytes()), ("Skin Kit/Mute.xmp", xmp.as_bytes()), ("Looks/Bright.xmp", develop_xmp.as_bytes())], + true, + ); + let path = lib.join("bundle.zip"); + std::fs::write(&path, zip).unwrap(); + let mut s = Session::with_demo(); + let r = s.execute("preset.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop XMP: {r}"); + assert_eq!(r["effects"].as_array().unwrap().len(), 2); + let got: Vec<(String, String)> = s.mask_effects.iter().map(|e| (e.name.clone(), e.group.clone())).collect(); + assert_eq!(got, [("Brush A".to_string(), "Skin Kit".to_string()), ("Mute".into(), "Skin Kit".into())]); + assert_eq!(s.mask_effects[1].adjust.saturation, -50.0); + // the effects' own import reads the zip and a lone XMP; a develop XMP named on its own fails + let mut s = Session::with_demo(); + let lone = lib.join("Mute.xmp"); + std::fs::write(&lone, xmp).unwrap(); + let dev = lib.join("Bright.xmp"); + std::fs::write(&dev, develop_xmp).unwrap(); + let r = s.execute("mask.importEffects", &json!({"paths": [path.to_string_lossy(), lone.to_string_lossy(), dev.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 3); + assert_eq!(r["failed"].as_array().unwrap().len(), 1); + assert_eq!(s.mask_effects.len(), 2, "same names replaced"); + // unreadable things are errors, never panics + std::fs::write(lib.join("junk.zip"), b"PK\x03\x04\xff\xff").unwrap(); + std::fs::write(lib.join("junk.lceffect"), b"{\"effects\": [{\"name\": \"x\", \"adjust\": {\"exposure\": \"high\"}}]}").unwrap(); + for f in ["junk.zip", "junk.lceffect", "missing.lrtemplate"] { + let r = s.execute("mask.importEffects", &json!({"path": lib.join(f).to_string_lossy()})).unwrap(); + assert_eq!(r["failed"].as_array().unwrap().len(), 1, "{f}: {r}"); + let _ = s.execute("preset.import", &json!({"paths": [lib.join(f).to_string_lossy()]})); + } + assert!(s.execute("mask.importEffects", &json!({})).is_err()); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_merge.rs b/crates/engine/src/tests_merge.rs index cb42dbc2a..aaa59acb1 100644 --- a/crates/engine/src/tests_merge.rs +++ b/crates/engine/src/tests_merge.rs @@ -164,7 +164,65 @@ fn panorama_command_with_auto_crop() { assert!(p.width > 800 && p.height > 300, "{}×{}", p.width, p.height); let crop = s.develop_of(id).unwrap().crop.geometry.rect; assert!(crop.width() < 1.0 && crop.width() * crop.height() > 0.5, "{crop:?}"); + // the photos' own resolution, so nothing held it back (issue #1455) + let (mp, native) = (r["megapixels"].as_f64().unwrap(), r["nativeMegapixels"].as_f64().unwrap()); + assert!((mp / native - 1.0).abs() < 0.02 && r["limit"].is_null(), "{r}"); + // a size asked for says so + let small = + s.execute("merge.panorama", &json!({"ids": ids, "projection": "cylindrical", "autoSettings": false, "maxMegapixels": native / 2.0})).unwrap(); + assert_eq!(small["limit"], "asked", "{small}"); + assert!(small["megapixels"].as_f64().unwrap() <= native / 2.0 * 1.02, "{small}"); // too few photos assert!(s.execute("merge.panorama", &json!({"ids": [ids[0]]})).is_err()); let _ = std::fs::remove_dir_all(&dir); } + +/// HDR for Each Bracket Set: two stacks of brackets → two merged DNGs, each next to its first photo; photos that form +/// no set are refused. +#[test] +fn hdr_batch_merges_each_stack() { + let dir = temp_dir("hdr-batch"); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5])] { + for (i, b) in lightcraft_merge::synth::bracket_dngs(240, 160, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{i}.dng")); + std::fs::write(&p, b).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + } + let mut s = Session::new().with_fs(); + let ids = import(&mut s, paths); + assert_eq!(ids.len(), 6); + let err = s.execute("merge.hdrBatch", &json!({"ids": [ids[0]]})).unwrap_err().to_string(); + assert!(err.contains("no bracket sets"), "{err}"); + s.execute("stack.group", &json!({"ids": ids[..3]})).unwrap(); + s.execute("stack.group", &json!({"ids": ids[3..]})).unwrap(); + let r = s.execute("merge.hdrBatch", &json!({"ids": [ids[0], ids[3]], "autoSettings": false})).unwrap(); + let merged = r["merged"].as_array().unwrap(); + assert_eq!(merged.len(), 2, "{r}"); + assert!(r["failed"].as_array().unwrap().is_empty(), "{r}"); + let names: Vec<&str> = merged.iter().map(|m| m["path"].as_str().unwrap()).collect(); + assert!(names[0].ends_with("A_0-HDR.dng") && names[1].ends_with("B_0-HDR.dng"), "{names:?}"); + assert_eq!(s.catalog.photos().count(), 8); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Issue #1455: a panorama keeps its photos' resolution, held to what fits in memory beside them +/// (never below the 40 MP every panorama used to have), and never above a size asked for. +#[test] +fn panorama_size_is_held_to_memory() { + use crate::merge::{MIN_FITTED_MEGAPIXELS, fit_in_memory}; + let full = lightcraft_merge::PanoOptions::default(); + let gb = |g: usize| Some(g << 30); + let photos = 5.0 * 51e6; // five 51 MP photos + let mp = |o: lightcraft_merge::PanoOptions| o.max_megapixels; + assert_eq!(mp(fit_in_memory(&full, photos, gb(512))), lightcraft_merge::pano::MAX_MEGAPIXELS, "plenty of memory: the full size"); + let m16 = mp(fit_in_memory(&full, photos, gb(16))); + assert!(m16 > MIN_FITTED_MEGAPIXELS && m16 < 512.0, "16 GB: {m16} MP"); + assert!(mp(fit_in_memory(&full, photos, gb(32))) > m16, "more memory, more pixels"); + assert_eq!(mp(fit_in_memory(&full, photos, gb(4))), MIN_FITTED_MEGAPIXELS, "never below 40 MP"); + let asked = lightcraft_merge::PanoOptions { max_megapixels: 20.0, ..full.clone() }; + assert_eq!(mp(fit_in_memory(&asked, photos, gb(16))), 20.0, "a size asked for is kept when it fits"); + assert_eq!(fit_in_memory(&full, 1e6, None), fit_in_memory(&full, 1e6, gb(8)), "memory unknown: taken as 8 GB"); +} diff --git a/crates/engine/src/tests_move_to_share.rs b/crates/engine/src/tests_move_to_share.rs new file mode 100644 index 000000000..ed824b29d --- /dev/null +++ b/crates/engine/src/tests_move_to_share.rs @@ -0,0 +1,286 @@ +//! Moving photos to a folder on a NAS or network share (`photo.moveToFolder`, which dropping them +//! on a Folders row runs), where the share can go away between the move and its undo: +//! +//! * The share offline when Undo runs: Undo is refused with the reason and keeps the step; +//! nothing is deleted, the library still points at the files where they are, and once the share +//! is back the same Undo moves them home. +//! * The share read-only when Undo runs: the same. +//! * The connection lost partway through a move: the files already moved go back, the library is +//! unchanged and no history step is left. +//! * One file the share can't give back during Undo: the files already brought back go out again, +//! so every photo points at its file and the step can run once the share answers. +//! * Across devices (a share is another volume): the file is copied, checked and only then +//! removed; the copy goes to a hidden name and takes the photo's name only once complete. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::Session; + +/// A scratch folder that goes away with the test, however it ends. +struct Scratch(std::path::PathBuf); + +impl Scratch { + fn new(tag: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lc-share-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(dir.join("local")).unwrap(); + std::fs::create_dir_all(dir.join("share")).unwrap(); + Scratch(dir) + } + fn path(&self, rel: &str) -> String { + self.0.join(rel).components().collect::<std::path::PathBuf>().to_string_lossy().to_string() + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn add_file(s: &mut Session, path: &str, body: &str) -> PhotoId { + std::fs::write(path, body).unwrap(); + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: path.into() }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id +} + +fn file_of(s: &Session, id: PhotoId) -> String { + match &s.catalog.photo(id).unwrap().source { + Source::File { path } => path.clone(), + _ => panic!("not a file"), + } +} + +/// Two photos on the local disk moved to the share. +fn moved_to_share(dir: &Scratch) -> (Session, PhotoId, PhotoId) { + let mut s = Session::new(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), "A"); + std::fs::write(dir.path("local/a.xmp"), "<edits/>").unwrap(); + let b = add_file(&mut s, &dir.path("local/b.jpg"), "B"); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0, b.0], "folder": dir.path("share")})).unwrap(); + assert_eq!(r["moved"], 2); + (s, a, b) +} + +#[test] +fn undo_with_the_share_offline_is_refused_keeps_the_step_and_works_once_it_is_back() { + let dir = Scratch::new("offline"); + let (mut s, a, b) = moved_to_share(&dir); + let steps = s.undo.len(); + // the share goes offline: its folder is no longer there + let away = dir.0.join("share-unmounted"); + std::fs::rename(dir.0.join("share"), &away).unwrap(); + let e = s.execute("edit.undo", &json!({})).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "says why: {e}"); + assert_eq!(s.undo.len(), steps, "the step stays, to undo once the share is back"); + // nothing deleted, nothing half-moved, and the library still points at the share + assert!(away.join("a.jpg").is_file() && away.join("a.xmp").is_file() && away.join("b.jpg").is_file()); + assert!(!dir.0.join("local/a.jpg").exists() && !dir.0.join("local/b.jpg").exists()); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("share/b.jpg")); + // the share is back: the same Undo moves them home, sidecar and all + std::fs::rename(&away, dir.0.join("share")).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(std::fs::read_to_string(dir.path("local/a.jpg")).unwrap(), "A"); + assert!(dir.0.join("local/a.xmp").is_file() && dir.0.join("local/b.jpg").is_file()); + assert_eq!(file_of(&s, a), dir.path("local/a.jpg")); + assert_eq!(file_of(&s, b), dir.path("local/b.jpg")); +} + +#[cfg(unix)] +#[test] +fn undo_from_a_read_only_share_is_refused_and_deletes_nothing() { + use std::os::unix::fs::PermissionsExt; + let dir = Scratch::new("readonly"); + let (mut s, a, _) = moved_to_share(&dir); + let steps = s.undo.len(); + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o555)).unwrap(); + // running as root ignores permissions: nothing to check there + let root = std::fs::write(dir.0.join("share/probe"), "x").is_ok(); + if !root { + assert!(s.execute("edit.undo", &json!({})).is_err()); + assert_eq!(s.undo.len(), steps, "the step stays"); + assert!(dir.0.join("share/a.jpg").is_file() && dir.0.join("share/b.jpg").is_file(), "nothing deleted"); + assert!(!dir.0.join("local/a.jpg").exists(), "no second copy left behind"); + assert_eq!(file_of(&s, a), dir.path("share/a.jpg")); + } + std::fs::set_permissions(dir.0.join("share"), std::fs::Permissions::from_mode(0o755)).unwrap(); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(dir.0.join("local/a.jpg").is_file()); +} + +/// The model volume spells paths with `/`; the code under test joins them with the host's separator. +fn host(path: &str) -> String { + path.replace('/', std::path::MAIN_SEPARATOR_STR) +} + +fn model_session(fs_files: &[&str]) -> (Session, Vec<PhotoId>) { + let mut s = Session::new(); + let ids = fs_files + .iter() + .map(|path| { + let id = s.catalog.alloc_photo_id(); + let name = std::path::Path::new(path).file_name().unwrap().to_string_lossy().to_string(); + let p = Photo::new(id, Source::File { path: host(path) }, &name, "JPEG", 1, 1, "2026-01-01T00:00:00"); + s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }) + .collect(); + (s, ids) +} + +fn plans(s: &Session, ids: &[PhotoId], into: &str) -> Vec<crate::rename::RenamePlan> { + ids.iter() + .map(|id| { + let from = file_of(s, *id); + let name = std::path::Path::new(&from).file_name().unwrap().to_string_lossy().to_string(); + crate::rename::RenamePlan { + id: id.0, + from: name.clone(), + to: name.clone(), + from_path: Some(from), + to_path: Some(host(&format!("{into}/{name}"))), + } + }) + .collect() +} + +/// Every photo points at a file the model volume holds (or, for `offline`, one it held). +fn assert_linked(s: &Session, fs: &crate::rename::tests::FakeFs, offline: &[&str]) { + let files = fs.listing(); + for p in s.catalog.photos() { + let Source::File { path } = &p.source else { continue }; + let path = path.replace('\\', "/"); + assert!( + files.iter().any(|(f, _)| *f == path) || offline.contains(&path.as_str()), + "photo {} points at {path}, which isn't there: {files:?}", + p.id.0 + ); + } +} + +#[test] +fn a_connection_lost_partway_through_a_move_puts_back_what_moved() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B"), ("/local/c.jpg", "C")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg", "/local/c.jpg"]); + let plans = plans(&s, &ids, "/share"); + // the share stops answering when the second file goes + fs.fail.borrow_mut().push("/local/b.jpg".into()); + let steps = s.undo.len(); + assert!(s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).is_err()); + assert_eq!( + fs.listing(), + vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into()), ("/local/c.jpg".into(), "C".into())], + "all back home" + ); + assert_eq!(s.undo.len(), steps, "no step for a move that didn't happen"); + assert_linked(&s, &fs, &[]); +} + +#[test] +fn a_file_the_share_cannot_give_back_rolls_the_whole_undo_back() { + let fs = crate::rename::tests::FakeFs::new(false, &[("/local/a.jpg", "A"), ("/local/b.jpg", "B")]); + let (mut s, ids) = model_session(&["/local/a.jpg", "/local/b.jpg"]); + let plans = plans(&s, &ids, "/share"); + s.apply_file_plans(&fs, &plans, &|_| "Move to Folder".into()).unwrap(); + assert_eq!(fs.listing(), vec![("/share/a.jpg".into(), "A".into()), ("/share/b.jpg".into(), "B".into())]); + // b can't be read back: the share answers for a but not for b + fs.fail.borrow_mut().push("/share/b.jpg".into()); + let e = s.undo_step_with(&fs).unwrap_err().to_string(); + assert!(e.contains("can't move the files back"), "{e}"); + // a came back before b failed: it went out again, so the step is whole and every photo + // points at its file, none lost + assert_linked(&s, &fs, &[]); + let contents: Vec<String> = fs.listing().into_iter().map(|(_, c)| c).collect(); + assert_eq!(contents, vec!["A".to_string(), "B".to_string()], "both files still exist once: {:?}", fs.listing()); + // the share answers again: undoing whatever is left brings both home + fs.fail.borrow_mut().clear(); + while s.undo_step_with(&fs).is_ok() && fs.listing().iter().any(|(p, _)| p.starts_with("/share")) {} + assert_eq!(fs.listing(), vec![("/local/a.jpg".into(), "A".into()), ("/local/b.jpg".into(), "B".into())]); + assert_linked(&s, &fs, &[]); +} + +/// Where this machine has a second file system to write to (Linux's `/dev/shm`), a real move +/// across devices: copied, checked, the original removed, and Undo copies it back. Skipped where +/// there is none (a network share can't be mounted in a test). +#[test] +fn a_move_across_devices_copies_checks_and_removes_and_undoes_the_same_way() { + let dir = Scratch::new("devices"); + let Some(other) = other_device(&dir.0) else { return }; + let share = other.join(format!("lc-share-other-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&share); + std::fs::create_dir_all(&share).unwrap(); + let mut s = Session::new(); + let body: String = (0..300_000).map(|i| char::from(b'a' + (i % 26) as u8)).collect(); + let a = add_file(&mut s, &dir.path("local/a.jpg"), &body); + let r = s.execute("photo.moveToFolder", &json!({"ids": [a.0], "folder": share.to_string_lossy()})); + let moved = + r.is_ok() && std::fs::read_to_string(share.join("a.jpg")).ok().as_deref() == Some(body.as_str()) && !dir.0.join("local/a.jpg").exists(); + let undone = moved + && s.execute("edit.undo", &json!({})).is_ok() + && std::fs::read_to_string(dir.0.join("local/a.jpg")).ok().as_deref() == Some(body.as_str()); + let gone_from_share = !share.join("a.jpg").exists(); + let _ = std::fs::remove_dir_all(&share); + assert!(moved, "moved across devices: {r:?}"); + assert!(undone, "and back"); + assert!(gone_from_share, "no copy left on the other device"); +} + +/// A writable folder on another device than `here`, if there is one. +fn other_device(here: &std::path::Path) -> Option<std::path::PathBuf> { + #[cfg(unix)] + { + use std::os::unix::fs::MetadataExt; + let dev = std::fs::metadata(here).ok()?.dev(); + ["/dev/shm", "/run/shm"] + .iter() + .map(std::path::PathBuf::from) + .find(|p| { + std::fs::metadata(p).is_ok_and(|m| m.dev() != dev) + && std::fs::write(p.join(format!(".lc-probe-{}", std::process::id())), b"x").is_ok() + }) + .inspect(|p| { + let _ = std::fs::remove_file(p.join(format!(".lc-probe-{}", std::process::id()))); + }) + } + #[cfg(not(unix))] + { + let _ = here; + None + } +} + +#[test] +fn the_copy_across_volumes_is_whole_or_absent_and_never_replaces() { + use crate::rename::MoveFs; + let dir = Scratch::new("copy"); + let body: Vec<u8> = (0..(3usize << 20) + 17).map(|i| (i % 251) as u8).collect(); + let (from, to) = (dir.0.join("local/big.raw"), dir.0.join("share/big.raw")); + std::fs::write(&from, &body).unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &to).unwrap(); + assert_eq!(std::fs::read(&to).unwrap(), body); + // nothing left under a hidden name beside it + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left, vec![std::ffi::OsString::from("big.raw")], "{left:?}"); + // a partial left by a copy the share cut off doesn't block the next copy + let stale = dir.0.join(format!("share/.again.raw.lc-partial-{}-0", std::process::id())); + std::fs::write(&stale, b"cut off").unwrap(); + crate::rename::RealFs.copy_no_replace(&from, &dir.0.join("share/again.raw")).unwrap(); + assert_eq!(std::fs::read(dir.0.join("share/again.raw")).unwrap(), body); + std::fs::remove_file(&stale).unwrap(); + std::fs::remove_file(dir.0.join("share/again.raw")).unwrap(); + // a copy that can't start (its source unreadable) leaves nothing at all under the photo's name + let gone = dir.0.join("local/missing.raw"); + assert!(crate::rename::RealFs.copy_no_replace(&gone, &dir.0.join("share/missing.raw")).is_err()); + assert!(!dir.0.join("share/missing.raw").exists()); + let left: Vec<_> = std::fs::read_dir(dir.0.join("share")).unwrap().flatten().map(|e| e.file_name()).collect(); + assert_eq!(left.len(), 1, "no partial file left: {left:?}"); + // a name taken is never replaced, and what is there stays + std::fs::write(&to, b"other").unwrap(); + assert!(crate::rename::RealFs.copy_no_replace(&from, &to).is_err()); + assert_eq!(std::fs::read(&to).unwrap(), b"other"); +} diff --git a/crates/engine/src/tests_organize.rs b/crates/engine/src/tests_organize.rs index 80d1ae546..344838432 100644 --- a/crates/engine/src/tests_organize.rs +++ b/crates/engine/src/tests_organize.rs @@ -141,6 +141,29 @@ fn virtual_copies_are_independent_and_persist() { let _ = std::fs::remove_dir_all(&dir); } +/// Issue #1493: soft-delete must drop the copy from the stack so `1/2` does not linger, and +/// deleting `2/3` renumbers the rest to `1/2` `2/2`. Undo restores the stack. +#[test] +fn deleting_a_virtual_copy_drops_it_from_the_stack() { + let mut s = Session::with_demo(); + let src = s.visible_cloned()[0]; + s.execute("library.select", &json!({"ids": [src.0]})).unwrap(); + let c1 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({})).unwrap()["ids"][0].as_u64().unwrap()); + let c2 = lightcraft_catalog::PhotoId(s.execute("photo.virtualCopy", &json!({"ids": [src.0]})).unwrap()["ids"][0].as_u64().unwrap()); + assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c2, c1]); + s.execute("photo.delete", &json!({"ids": [c2.0]})).unwrap(); + let left = s.catalog.stack_of(src).expect("two members remain stacked"); + assert_eq!(left.photos, vec![src, c1]); + assert_eq!((left.position(src), left.position(c1), left.photos.len()), (Some(0), Some(1), 2), "1/2 and 2/2"); + assert!(s.catalog.photo(c2).unwrap().deleted); + s.execute("photo.delete", &json!({"ids": [c1.0]})).unwrap(); + assert!(s.catalog.stack_of(src).is_none(), "the original is unstacked once it is the only member"); + assert!(s.catalog.photo(c1).unwrap().deleted); + s.execute("edit.undo", &json!({})).unwrap(); + assert!(!s.catalog.photo(c1).unwrap().deleted); + assert_eq!(s.catalog.stack_of(src).unwrap().photos, vec![src, c1]); +} + #[test] fn stacks_group_collapse_and_replay() { let dir = temp_dir("stacks"); @@ -313,3 +336,23 @@ fn deleting_an_album_folder_preserves_photos_and_restores_its_subtree() { drop(reopened); let _ = std::fs::remove_dir_all(dir); } + +/// A huge `start` is a validation error, not an arithmetic overflow in the numbering. +#[test] +fn rename_huge_start_is_a_bad_param() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + let names_before: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + for cmd in ["photo.renamePreview", "photo.rename"] { + let e = s.execute(cmd, &json!({"template": "{seq}", "start": u64::MAX, "ids": ids})).unwrap_err(); + assert!(e.to_string().contains("start out of range"), "{cmd}: {e}"); + } + let names_after: Vec<String> = s.catalog.photos().map(|p| p.file_name.clone()).collect(); + assert_eq!(names_before, names_after, "a rejected rename changes nothing"); + // the last start whose whole sequence fits is accepted, one more is not + let fits = usize::MAX as u64 - 2; + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits, "ids": ids})); + assert!(r.is_ok(), "{r:?}"); + let r = s.execute("photo.renamePreview", &json!({"template": "{seq}", "start": fits + 1, "ids": ids})); + assert!(r.is_err(), "{r:?}"); +} diff --git a/crates/engine/src/tests_prefs.rs b/crates/engine/src/tests_prefs.rs index 3534064f2..4c9d9582d 100644 --- a/crates/engine/src/tests_prefs.rs +++ b/crates/engine/src/tests_prefs.rs @@ -108,7 +108,7 @@ fn preferences_persist_with_the_library() { s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); s.execute( "library.preferences", - &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), + &json!({"import": {"rawPreset": "user.rawlook", "perCamera": true, "showOnCard": true}, "camera": {"camera": "Canon EOS R5", "preset": "user.canonlook"}, "cacheMb": 512}), ) .unwrap(); s.close_library().unwrap(); @@ -118,6 +118,7 @@ fn preferences_persist_with_the_library() { assert!(s.xmp.auto_write); assert_eq!(s.import_defaults.raw_preset.as_deref(), Some("user.rawlook")); assert!(s.import_defaults.per_camera); + assert!(s.import_defaults.show_on_card); assert_eq!(s.import_defaults.cameras.len(), 1); assert_eq!(s.import_defaults.preset_for(true, "Canon EOS R5"), Some("user.canonlook")); assert_eq!(s.cache_mb, 512); @@ -141,6 +142,25 @@ fn preferences_persist_with_the_library() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn last_merge_settings_persist_with_the_library() { + let lib = temp_dir("merge"); + let opts = json!({"align": false, "deghost": "high"}); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s.last_merge.insert("merge.hdr".into(), opts.clone()); + s.save_prefs().unwrap(); + s.close_library().unwrap(); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.last_merge.get("merge.hdr"), Some(&opts)); + assert!(!s.last_merge.contains_key("merge.panorama")); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn default_copyright_and_creator_fill_gaps_on_import() { let dir = temp_dir("copyright"); @@ -265,6 +285,60 @@ fn curve_presets_save_apply_export_import_and_persist() { let _ = std::fs::remove_dir_all(&lib); } +#[test] +fn mask_effects_import_apply_save_export_and_persist() { + let lib = temp_dir("effects"); + let src = lib.join("Local Adjustment Presets"); + std::fs::create_dir_all(&src).unwrap(); + // a Lightroom local adjustment preset (synthetic values) and a develop template beside it + std::fs::write( + src.join("Brighten.lrtemplate"), + "s = {\n\ttitle = \"Brighten\",\n\ttype = \"LocalizedAdjustmentPreset\",\n\tvalue = {\n\t\texposure2012 = 0.25,\n\t\tshadows2012 = 0.2,\n\t\tgrain = 0.3,\n\t},\n\tversion = 0,\n}", + ) + .unwrap(); + std::fs::write(src.join("Look.lrtemplate"), "s = { title = \"Look\", type = \"Develop\", value = { settings = { Exposure2012 = 0.5 } } }") + .unwrap(); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, true).unwrap(); + // the general preset import routes the local one to the effects + let r = s.execute("preset.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 1, "the develop template stays a develop preset"); + assert_eq!(r["effects"][0]["name"], "Brighten"); + assert_eq!(r["effects"][0]["unmapped"], json!(["Grain"])); + assert_eq!(s.mask_effects.len(), 1); + // apply to a mask: every slider set, Amount kept + s.execute("mask.add", &json!({"kind": "radial"})).unwrap(); + s.execute("mask.adjust", &json!({"values": {"clarity": 30, "amount": 70}})).unwrap(); + assert!(s.execute("mask.applyEffect", &json!({"name": "nope"})).is_err()); + s.execute("mask.applyEffect", &json!({"name": "brighten"})).unwrap(); + let d = s.develop_of(s.active().unwrap()).unwrap(); + let a = d.masks.last().unwrap().adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (1.0, 20.0, 0.0, 70.0)); + assert_eq!(s.execute("mask.effects", &json!({})).unwrap()["current"], "Brighten"); + // save the mask's sliders under a new name, export, delete, re-import + s.execute("mask.adjust", &json!({"values": {"dehaze": 15}})).unwrap(); + s.execute("mask.saveEffect", &json!({"name": "Brighten + Dehaze"})).unwrap(); + let r = s.execute("mask.exportEffects", &json!({"path": lib.join("out").join("mine").to_string_lossy()})).unwrap(); + assert_eq!(r["count"], 2); + let path = r["path"].as_str().unwrap().to_string(); + assert!(path.ends_with(".lceffect")); + s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).unwrap(); + assert!(s.execute("mask.deleteEffect", &json!({"name": "brighten + dehaze"})).is_err()); + let r = s.execute("mask.importEffects", &json!({"path": path})).unwrap(); + assert_eq!(r["imported"].as_array().unwrap().len(), 2); + assert_eq!(s.mask_effects.len(), 2, "same-named effects are replaced"); + s.close_library().unwrap(); + } + // saved with the library preferences + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let names: Vec<String> = s.mask_effects.iter().map(|e| e.name.clone()).collect(); + assert_eq!(names, ["Brighten", "Brighten + Dehaze"]); + assert_eq!(s.mask_effects[1].adjust.dehaze, 15.0); + let _ = std::fs::remove_dir_all(&lib); +} + #[test] fn resizing_the_disk_cache_keeps_the_cache_and_its_thumbnails() { let lib = temp_dir("resize"); @@ -317,3 +391,43 @@ fn reopening_a_library_retires_the_old_cache_so_its_jobs_cannot_write_after_a_cl drop(s); let _ = std::fs::remove_dir_all(&lib); } + +#[test] +fn slideshow_playback_settings_persist_with_the_library() { + let lib = temp_dir("slideshow"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!(r["slideDuration"], 4.0, "defaults: {r}"); + let r = s + .execute( + "slideshow.playback", + &json!({"slideDuration": 6, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false}), + ) + .unwrap(); + assert_eq!(r["fadeColor"], "#000000"); + // a hostile value changes nothing + assert!(s.execute("slideshow.playback", &json!({"slideDuration": "soon", "repeat": true})).is_err()); + assert!(!s.slideshow.repeat); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.playback", &json!({})).unwrap(); + assert_eq!( + r, + json!({"slideDuration": 6.0, "fadeDuration": 2.5, "fadeColor": "#000000", "randomOrder": true, "repeat": false, "preparePreviews": true}) + ); + drop(s); + // out-of-range values written by hand are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideshow": {"slideDuration": 1e9, "fadeDuration": -3}}"#).unwrap(); + let s = { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + s + }; + assert_eq!((s.slideshow.slide_duration, s.slideshow.fade_duration), (120.0, 0.0)); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} diff --git a/crates/engine/src/tests_print.rs b/crates/engine/src/tests_print.rs new file mode 100644 index 000000000..898faa889 --- /dev/null +++ b/crates/engine/src/tests_print.rs @@ -0,0 +1,363 @@ +//! The `print.*` commands: state, cells, templates and the PDF. + +use lightcraft_catalog::MemStore; +use serde_json::{Value, json}; + +use crate::Session; +use crate::library::LibraryStores; + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn fails(s: &mut Session, id: &str, p: Value) -> String { + s.execute(id, &p).expect_err(&format!("{id} {p} should fail")).to_string() +} + +fn package(s: &mut Session) { + run(s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "letter"}}})); +} + +#[test] +fn get_set_and_reset() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["style"], "singleImage"); + assert_eq!(g["pageWidth"], 8.5); + assert_eq!(g["printable"]["x"], 0.25); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); + assert!(g["papers"].as_array().unwrap().iter().any(|p| p["name"] == "a4")); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 1); + + let g = run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 2, "rows": 3}, "page": {"paper": "a4", "orientation": "landscape", "margins": {"left": 1.0}}}, "image": {"zoomToFill": true}, "job": {"ppi": 300.0}}), + ); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["left"], 1.0); + assert_eq!(g["settings"]["layout"]["page"]["margins"]["top"], 0.25, "a patch merges"); + assert_eq!(g["gridCells"].as_array().unwrap().len(), 6); + assert!((g["pageWidth"].as_f64().unwrap() - 11.693).abs() < 1e-3); + assert_eq!(g["settings"]["image"]["zoomToFill"], true); + // the page count follows the photos: 6 cells per page + let g = run(&mut s, "print.get", json!({"ids": vec![1; 7]})); + assert_eq!(g["pageCount"], 2); + + let g = run(&mut s, "print.reset", json!({})); + assert_eq!(g["settings"]["job"]["ppi"], 240.0); +} + +#[test] +fn bad_settings_are_refused_and_change_nothing() { + let mut s = Session::with_demo(); + let before = run(&mut s, "print.get", json!({})); + for p in [ + json!({"typo": 1}), + json!({"layout": {"typo": 1}}), + json!({"layout": {"style": "poster"}}), + json!({"layout": {"cells": []}}), + json!({"layout": {"pages": 3}}), + json!({"layout": {"page": {"paper": "poster"}}}), + json!({"layout": {"page": {"paper": "custom", "customWidth": -1}}}), + json!({"layout": {"page": {"margins": {"top": 1e9}}}}), + json!({"layout": {"page": {"margins": {"top": 6.0, "bottom": 5.0}}}}), + json!({"layout": {"grid": {"columns": 0}}}), + json!({"layout": {"grid": {"columns": 9999}}}), + json!({"layout": {"spacing": -1}}), + json!({"image": {"borderWidth": 99}}), + json!({"image": {"strokeWidthPt": -2}}), + json!({"image": {"borderColor": [1, 2]}}), + json!({"image": {"zoomToFill": "yes"}}), + json!({"guides": {"gridSize": 0}}), + json!({"guides": {"units": "furlong"}}), + json!({"job": {"ppi": 1e30}}), + json!({"job": {"ppi": 10}}), + json!({"job": null}), + ] { + fails(&mut s, "print.set", p); + } + assert_eq!(run(&mut s, "print.get", json!({})), before); + assert!(s.execute("print.set", &json!([1])).is_err()); +} + +#[test] +fn cells_are_commands() { + let mut s = Session::with_demo(); + // grid styles have no cells + fails(&mut s, "print.cell.add", json!({"width": 2, "height": 2})); + package(&mut s); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0, "x": 5.0, "y": 5.0}))["id"].as_u64().unwrap(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["layout"]["cells"].as_array().unwrap().len(), 2); + assert_eq!(g["overlaps"], false); + // move, clamped inside the margins + let r = run(&mut s, "print.cell.move", json!({"id": a, "x": 99.0, "y": 0.0})); + assert_eq!(r["rect"]["x"], 8.25 - 3.0); + assert_eq!(r["rect"]["y"], 0.25); + // resize keeps what is not given + let r = run(&mut s, "print.cell.resize", json!({"id": b, "width": 4.0})); + assert_eq!((r["rect"]["w"].as_f64().unwrap(), r["rect"]["h"].as_f64().unwrap()), (4.0, 2.0)); + // snapping to the other cell's edge + let ra = run(&mut s, "print.get", json!({}))["settings"]["layout"]["cells"][0]["rect"].clone(); + let left = ra["x"].as_f64().unwrap(); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.0)).abs() < 1e-9, "{r}"); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "no snap unless asked: {r}"); + run(&mut s, "print.set", json!({"guides": {"snap": "off"}})); + let r = run(&mut s, "print.cell.move", json!({"id": b, "x": left - 4.0 - 0.04, "y": 5.0, "snap": true})); + assert!((r["rect"]["x"].as_f64().unwrap() - (left - 4.04)).abs() < 1e-9, "snap off"); + // stacking + let r = run(&mut s, "print.cell.stack", json!({"id": a, "order": "toFront"})); + assert_eq!(r["order"], json!([b, a])); + fails(&mut s, "print.cell.stack", json!({"id": a, "order": "sideways"})); + // photos and pages + let photo = s.catalog.photos().next().unwrap().id.0; + run(&mut s, "print.cell.photo", json!({"id": a, "photo": photo})); + fails(&mut s, "print.cell.photo", json!({"id": a, "photo": 999_999})); + run(&mut s, "print.layout.newPage", json!({})); + run(&mut s, "print.cell.page", json!({"id": b, "page": 1})); + fails(&mut s, "print.cell.page", json!({"id": b, "page": 7})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["pageCount"], 2); + // delete, clear + run(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.delete", json!({"id": b})); + fails(&mut s, "print.cell.move", json!({"id": 99, "x": 0, "y": 0})); + fails(&mut s, "print.cell.move", json!({"x": 0, "y": 0})); + run(&mut s, "print.layout.clear", json!({})); + let g = run(&mut s, "print.get", json!({})); + assert_eq!((g["settings"]["layout"]["cells"].as_array().unwrap().len(), g["pageCount"].as_u64()), (0, Some(1))); +} + +#[test] +fn hostile_cell_numbers_are_refused() { + let mut s = Session::with_demo(); + package(&mut s); + for (w, h) in [(0.0, 2.0), (-1.0, 2.0), (1e12, 1e12), (2.0, 1e-9), (50.0, 50.0)] { + fails(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + fails(&mut s, "print.cell.add", json!({"width": "wide", "height": 2})); + fails(&mut s, "print.cell.add", json!({"width": 2})); + let id = run(&mut s, "print.cell.add", json!({"width": 2, "height": 2}))["id"].as_u64().unwrap(); + fails(&mut s, "print.cell.move", json!({"id": id, "x": "left", "y": 0})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": -4})); + fails(&mut s, "print.cell.resize", json!({"id": id, "width": "x"})); + fails(&mut s, "print.cell.move", json!({"id": id, "x": 1, "y": 1, "snap": "yes"})); + fails(&mut s, "print.cell.move", json!({"id": u64::MAX, "x": 1, "y": 1})); + let r = run(&mut s, "print.cell.move", json!({"id": id, "x": -1e300, "y": 1e300})); + assert_eq!(r["rect"]["x"], 0.25); +} + +#[test] +fn auto_layout_fills_pages_without_overlap() { + let mut s = Session::with_demo(); + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + for (w, h) in [(4.0, 6.0), (4.0, 6.0), (5.0, 7.0), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (2.5, 3.5), (8.0, 10.0)] { + run(&mut s, "print.cell.add", json!({"width": w, "height": h})); + } + let r = run(&mut s, "print.layout.autoLayout", json!({})); + assert!(r["pages"].as_u64().unwrap() >= 2); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["overlaps"], false); + let again = run(&mut s, "print.layout.autoLayout", json!({})); + assert_eq!(again["cells"], r["cells"], "deterministic"); + // an oversize cell that arrived by a paper change is pulled in, not left outside the page + run(&mut s, "print.set", json!({"layout": {"page": {"paper": "4x6"}}})); + let g = run(&mut s, "print.get", json!({})); + for c in g["settings"]["layout"]["cells"].as_array().unwrap() { + assert!(c["rect"]["x"].as_f64().unwrap() + c["rect"]["w"].as_f64().unwrap() <= 4.0 - 0.25 + 1e-9); + } +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn temp_pdf(name: &str) -> String { + std::env::temp_dir().join(format!("lightcraft-print-{name}-{}.pdf", std::process::id())).to_string_lossy().to_string() +} + +#[test] +fn export_pdf_prints_the_current_settings() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(3).map(|p| p.id.0).collect(); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 2}, "page": {"paper": "a5"}}, "job": {"ppi": 100.0}}), + ); + let path = temp_pdf("export"); + let r = run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["photos"].as_u64()), (Some(2), Some(3))); + let bytes = std::fs::read(&path).unwrap(); + assert_eq!(bytes.len() as u64, r["bytes"].as_u64().unwrap()); + let text = String::from_utf8_lossy(&bytes); + assert!(text.starts_with("%PDF-") && text.contains("/Count 2")); + // A5 is 148 x 210 mm = 419.53 x 595.28 pt, rounded to whole pixels at 100 ppi (583 x 827) + let (w, h) = media_box(&bytes); + assert!((w - 419.53).abs() < 1.0 && (h - 595.28).abs() < 1.0, "A5, got {w} x {h} pt"); + // the row in the activity stack is gone when the print is done + assert!(s.activity.list().is_empty()); + fails(&mut s, "print.exportPdf", json!({})); + fails(&mut s, "print.exportPdf", json!({"path": path, "typo": 1})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ["x"]})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": [999_999]})); + // a package with no cells has nothing to print + run(&mut s, "print.set", json!({"layout": {"style": "picturePackage"}})); + fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); +} + +#[test] +fn export_pdf_with_a_printer_profile() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(1).map(|p| p.id.0).collect(); + let dir = std::env::temp_dir().join(format!("lightcraft-print-icc-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Fine Art Matte.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let color = json!({"profile": {"kind": "icc", "path": icc}, "intent": "relative", "sharpen": "high", "media": "matte", "brightness": 10}); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0, "color": color}}), + ); + assert_eq!(run(&mut s, "print.get", json!({}))["settings"]["job"]["color"]["media"], "matte"); + let path = temp_pdf("icc"); + run(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&path).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/ICCBased") && text.contains("/BitsPerComponent 16")); + // out-of-range adjustments are refused when set; a missing profile fails before rendering + fails(&mut s, "print.set", json!({"job": {"color": {"contrast": 150}}})); + run(&mut s, "print.set", json!({"job": {"color": {"profile": {"kind": "icc", "path": dir.join("gone.icc")}}}})); + let err = fails(&mut s, "print.exportPdf", json!({"path": path, "ids": ids})); + assert!(err.contains("gone.icc"), "{err}"); + assert!(s.activity.list().is_empty(), "nothing started"); +} + +#[test] +fn custom_package_prints_each_cells_photo() { + let mut s = Session::with_demo(); + let ids: Vec<u64> = s.catalog.photos().take(2).map(|p| p.id.0).collect(); + run(&mut s, "print.set", json!({"layout": {"style": "customPackage", "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}})); + let a = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + let b = run(&mut s, "print.cell.add", json!({"width": 3.0, "height": 2.0}))["id"].as_u64().unwrap(); + run(&mut s, "print.cell.photo", json!({"id": a, "photo": ids[0]})); + run(&mut s, "print.cell.photo", json!({"id": b, "photo": ids[1]})); + let r = run(&mut s, "print.exportPdf", json!({"path": temp_pdf("custom"), "ids": [ids[0]]})); + assert_eq!(r["pages"], 1); +} + +#[test] +fn templates_save_apply_delete_and_persist() { + let files = MemStore::new(); + let open = |files: &MemStore| { + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + s + }; + let mut s = open(&files); + run( + &mut s, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 4, "rows": 5}}, "page": {"background": [10, 20, 30]}, "guides": {"gridSize": 1.0}}), + ); + let r = run(&mut s, "print.template.save", json!({"name": "Proofs"})); + assert_eq!(r["templates"][0]["name"], "Proofs"); + assert!(r["templates"][0]["settings"].get("guides").is_none()); + run(&mut s, "print.template.save", json!({"name": "proofs"})); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 1, "the same name in another case replaces"); + + run(&mut s, "print.reset", json!({})); + run(&mut s, "print.set", json!({"guides": {"gridSize": 0.5}})); + let g = run(&mut s, "print.template.apply", json!({"name": "PROOFS"})); + assert_eq!(g["settings"]["layout"]["grid"]["columns"], 4); + assert_eq!(g["settings"]["page"]["background"], json!([10, 20, 30])); + assert_eq!(g["settings"]["guides"]["gridSize"], 0.5, "guides stay as they were"); + + // it survives reopening the library + let mut again = open(&files); + assert_eq!(run(&mut again, "print.template.list", json!({}))["templates"][0]["name"], "proofs"); + run(&mut again, "print.template.apply", json!({"name": "Proofs"})); + + fails(&mut s, "print.template.save", json!({"name": " "})); + fails(&mut s, "print.template.save", json!({})); + fails(&mut s, "print.template.apply", json!({"name": "Nope"})); + fails(&mut s, "print.template.delete", json!({"name": "Nope"})); + run(&mut s, "print.template.delete", json!({"name": "proofs"})); + assert!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); + assert!(run(&mut open(&files), "print.template.list", json!({}))["templates"].as_array().unwrap().is_empty()); +} + +#[test] +fn a_hostile_template_in_the_preferences_does_not_break_the_library() { + let files = MemStore::new(); + let hostile = json!({"printTemplates": [ + {"name": "Bad", "settings": {"layout": {"pages": 0}, "extra": true}}, + {"name": "Huge", "settings": {"job": {"ppi": 1e300}}}, + {"name": "Fine", "settings": {"layout": {"style": "contactSheet"}}} + ], "cacheMb": 123}); + files.set("prefs.json", serde_json::to_vec(&hostile).unwrap()); + let mut s = Session::with_demo(); + let stores = LibraryStores { dir: "print".into(), catalog: Box::new(MemStore::new()), files: Box::new(files.clone()), on_disk: false }; + s.open_library_in(stores, false).unwrap(); + assert!(s.take_library_warnings().is_empty(), "the preferences are not damaged"); + assert_eq!(s.cache_mb, 123); + assert_eq!(run(&mut s, "print.template.list", json!({}))["templates"].as_array().unwrap().len(), 3); + fails(&mut s, "print.template.apply", json!({"name": "Bad"})); + fails(&mut s, "print.template.apply", json!({"name": "Huge"})); + run(&mut s, "print.template.apply", json!({"name": "Fine"})); +} + +#[test] +fn every_print_command_is_registered_with_a_description() { + for id in [ + "print.get", + "print.set", + "print.reset", + "print.cell.add", + "print.cell.move", + "print.cell.resize", + "print.cell.delete", + "print.cell.stack", + "print.cell.photo", + "print.cell.page", + "print.layout.autoLayout", + "print.layout.newPage", + "print.layout.clear", + "print.exportPdf", + "print.template.list", + "print.template.save", + "print.template.apply", + "print.template.delete", + ] { + let c = crate::find_command(id).unwrap_or_else(|| panic!("{id} missing")); + assert!(c.params.len() > 3, "{id}"); + } +} + +#[test] +fn page_options_are_print_settings_and_template_fields() { + let mut s = Session::with_demo(); + let g = run(&mut s, "print.get", json!({})); + assert_eq!(g["settings"]["page"], json!({"background": [255, 255, 255], "pageNumbers": false, "pageInfo": false, "cropMarks": false})); + let g = run(&mut s, "print.set", json!({"page": {"pageNumbers": true, "cropMarks": true}})); + assert_eq!((g["settings"]["page"]["pageNumbers"].as_bool(), g["settings"]["page"]["pageInfo"].as_bool()), (Some(true), Some(false))); + assert_eq!(g["settings"]["page"]["background"], json!([255, 255, 255]), "a patch merges"); + fails(&mut s, "print.set", json!({"page": {"pageInfo": "yes"}})); + fails(&mut s, "print.set", json!({"page": {"pageNumber": true}})); + // a template keeps them + run(&mut s, "print.template.save", json!({"name": "Marked"})); + run(&mut s, "print.reset", json!({})); + assert!(!s.print.page.crop_marks); + run(&mut s, "print.template.apply", json!({"name": "Marked"})); + assert!(s.print.page.page_numbers && s.print.page.crop_marks && !s.print.page.page_info); + let set = crate::find_command("print.set").unwrap(); + assert!(set.params.contains("pageNumbers") && set.params.contains("pageInfo") && set.params.contains("cropMarks"), "{}", set.params); +} diff --git a/crates/engine/src/tests_recheck.rs b/crates/engine/src/tests_recheck.rs new file mode 100644 index 000000000..9f7dab984 --- /dev/null +++ b/crates/engine/src/tests_recheck.rs @@ -0,0 +1,155 @@ +//! Issue #662: preview-only raws are rechecked when a library opens in another app version. + +use std::path::PathBuf; + +use lightcraft_catalog::{APP_VERSION, Op, PhotoId}; +use serde_json::json; + +use crate::Session; +use crate::cmd::recheck; +use crate::files::tests::cr3_with_preview; + +/// A version other than this build's: the flag was set by an older (or newer) LightCraft. +const OTHER: &str = "0.5.0"; + +struct Lib { + s: Session, + dir: PathBuf, + lib: PathBuf, + file: PathBuf, + id: PhotoId, +} + +impl Drop for Lib { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.dir); + } +} + +/// A library with one preview-only CR3, its flag stamped by [`OTHER`] as a library saved by +/// another version would be. +fn library(name: &str) -> Lib { + let dir = std::env::temp_dir().join(format!("lc-recheck-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let (lib, files) = (dir.join("lib"), dir.join("files")); + std::fs::create_dir_all(&files).unwrap(); + let file = files.join("IMG_0001.cr3"); + std::fs::write(&file, cr3_with_preview(96, 64)).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = PhotoId(r["imported"][0].as_u64().unwrap()); + let p = s.catalog.photo(id).unwrap().clone(); + assert!(p.preview_only.is_some() && !p.preview_only_stale(), "just imported: decided by this version"); + s.apply_system(Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: p.preview_only.clone(), + preview_checked: OTHER.into(), + }) + .unwrap(); + assert!(s.catalog.photo(id).unwrap().preview_only_stale()); + Lib { s, dir, lib, file, id } +} + +fn decodable_now(x: &Lib) { + std::fs::write(&x.file, crate::tests_xmp::synthetic_dng_with(None, lightcraft_meta::Metadata::default())).unwrap(); +} + +/// The acceptance test: a stale flag on a file that decodes now is cleared without a manual +/// Reload, adds no undo step, leaves develop settings and history alone, and survives a reopen. +#[test] +fn stale_flag_on_a_file_that_decodes_now_is_cleared() { + let mut x = library("decodes"); + x.s.execute("develop.set", &json!({"ids": [x.id.0], "control": "light.exposure", "value": 0.5})).unwrap(); + let before = x.s.catalog.photo(x.id).unwrap().clone(); + let undo = x.s.undo.len(); + decodable_now(&x); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": [x.id.0]})); + let p = x.s.catalog.photo(x.id).unwrap().clone(); + assert_eq!(p.preview_only, None); + assert_eq!(p.preview_checked, "", "the stamp goes with the flag"); + assert!(p.develops_raw()); + assert_eq!(x.s.undo.len(), undo, "not an undo step"); + assert_eq!(*p.develop, *before.develop); + assert_eq!(p.history.len(), before.history.len()); + // journaled: a reopen keeps it + x.s.persist().unwrap(); + x.s = Session::new().with_fs(); + x.s.open_library(&x.lib, false).unwrap(); + assert_eq!(x.s.catalog.photo(x.id).unwrap().preview_only, None); +} + +/// A file that still doesn't decode is stamped by this version (with the decoder's current +/// reason), so the next open doesn't probe it again. +#[test] +fn still_undecodable_file_gets_a_fresh_stamp_and_isnt_probed_again() { + let mut x = library("still"); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [x.id.0], "cleared": []})); + let p = x.s.catalog.photo(x.id).unwrap(); + assert!(p.preview_only.is_some()); + assert_eq!(p.preview_checked, APP_VERSION); + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["checked"], 0); + // `all` ignores stamps (testing a decoder build of the same version) + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({"all": true})).unwrap()["checked"], 1); +} + +/// A file that can't be read (unplugged drive) changes nothing and is tried again next time. +#[test] +fn missing_file_keeps_its_flag_and_stamp_and_is_retried() { + let mut x = library("missing"); + std::fs::remove_file(&x.file).unwrap(); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r, json!({"checked": 1, "updated": [], "cleared": []})); + let p = x.s.catalog.photo(x.id).unwrap(); + assert!(p.preview_only.is_some()); + assert_eq!(p.preview_checked, OTHER); + // back again, and decodable now + decodable_now(&x); + assert_eq!(x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap()["cleared"], json!([x.id.0])); +} + +/// A photo that changed between the probe and applying it (here: relinked) is left alone. +#[test] +fn photo_changed_while_probing_is_skipped() { + let mut x = library("changed"); + let listed = recheck::stale(&x.s.catalog, false); + assert_eq!(listed.iter().map(|i| i.id).collect::<Vec<_>>(), vec![x.id]); + let probed = recheck::probe(&recheck::file_probe(&x.s).unwrap(), listed); + decodable_now(&x); + let other = x.dir.join("files").join("IMG_0001-moved.cr3"); + std::fs::write(&other, cr3_with_preview(96, 64)).unwrap(); + x.s.execute("photo.relink", &json!({"id": x.id.0, "path": other.to_string_lossy()})).unwrap(); + let relinked = x.s.catalog.photo(x.id).unwrap().clone(); + assert_eq!(recheck::apply(&mut x.s, probed).unwrap(), recheck::Rechecked::default()); + assert_eq!(*x.s.catalog.photo(x.id).unwrap().clone(), *relinked); +} + +/// Virtual copies share the file: they follow the photo they were copied from. +#[test] +fn virtual_copies_follow_their_photo() { + let mut x = library("copies"); + let r = x.s.execute("photo.virtualCopy", &json!({"ids": [x.id.0]})).unwrap(); + let copy = PhotoId(r["ids"][0].as_u64().unwrap()); + assert!(x.s.catalog.photo(copy).unwrap().preview_only.is_some()); + assert_eq!(recheck::stale(&x.s.catalog, true).len(), 1, "copies aren't probed on their own"); + decodable_now(&x); + let r = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap(); + assert_eq!(r["cleared"], json!([x.id.0, copy.0])); + assert!(x.s.catalog.photo(copy).unwrap().develops_raw()); +} + +/// Without file access every probe would "succeed" knowing nothing and clear every flag: refused. +#[test] +fn session_without_file_access_refuses() { + let mut x = library("nofs"); + x.s.media.file_probe = None; + let e = x.s.execute("library.recheckPreviewOnly", &json!({})).unwrap_err().to_string(); + assert!(e.contains("no file access"), "{e}"); + assert!(x.s.catalog.photo(x.id).unwrap().preview_only.is_some()); +} diff --git a/crates/engine/src/tests_slide.rs b/crates/engine/src/tests_slide.rs new file mode 100644 index 000000000..7f57de970 --- /dev/null +++ b/crates/engine/src/tests_slide.rs @@ -0,0 +1,312 @@ +//! Slideshow slides: `slideshow.layout` (validated, saved with the library) and +//! `slideshow.preview` (a slide composed with the layout, as a PNG). + +use std::path::PathBuf; + +use serde_json::json; + +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-slide-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn png_size(bytes: &[u8]) -> (u32, u32) { + assert_eq!(&bytes[..8], b"\x89PNG\r\n\x1a\n", "a PNG"); + let be = |o: usize| u32::from_be_bytes([bytes[o], bytes[o + 1], bytes[o + 2], bytes[o + 3]]); + (be(16), be(20)) +} + +fn base64_decode(text: &str) -> Vec<u8> { + const A: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let (mut out, mut acc, mut bits) = (Vec::new(), 0u32, 0u32); + for c in text.bytes().take_while(|c| *c != b'=') { + acc = (acc << 6) | A.iter().position(|a| *a == c).unwrap() as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + out +} + +#[test] +fn slide_layout_is_validated_and_persists_with_the_library() { + let lib = temp_dir("layout"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!(r["marginLeft"], 0.0, "defaults: {r}"); + assert_eq!(r["backdropColor"], "#000000"); + let r = s + .execute( + "slideshow.layout", + &json!({"marginTop": 0.1, "strokeBorder": true, "strokeColor": [255, 255, 255], "castShadow": true, "backdropColor": "#203040", "aspect": "4:3"}), + ) + .unwrap(); + assert_eq!((r["marginLeft"].as_f64(), r["marginBottom"].as_f64()), (Some(0.1), Some(0.1)), "linked margins: {r}"); + assert_eq!(r["strokeColor"], "#ffffff"); + // a bad value changes nothing (the good keys beside it included) + for bad in [json!({"marginLeft": "wide", "zoomToFill": true}), json!({"backdropColor": "red"}), json!({"glitter": 1})] { + assert!(s.execute("slideshow.layout", &bad).is_err(), "{bad}"); + } + assert!(!s.slide_layout.zoom_to_fill); + // out of range: clamped + let r = s.execute("slideshow.layout", &json!({"linkMargins": false, "marginRight": 9, "shadowOpacity": -1})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["marginLeft"].as_f64(), r["shadowOpacity"].as_f64()), (Some(0.45), Some(0.1), Some(0.0))); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.layout", &json!({})).unwrap(); + assert_eq!((r["marginRight"].as_f64(), r["aspect"].as_str(), r["backdropColor"].as_str()), (Some(0.45), Some("4:3"), Some("#203040"))); + assert_eq!(r["linkMargins"], false); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideLayout": {"marginTop": 1e9, "shadowAngle": 720, "strokeWidth": -3}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_layout.margin_top, s.slide_layout.shadow_angle, s.slide_layout.stroke_width), (0.45, 0.0, 0.0)); + // and the playback settings beside them are untouched by a layout change + let before = s.slideshow.clone(); + s.execute("slideshow.layout", &json!({"zoomToFill": true})).unwrap(); + assert_eq!(s.slideshow, before); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_returns_the_composed_slide_at_the_asked_size() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.1, "backdropColor": "#ff0000"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"width": 320, "height": 200})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64(), r["id"].as_u64()), (Some(320), Some(200), Some(photos[0].0))); + assert_eq!(r["cell"], json!([20.0, 20.0, 300.0, 180.0]), "10 % of the 200 px short side"); + let png = base64_decode(r["png"].as_str().unwrap()); + assert_eq!(png_size(&png), (320, 200)); + // written to a file instead + let dir = temp_dir("preview"); + let path = dir.join("slide.png"); + let r = s.execute("slideshow.preview", &json!({"id": photos[1].0, "width": 240, "path": path.to_string_lossy()})).unwrap(); + assert_eq!(r["path"], path.to_string_lossy().as_ref()); + assert!(r.get("png").is_none()); + assert_eq!(png_size(&std::fs::read(&path).unwrap()), (240, 150), "the height from the aspect preview (screen: 16:10)"); + s.execute("slideshow.layout", &json!({"aspect": "16:9"})).unwrap(); + let r = s.execute("slideshow.preview", &json!({"height": 90})).unwrap(); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(160), Some(90))); + // the pixels: red margins, the photo inside + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 320, 200).unwrap(); + assert_eq!((img.width, img.height), (320, 200)); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); + assert_eq!(img.get(160, 196), [255, 0, 0, 255]); + assert_ne!(img.get(160, 100), [255, 0, 0, 255], "the photo in the cell"); + // refused: bad sizes, a photo that isn't there + for bad in [json!({"width": 4}), json!({"height": 100000}), json!({"width": "big"}), json!({"id": 999_999}), json!({"id": "one"})] { + assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}"); + } + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn a_background_image_and_the_slide_key() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + let plain = s.slide_layout.clone(); + let job = s.slide_job(photos[0], 320, 200, None).unwrap(); + let loupe = s.loupe_job(photos[0], 320, 200, true).unwrap(); + assert_ne!(job.key, loupe.key, "a slide is not the photo's render"); + // a background image fills the slide around the photo + let bg = + s.slide_layout.merged(&json!({"margins": 0.2, "backdropColor": "#ff0000", "backgroundImage": photos[2].0, "backgroundOpacity": 1})).unwrap(); + let img = s.slide_now(&bg, photos[0], 320, 200).unwrap(); + assert_ne!(img.get(3, 3), [255, 0, 0, 255], "the background photo, not the colour"); + // it is rendered once and reused + assert!(s.slide_backdrop.lock().unwrap().is_some()); + let other = s.slide_job_with(&bg, photos[1], 320, 200, None, false).unwrap(); + assert_ne!(other.key, s.slide_job_with(&plain, photos[1], 320, 200, None, false).unwrap().key); + // a background photo that isn't in the catalog: the colour alone + let gone = bg.merged(&json!({"backgroundImage": 987_654})).unwrap(); + let img = s.slide_now(&gone, photos[0], 320, 200).unwrap(); + assert_eq!(img.get(3, 3), [255, 0, 0, 255]); +} + +#[test] +fn slide_overlays_are_validated_and_persist_with_the_library() { + let lib = temp_dir("overlays"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!(r["identityPlate"]["enabled"], false, "defaults: {r}"); + assert_eq!(r["texts"], json!([])); + assert!(s.slides_plain()); + let r = s + .execute( + "slideshow.overlays", + &json!({"identityPlate": {"enabled": true, "text": "Studio Nord"}, "ratingStars": {"enabled": true}, "addText": {"text": "{caption} {bogus}"}, "showWatermark": true, "watermark": "© Ann"}), + ) + .unwrap(); + assert_eq!(r["texts"].as_array().map(Vec::len), Some(1)); + assert_eq!(r["watermark"]["text"], "© Ann"); + assert_eq!(r["warnings"].as_array().map(Vec::len), Some(1), "the unknown token is reported: {r}"); + assert!(!s.slides_plain(), "overlays make playback compose its slides"); + // a bad value changes nothing (the good keys beside it included) + for bad in + [json!({"identityPlate": {"size": "big"}, "showWatermark": false}), json!({"texts": [{"anchor": "nowhere"}]}), json!({"removeText": 5})] + { + assert!(s.execute("slideshow.overlays", &bad).is_err(), "{bad}"); + } + assert!(s.slide_overlays.show_watermark); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.overlays", &json!({})).unwrap(); + assert_eq!((r["identityPlate"]["text"].as_str(), r["texts"][0]["text"].as_str()), (Some("Studio Nord"), Some("{caption} {bogus}"))); + // the layout beside it is untouched by an overlay change + let before = s.slide_layout.clone(); + s.execute("slideshow.overlays", &json!({"removeText": 0})).unwrap(); + assert_eq!(s.slide_layout, before); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideOverlays": {"identityPlate": {"size": 1e9, "opacity": -2}, "texts": [{"size": 1e300}]}}"#) + .unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!((s.slide_overlays.identity_plate.size, s.slide_overlays.identity_plate.opacity), (crate::slide_overlay::PLATE_SIZE_MAX, 0.0)); + assert_eq!(s.slide_overlays.texts[0].size, crate::slide_overlay::TEXT_SIZE_MAX); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_draws_the_overlays() { + let mut s = Session::with_demo(); + let photos = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [photos[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 4, "ids": [photos[0].0]})).unwrap(); + s.execute("slideshow.layout", &json!({"margins": 0.2, "backdropColor": "#000000"})).unwrap(); + let before = s.slide_job(photos[0], 320, 200, None).unwrap().key; + let plain = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + s.execute( + "slideshow.overlays", + &json!({ + "identityPlate": {"enabled": true, "text": "LC", "size": 0.1, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.01, "offsetY": 0.01}, + "ratingStars": {"enabled": true, "color": "#ffff00", "size": 0.05, "anchor": "bottomRight", "offsetX": -0.01, "offsetY": -0.01, "relativeTo": "slide"}, + "addText": {"text": "{file}", "size": 0.05, "color": "#ffffff", "opacity": 1, "anchor": "bottom", "offsetX": 0, "offsetY": -0.01}, + }), + ) + .unwrap(); + assert_ne!(s.slide_job(photos[0], 320, 200, None).unwrap().key, before, "overlays change the slide's render key"); + let r = s.execute("slideshow.preview", &json!({"width": 640, "height": 400})).unwrap(); + let kinds: Vec<&str> = r["overlays"].as_array().unwrap().iter().filter_map(|o| o["kind"].as_str()).collect(); + assert_eq!(kinds, ["identityPlate", "ratingStars", "text0"]); + let img = s.slide_now(&s.slide_layout.clone(), photos[0], 640, 400).unwrap(); + let count = |img: &lightcraft_raster::Rgba8, b: &serde_json::Value, want: fn([u8; 4]) -> bool| { + let v: Vec<usize> = (0..4).map(|i| b[i].as_f64().unwrap() as usize).collect(); + (v[1]..v[3]).flat_map(|y| (v[0]..v[2]).map(move |x| (x, y))).filter(|&(x, y)| want(img.get(x, y))).count() + }; + for (i, (kind, want)) in [ + ("identityPlate", (|p: [u8; 4]| p[1] > 200 && p[0] < 60) as fn([u8; 4]) -> bool), + ("ratingStars", |p| p[0] > 200 && p[1] > 200 && p[2] < 60), + ("text0", |p| p[0] > 200 && p[1] > 200 && p[2] > 200), + ] + .into_iter() + .enumerate() + { + let b = &r["overlays"][i]["box"]; + assert!(count(&img, b, want) > 10, "{kind} is drawn in its box {b}"); + assert_eq!(count(&plain, b, want), 0, "{kind}: not there without overlays"); + } +} + +#[test] +fn slide_titles_are_validated_persist_and_preview() { + let lib = temp_dir("titles"); + { + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + let r = s.execute("slideshow.titles", &json!({})).unwrap(); + assert_eq!((r["intro"]["enabled"].as_bool(), r["ending"]["enabled"].as_bool()), (Some(false), Some(false)), "off by default: {r}"); + assert_eq!(r["intro"]["content"], "identityPlate"); + assert_eq!(r["intro"]["duration"], serde_json::Value::Null, "the slide duration"); + let r = s + .execute( + "slideshow.titles", + &json!({"intro": {"enabled": true, "color": "#102030", "content": "text", "text": "Iceland 2026", "scale": 0.12, "duration": 3}, "ending": {"enabled": true, "color": [255, 255, 255]}}), + ) + .unwrap(); + assert_eq!((r["intro"]["color"].as_str(), r["intro"]["duration"].as_f64()), (Some("#102030"), Some(3.0))); + assert_eq!(r["ending"]["color"], "#ffffff"); + // a bad value changes nothing, the good keys beside it included + for bad in [ + json!({"intro": {"enabled": false, "scale": "huge"}}), + json!({"ending": {"content": "credits"}}), + json!({"intro": {"duration": -1, "color": "blue"}}), + json!({"middle": {}}), + ] { + assert!(s.execute("slideshow.titles", &bad).is_err(), "{bad}"); + } + assert!(s.slide_titles.intro.enabled); + // out of range: clamped + let r = s.execute("slideshow.titles", &json!({"ending": {"scale": 4, "duration": 1e6}})).unwrap(); + assert_eq!((r["ending"]["scale"].as_f64(), r["ending"]["duration"].as_f64()), (Some(0.5), Some(crate::slideshow::SLIDE_MAX))); + // a plate image that can't be read is reported + s.execute("slideshow.overlays", &json!({"identityPlate": {"image": "/no/such/plate.png"}})).unwrap(); + let r = s.execute("slideshow.titles", &json!({})).unwrap(); + let w = r["warnings"].as_array().unwrap(); + assert!(w.iter().any(|w| w.as_str().unwrap().contains("ending screen")), "{r}"); + drop(s); + } + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert_eq!(s.slide_titles.intro.text, "Iceland 2026", "saved with the library"); + assert_eq!(s.slide_titles.durations(4.0), (Some(3.0), Some(crate::slideshow::SLIDE_MAX))); + drop(s); + // hand-edited values out of range are clamped when the library opens + std::fs::write(lib.join("prefs.json"), r#"{"slideTitles": {"intro": {"enabled": true, "scale": -4, "duration": 1e99}}}"#).unwrap(); + let mut s = Session::new().with_fs(); + s.open_library(&lib, false).unwrap(); + assert!(s.slide_titles.intro.enabled); + assert_eq!((s.slide_titles.intro.scale, s.slide_titles.intro.duration), (crate::slide_overlay::SIZE_MIN, Some(crate::slideshow::SLIDE_MAX))); + drop(s); + let _ = std::fs::remove_dir_all(&lib); +} + +#[test] +fn slide_preview_renders_a_title_screen() { + let mut s = Session::with_demo(); + s.execute( + "slideshow.titles", + &json!({"ending": {"color": "#00ff00", "content": "text", "text": "Fin", "overrideColor": true, "textColor": "#ff0000", "scale": 0.2}}), + ) + .unwrap(); + let r = s.execute("slideshow.preview", &json!({"title": "ending", "width": 400, "height": 250})).unwrap(); + assert_eq!( + (r["title"].as_str(), r["enabled"].as_bool(), r["width"].as_u64(), r["height"].as_u64()), + (Some("ending"), Some(false), Some(400), Some(250)) + ); + let png = base64_decode(r["png"].as_str().unwrap()); + assert_eq!(png_size(&png), (400, 250)); + let b: Vec<f64> = r["plate"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); + assert!((b[3] - b[1] - 50.0).abs() < 1.5, "20 % of the 250 px short side: {b:?}"); + assert!(((b[0] + b[2]) / 2.0 - 200.0).abs() < 1.0 && ((b[1] + b[3]) / 2.0 - 125.0).abs() < 1.0, "centred: {b:?}"); + // the same pixels as playback composes + let img = s.title_compose(crate::slideshow::Title::Ending, 400, 250).compose(); + assert_eq!(img.get(3, 3), [0, 255, 0, 255]); + assert!(img.data.iter().any(|p| p[0] > 200 && p[1] < 60), "the red text"); + // no photo needed; refused: an unknown title, an id with it, bad sizes + s.execute("library.select", &json!({"ids": []})).unwrap(); + assert!(s.execute("slideshow.preview", &json!({"title": "intro"})).is_ok()); + for bad in [json!({"title": "credits"}), json!({"title": 1}), json!({"title": "intro", "id": 1}), json!({"title": "intro", "width": 3})] { + assert!(s.execute("slideshow.preview", &bad).is_err(), "{bad}"); + } +} diff --git a/crates/engine/src/tests_slide_template.rs b/crates/engine/src/tests_slide_template.rs new file mode 100644 index 000000000..f7a899c43 --- /dev/null +++ b/crates/engine/src/tests_slide_template.rs @@ -0,0 +1,363 @@ +//! Slideshow templates: built-ins, user template files, validation, apply / previous. + +use std::path::PathBuf; + +use serde_json::{Value, json}; + +use super::*; +use crate::Session; + +fn temp_dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("lc-sstpl-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// A demo session whose user templates live in a fresh folder. +fn session(tag: &str) -> (Session, PathBuf) { + let dir = temp_dir(tag); + let mut s = Session::with_demo(); + s.slide_templates_dir = Some(dir.join(DIR_NAME)); + (s, dir) +} + +fn run(s: &mut Session, id: &str, p: Value) -> Value { + s.execute(id, &p).unwrap_or_else(|e| panic!("{id} {p}: {e}")) +} + +fn err(s: &mut Session, id: &str, p: Value) -> String { + match s.execute(id, &p) { + Ok(v) => panic!("{id} {p} should fail, got {v}"), + Err(e) => e.to_string(), + } +} + +fn ids(s: &mut Session) -> Vec<String> { + run(s, "slideshow.templates.list", json!({}))["templates"].as_array().unwrap().iter().map(|t| t["id"].as_str().unwrap().to_string()).collect() +} + +/// A look different from the defaults in every section. +fn custom(s: &mut Session) { + run(s, "slideshow.playback", json!({"slideDuration": 7.5, "fadeDuration": 0.5, "fadeColor": "#102030", "randomOrder": true, "repeat": false})); + run( + s, + "slideshow.layout", + json!({"margins": 0.1, "strokeBorder": true, "strokeColor": "#ff0000", "castShadow": true, "aspect": "4:3", "backdropColor": "#334455"}), + ); + run( + s, + "slideshow.overlays", + json!({"ratingStars": {"enabled": true}, "addText": {"text": "{caption} — {exposure}", "anchor": "top"}, "showWatermark": true, "watermark": "© me"}), + ); + run(s, "slideshow.titles", json!({"intro": {"enabled": true, "content": "text", "text": "Hello", "duration": 3}})); +} + +#[test] +fn builtins_are_valid_distinct_and_apply_cleanly() { + let b = builtins(); + assert_eq!(b.len(), BUILTINS.len(), "every built-in parses"); + assert!((4..=6).contains(&b.len())); + let mut names: Vec<_> = b.iter().map(|t| t.name.to_lowercase()).collect(); + names.dedup(); + assert_eq!(names.len(), b.len(), "unique names"); + // our own names: none of Lightroom Classic's built-in slideshow template names + for lr in ["default", "simple", "caption and rating", "crop to fill", "exif metadata", "widescreen"] { + assert!(!names.contains(&lr.to_string()), "{lr}"); + } + for t in &b { + assert!(!t.description.is_empty(), "{}", t.id); + // no image or font files: nothing that needs an attribution or can go missing + assert!( + t.look.overlays.identity_plate.image.is_empty() && t.look.overlays.watermark.image.is_empty() && t.look.layout.background_image.is_none() + ); + // the document form reads back to the same look + let back = parse(&serde_json::to_vec(&t.to_doc()).unwrap()).unwrap(); + assert_eq!(back.look, t.look, "{}", t.id); + } + let looks: Vec<_> = b.iter().map(|t| &t.look).collect(); + for (i, a) in looks.iter().enumerate() { + assert!(looks.iter().skip(i + 1).all(|o| o != a), "each built-in looks different"); + } + let (mut s, dir) = session("builtins"); + let active = s.active().unwrap(); + for t in b { + let r = run(&mut s, "slideshow.templates.apply", json!({"id": t.id})); + assert_eq!(r["warnings"], json!([]), "{}: {r}", t.id); + assert_eq!( + SlideLook { playback: PlaybackSettings { prepare_previews: true, ..t.look.playback.clone() }, ..t.look.clone() }, + SlideLook::of(&s) + ); + let img = run(&mut s, "slideshow.preview", json!({"id": active.0, "width": 160})); + assert_eq!(img["width"], 160, "{}", t.id); + } + // by name, case-insensitive + run(&mut s, "slideshow.templates.apply", json!({"name": "edge TO edge"})); + assert!(s.slide_layout.zoom_to_fill); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn save_list_apply_restores_exactly_and_previous_goes_back() { + let (mut s, dir) = session("roundtrip"); + s.slideshow.prepare_previews = false; + custom(&mut s); + let saved = SlideLook::of(&s); + let r = run(&mut s, "slideshow.templates.save", json!({"name": "My Look"})); + assert_eq!(r["id"], "user:My Look"); + let path = PathBuf::from(r["path"].as_str().unwrap()); + assert_eq!(path, dir.join(DIR_NAME).join("My Look.json")); + let doc: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!((doc["format"].as_str(), doc["version"].as_u64(), doc["name"].as_str()), (Some(FORMAT), Some(VERSION), Some("My Look"))); + assert!(doc["playback"].get("preparePreviews").is_none(), "a machine preference, not part of the look"); + assert!(ids(&mut s).contains(&"user:My Look".to_string())); + + run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:plain-black"})); + assert_ne!(SlideLook::of(&s), saved); + assert!(!s.slideshow.prepare_previews, "prepare previews is kept"); + let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:My Look"})); + assert_eq!(r["applied"], "user:My Look"); + assert_eq!(SlideLook::of(&s), saved, "applying restores every setting exactly"); + + // previous: back to Plain Black's, then forward again + let plain = builtins().into_iter().find(|t| t.id == "builtin:plain-black").unwrap(); + run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS})); + assert_eq!(s.slide_layout, plain.look.layout); + run(&mut s, "slideshow.templates.apply", json!({"id": PREVIOUS})); + assert_eq!(SlideLook::of(&s), saved); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn previous_needs_an_apply_first() { + let (mut s, dir) = session("noprev"); + assert_eq!(run(&mut s, "slideshow.templates.list", json!({}))["previous"], false); + assert!(err(&mut s, "slideshow.templates.apply", json!({"id": "previous"})).contains("no previous settings")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn update_rename_delete_export_import() { + let (mut s, dir) = session("ops"); + run(&mut s, "slideshow.templates.save", json!({"name": "Mine"})); + // a taken name, case-insensitively, and a built-in's are refused; replace overwrites + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "mine"})).contains("already exists")); + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "Plain Black"})).contains("built-in")); + run(&mut s, "slideshow.layout", json!({"margins": 0.2})); + run(&mut s, "slideshow.templates.save", json!({"name": "Mine", "replace": true})); + assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.2); + // update + run(&mut s, "slideshow.layout", json!({"margins": 0.05})); + run(&mut s, "slideshow.templates.update", json!({"id": "user:Mine"})); + assert_eq!(s.slide_template("user:Mine").unwrap().look.layout.margin_top, 0.05); + assert!(err(&mut s, "slideshow.templates.update", json!({"id": "builtin:plain-black"})).contains("built-in")); + // rename (also just the case) + let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Mine", "name": "Ours"})); + assert_eq!(r["id"], "user:Ours"); + assert!(!dir.join(DIR_NAME).join("Mine.json").exists()); + let r = run(&mut s, "slideshow.templates.rename", json!({"id": "user:Ours", "name": "OURS"})); + assert_eq!(r["id"], "user:OURS"); + assert_eq!(ids(&mut s).iter().filter(|i| i.starts_with(USER)).count(), 1); + run(&mut s, "slideshow.templates.save", json!({"name": "Other"})); + assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "user:Other", "name": "ours"})).contains("already exists")); + assert!(err(&mut s, "slideshow.templates.rename", json!({"id": "builtin:cinema-wide", "name": "X"})).contains("built-in")); + // export a user template and a built-in, import them back + let out = dir.join("out"); + let a = out.join("ours.json").to_string_lossy().to_string(); + run(&mut s, "slideshow.templates.export", json!({"id": "user:OURS", "path": a})); + let b = out.join("cinema.json").to_string_lossy().to_string(); + run(&mut s, "slideshow.templates.export", json!({"id": "builtin:cinema-wide", "path": b})); + let r = run(&mut s, "slideshow.templates.import", json!({"path": a})); + assert_eq!((r["name"].as_str(), r["renamed"].as_bool()), (Some("OURS (2)"), Some(true)), "{r}"); + assert_eq!(s.slide_template("user:OURS (2)").unwrap().look, s.slide_template("user:OURS").unwrap().look); + let r = run(&mut s, "slideshow.templates.import", json!({"path": b})); + assert_eq!(r["name"], "Cinema Wide (2)", "a built-in's name is taken too"); + let cinema = s.slide_template("builtin:cinema-wide").unwrap(); + assert_eq!(s.slide_template("user:Cinema Wide (2)").unwrap().look, cinema.look); + // delete + run(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"})); + assert!(!ids(&mut s).contains(&"user:OURS".to_string())); + assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "user:OURS"})).contains("no slideshow template")); + assert!(err(&mut s, "slideshow.templates.delete", json!({"id": "builtin:plain-black"})).contains("built-in")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn file_names_are_sanitised_and_ids_never_leave_the_folder() { + assert_eq!(file_stem("../../etc/passwd").as_deref(), Some("______etc_passwd")); + assert_eq!(file_stem(r"C:\x\y").as_deref(), Some("C__x_y")); + assert_eq!(file_stem("a:b*c?d\"e<f>g|h").as_deref(), Some("a_b_c_d_e_f_g_h")); + assert_eq!(file_stem(" Holiday (2024) ").as_deref(), Some("Holiday (2024)")); + assert_eq!(file_stem("Été à Kyōto 京都").as_deref(), Some("Été à Kyōto 京都")); + assert_eq!(file_stem("con").as_deref(), Some("con_")); + assert_eq!(file_stem("LPT1").as_deref(), Some("LPT1_")); + assert_eq!(file_stem("..."), None); + assert_eq!(file_stem("///"), None); + assert_eq!(file_stem(&"x".repeat(500)).map(|s| s.chars().count()), Some(64)); + let (mut s, dir) = session("escape"); + let folder = dir.join(DIR_NAME); + let r = run(&mut s, "slideshow.templates.save", json!({"name": "../../escape"})); + assert_eq!(r["id"], "user:______escape"); + assert!(folder.join("______escape.json").exists()); + assert!(!dir.join("escape.json").exists()); + for name in ["", " ", "...", "/", "a\nb", &"y".repeat(81)] { + err(&mut s, "slideshow.templates.save", json!({"name": name})); + } + // an id can't point outside the folder (or at a file LightCraft wouldn't name) + std::fs::write(dir.join("victim.json"), b"{}").unwrap(); + for id in ["user:../victim", "user:..", "user:/etc/passwd", "user:a/b", "user:", "user:x.json"] { + let e = err(&mut s, "slideshow.templates.delete", json!({"id": id})); + assert!(e.contains("isn't a template id"), "{id}: {e}"); + } + assert!(dir.join("victim.json").exists()); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn bad_files_are_reported_and_skipped() { + let (mut s, dir) = session("bad"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + run(&mut s, "slideshow.templates.save", json!({"name": "Good"})); + let doc = |extra: Value| { + let mut d = json!({"format": FORMAT, "version": 1, "name": "X"}); + for (k, v) in extra.as_object().unwrap() { + d[k] = v.clone(); + } + serde_json::to_vec(&d).unwrap() + }; + let files: Vec<(&str, Vec<u8>, &str)> = vec![ + ("truncated.json", br#"{"format": "lightcraft.slideshowTemplate", "vers"#.to_vec(), "not a slideshow template"), + ("binary.json", vec![0xff, 0xfe, 0x00, 0x01], "not UTF-8"), + ("array.json", b"[1, 2]".to_vec(), "not a JSON object"), + ("preset.json", br#"{"format": "lightcraft.preset", "version": 1}"#.to_vec(), "format `lightcraft.preset`"), + ("newer.json", doc(json!({"version": 2})), "newer LightCraft"), + ("noversion.json", br#"{"format": "lightcraft.slideshowTemplate"}"#.to_vec(), "no `version`"), + ("badversion.json", doc(json!({"version": -1})), "`version`"), + ("unknown.json", doc(json!({"music": {}})), "unknown key `music`"), + ("nan.json", doc(json!({"layout": {"margins": "wide"}})), "layout"), + ("badkey.json", doc(json!({"overlays": {"fireworks": true}})), "overlays"), + ("badtype.json", doc(json!({"titles": [1]})), "`titles` must be an object"), + ("toomanytexts.json", doc(json!({"overlays": {"texts": vec![json!({}); 40]}})), "at most"), + ("badname.json", doc(json!({"name": 5})), "`name`"), + ("huge.json", vec![b' '; MAX_FILE as usize + 1], "too large"), + ("bad name!.json", doc(json!({})), "file name"), + ]; + for (f, bytes, _) in &files { + std::fs::write(folder.join(f), bytes).unwrap(); + } + std::fs::create_dir_all(folder.join("folder.json")).unwrap(); + std::fs::write(folder.join("notes.txt"), b"ignored").unwrap(); + let r = run(&mut s, "slideshow.templates.list", json!({})); + let user: Vec<_> = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).collect(); + assert_eq!(user.len(), 1, "only the good one: {r}"); + let problems: Vec<String> = r["problems"].as_array().unwrap().iter().map(|p| p.as_str().unwrap().to_string()).collect(); + for (f, _, why) in &files { + assert!(problems.iter().any(|p| p.starts_with(f) && p.contains(why)), "{f}: {why} in {problems:?}"); + } + assert!(problems.iter().any(|p| p.starts_with("folder.json") && p.contains("not a regular file")), "{problems:?}"); + assert_eq!(problems.len(), files.len() + 1, "{problems:?}"); + // the same refusals on import + for (f, _, why) in files.iter().filter(|(f, _, _)| *f != "bad name!.json") { + let e = err(&mut s, "slideshow.templates.import", json!({"path": folder.join(f)})); + assert!(e.contains(why), "{f}: {e}"); + } + // out-of-range numbers in a hand-edited file are clamped, never applied as they are + std::fs::write(folder.join("wild.json"), doc(json!({"layout": {"margins": 9, "shadowAngle": 1e300}, "playback": {"slideDuration": 1e9}}))) + .unwrap(); + // written by hand, not through LightCraft: the folder's time may not have moved since the + // listing above (same clock tick), so the cached listing is dropped as a write would drop it + s.slide_templates_cache = None; + let t = s.slide_template("user:wild").unwrap(); + assert_eq!(t.look.layout.margin_left, lightcraft_slideshow::MARGIN_MAX); + assert_eq!(t.look.playback.slide_duration, crate::slideshow::SLIDE_MAX); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn thousands_of_templates_are_capped_not_fatal() { + let (mut s, dir) = session("many"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + let body = |i: usize| format!(r#"{{"format": "{FORMAT}", "version": 1, "name": "T{i}"}}"#); + for i in 0..MAX_TEMPLATES + 50 { + std::fs::write(folder.join(format!("T{i:05}.json")), body(i)).unwrap(); + } + let r = run(&mut s, "slideshow.templates.list", json!({})); + let user = r["templates"].as_array().unwrap().iter().filter(|t| t["builtin"] == false).count(); + assert_eq!(user, MAX_TEMPLATES); + assert!(r["problems"][0].as_str().unwrap().contains("only the first"), "{r}"); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn missing_files_apply_with_a_warning() { + let (mut s, dir) = session("missing"); + let folder = dir.join(DIR_NAME); + std::fs::create_dir_all(&folder).unwrap(); + let d = json!({ + "format": FORMAT, "version": 1, "name": "Logo", + "layout": {"backgroundImage": 987654}, + "overlays": {"identityPlate": {"enabled": true, "image": "/no/such/logo.png"}, "showWatermark": true, "watermark": {"text": "", "image": "/no/such/mark.png"}} + }); + std::fs::write(folder.join("Logo.json"), serde_json::to_vec(&d).unwrap()).unwrap(); + let r = run(&mut s, "slideshow.templates.apply", json!({"id": "user:Logo"})); + let w = r["warnings"].to_string(); + assert!(w.contains("background image") && w.contains("logo.png") && w.contains("mark.png"), "{w}"); + assert_eq!(s.slide_overlays.identity_plate.image, "/no/such/logo.png", "applied anyway"); + // the slide still renders (the backdrop colour, no plate) + let id = s.active().unwrap(); + run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 64})); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn preview_of_a_template_leaves_the_settings_alone() { + let (mut s, dir) = session("preview"); + custom(&mut s); + let before = SlideLook::of(&s); + let id = s.active().unwrap(); + let mine = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120})); + let r = run(&mut s, "slideshow.preview", json!({"id": id.0, "width": 200, "height": 120, "template": "builtin:edge-to-edge"})); + assert_eq!(r["template"], "builtin:edge-to-edge"); + assert_eq!(r["photo"], json!([0.0, 0.0, 200.0, 120.0]), "zoom to fill, no margins: {r}"); + assert_ne!(r["photo"], mine["photo"]); + assert_eq!(r["overlays"], json!([]), "the template's overlays (none), not the library's"); + let t = run(&mut s, "slideshow.preview", json!({"title": "ending", "template": "builtin:cinema-wide", "width": 160})); + assert_eq!((t["enabled"].as_bool(), t["height"].as_u64()), (Some(true), Some(90)), "the template's ending, at its 16:9 aspect: {t}"); + assert_eq!(SlideLook::of(&s), before, "nothing changed"); + assert!(s.slide_templates_previous.is_none()); + assert!(err(&mut s, "slideshow.preview", json!({"template": "builtin:nope"})).contains("no slideshow template")); + assert!(err(&mut s, "slideshow.preview", json!({"template": 3})).contains("template id")); + let _ = std::fs::remove_dir_all(dir); +} + +#[test] +fn without_a_settings_folder_only_builtins() { + let mut s = Session::with_demo(); + assert!(s.slide_templates_dir.is_none()); + let r = run(&mut s, "slideshow.templates.list", json!({})); + assert_eq!(r["templates"].as_array().unwrap().len(), BUILTINS.len()); + assert!(err(&mut s, "slideshow.templates.save", json!({"name": "X"})).contains("settings folder")); + run(&mut s, "slideshow.templates.apply", json!({"id": "builtin:gallery-wall"})); + assert!(s.slide_overlays.rating_stars.enabled); +} + +#[test] +fn a_saved_template_reads_back_through_its_section_validation() { + let (mut s, dir) = session("sections"); + custom(&mut s); + let look = SlideLook::of(&s); + let parsed = parse(&serde_json::to_vec(&doc("N", "d", &look)).unwrap()).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("N")); + assert_eq!(parsed.description, "d"); + assert_eq!(parsed.look.layout, look.layout); + assert_eq!(parsed.look.overlays, look.overlays); + assert_eq!(parsed.look.titles, look.titles); + assert_eq!(PlaybackSettings { prepare_previews: look.playback.prepare_previews, ..parsed.look.playback }, look.playback); + // a BOM and a missing name are fine + let mut bytes = "\u{feff}".as_bytes().to_vec(); + bytes.extend(br#"{"format": "lightcraft.slideshowTemplate", "version": 1}"#); + assert_eq!(parse(&bytes).unwrap().name, None); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/engine/src/tests_slideshow_export.rs b/crates/engine/src/tests_slideshow_export.rs new file mode 100644 index 000000000..766ae66f5 --- /dev/null +++ b/crates/engine/src/tests_slideshow_export.rs @@ -0,0 +1,334 @@ +//! `slideshow.export`: PDF, JPEG sequence and AV1 video of the slideshow, validation, the activity +//! row, cancel and photos that can't be rendered. + +use std::path::{Path, PathBuf}; +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, Source}; +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-ssexp-{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() + } + fn files(&self, rel: &str) -> Vec<String> { + let mut v: Vec<String> = std::fs::read_dir(self.0.join(rel)) + .map(|d| d.flatten().map(|e| e.file_name().to_string_lossy().to_string()).collect()) + .unwrap_or_default(); + v.sort(); + v + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A demo session playing 1 s slides with 0.5 s fades, its first `n` photos as ids. +fn session(n: usize) -> (Session, Vec<u64>) { + let mut s = Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1.0, "fadeDuration": 0.5})).unwrap(); + let ids = s.catalog.photos().take(n).map(|p| p.id.0).collect(); + (s, ids) +} + +fn export(s: &mut Session, p: Value) -> Value { + let mut p = p; + p["wait"] = json!(true); + s.execute("slideshow.export", &p).unwrap_or_else(|e| panic!("{p}: {e}")) +} + +#[test] +fn every_parameter_is_checked() { + let (mut s, ids) = session(2); + for p in [ + json!({"path": "x.pdf"}), + json!({"format": "gif", "path": "x.gif"}), + json!({"format": "pdf"}), + json!({"format": "pdf", "path": " "}), + json!({"format": "pdf", "path": "x.pdf", "size": 10}), + json!({"format": "pdf", "path": "x.pdf", "size": 100_000}), + json!({"format": "pdf", "path": "x.pdf", "size": "big"}), + json!({"format": "pdf", "path": "x.pdf", "size": -5}), + json!({"format": "video", "path": "x.mp4", "size": 4320}), + json!({"format": "video", "path": "x.mp4", "size": "8kp"}), + json!({"format": "pdf", "path": "x.pdf", "quality": 0}), + json!({"format": "pdf", "path": "x.pdf", "quality": 101}), + json!({"format": "video", "path": "x.mp4", "fps": 29}), + json!({"format": "video", "path": "x.mp4", "fps": 30.5}), + json!({"format": "video", "path": "x.avi", "container": "avi"}), + json!({"format": "video", "path": "x.mp4", "speed": 11}), + json!({"format": "pdf", "path": "x.pdf", "fullScreen": "yes"}), + json!({"format": "pdf", "path": "x.pdf", "name": "a"}), + json!({"format": "jpeg", "path": "out", "name": "../up"}), + json!({"format": "jpeg", "path": "out", "name": ""}), + json!({"format": "pdf", "path": "x.pdf", "ids": "all"}), + json!({"format": "pdf", "path": "x.pdf", "ids": ["one"]}), + json!({"format": "pdf", "path": "x.pdf", "ids": []}), + json!({"format": "pdf", "path": "x.pdf", "ids": [u64::MAX]}), + json!({"format": "pdf", "path": "x.pdf", "typo": 1}), + json!({"format": "pdf", "path": "x.pdf", "wait": "now"}), + json!("pdf"), + ] { + let e = s.execute("slideshow.export", &p).err().unwrap_or_else(|| panic!("accepted {p}")); + assert!(!e.to_string().is_empty()); + } + assert!(s.activity.list().is_empty(), "nothing started"); + assert!(s.slideshow_export.is_none()); + // the photos in view: none + s.execute("library.filter", &json!({"text": "zzz no photo is called this"})).unwrap(); + assert!(s.visible_cloned().is_empty()); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": "x.pdf"})).unwrap_err(); + assert!(e.to_string().contains("no photos in view"), "{e}"); + // in view: exported without ids + s.execute("library.filter", &json!({"text": ""})).unwrap(); + assert!(!s.visible_cloned().is_empty() && !ids.is_empty()); +} + +/// A minimal PDF reader: the cross-reference table points at every object, and the pages. +fn pdf_pages(bytes: &[u8]) -> Vec<String> { + let text = String::from_utf8_lossy(bytes); + let xref = text.rsplit("startxref\n").next().unwrap().lines().next().unwrap().parse::<usize>().unwrap(); + assert!(bytes[xref..].starts_with(b"xref\n0 ")); + let table = std::str::from_utf8(&bytes[xref..]).unwrap(); + let count: usize = table.lines().nth(1).unwrap().split_whitespace().nth(1).unwrap().parse().unwrap(); + for (id, line) in table.lines().skip(3).take(count - 1).enumerate() { + let offset: usize = line.split_whitespace().next().unwrap().parse().unwrap(); + assert!(bytes[offset..].starts_with(format!("{} 0 obj\n", id + 1).as_bytes()), "object {} misplaced", id + 1); + } + text.split(" 0 obj\n").filter(|o| o.starts_with("<< /Type /Page ")).map(|o| o.split("\nendobj").next().unwrap().to_string()).collect() +} + +/// The JPEGs embedded in a PDF, decoded. +fn pdf_images(bytes: &[u8]) -> Vec<lightcraft_raster::Rgba8> { + let mut out = Vec::new(); + let mut at = 0; + while let Some(start) = bytes[at..].windows(3).position(|v| v == [0xff, 0xd8, 0xff]).map(|p| p + at) { + let end = bytes[start..].windows(10).position(|v| v == b"\nendstream").unwrap() + start; + out.push(lightcraft_codecs::decode(&bytes[start..end], Default::default()).unwrap().to_srgb8()); + at = end; + } + out +} + +#[test] +fn pdf_has_a_page_per_slide_titles_included() { + let dir = Scratch::new("pdf"); + let (mut s, ids) = session(3); + s.execute("slideshow.titles", &json!({"intro": {"enabled": true, "content": "text", "text": "Hello"}, "ending": {"enabled": true}})).unwrap(); + let path = dir.path("Show.pdf"); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 160, "quality": 80, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["slides"].as_u64(), r["width"].as_u64(), r["height"].as_u64()), (Some(5), Some(5), Some(160), Some(90))); + let bytes = std::fs::read(&path).unwrap(); + assert!(bytes.starts_with(b"%PDF-1.4")); + let pages = pdf_pages(&bytes); + assert_eq!(pages.len(), 5); + assert!(pages.iter().all(|p| p.contains("/MediaBox [0 0 160.00 90.00]") && p.contains("/Dur 1.00")), "{pages:?}"); + assert!(!pages[0].contains("/Trans"), "nothing fades into the first slide"); + assert!(pages[1].contains("/Trans << /S /Fade /D 0.50 >>")); + let text = String::from_utf8_lossy(&bytes); + assert!(text.contains("/Count 5 ") && text.contains("/PageMode /FullScreen")); + let images = pdf_images(&bytes); + assert_eq!(images.len(), 5); + assert!(images.iter().all(|i| (i.width, i.height) == (160, 90))); + // the intro (black with white text) and a photo slide differ + let mean = |i: &lightcraft_raster::Rgba8| i.data.iter().map(|p| u64::from(p[1])).sum::<u64>() / i.data.len() as u64; + assert!(mean(&images[0]) < mean(&images[1]), "the intro screen is the first page"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); + // no full screen; a 4:3 aspect makes 4:3 pages + s.execute("slideshow.layout", &json!({"aspect": "4:3"})).unwrap(); + let r = export(&mut s, json!({"format": "pdf", "path": path, "size": 120, "fullScreen": false, "ids": ids})); + assert_eq!((r["width"].as_u64(), r["height"].as_u64()), (Some(120), Some(90))); + assert!(!String::from_utf8_lossy(&std::fs::read(&path).unwrap()).contains("/FullScreen")); +} + +#[test] +fn jpeg_sequence_is_numbered_in_a_folder() { + let dir = Scratch::new("jpeg"); + let (mut s, ids) = session(3); + let out = dir.path("Trip"); + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 128, "ids": ids})); + assert_eq!(dir.files("Trip"), vec!["Trip-001.jpg", "Trip-002.jpg", "Trip-003.jpg"]); + assert_eq!(r["files"].as_array().unwrap().len(), 3); + let img = lightcraft_codecs::decode(&std::fs::read(Path::new(&out).join("Trip-002.jpg")).unwrap(), Default::default()).unwrap().to_srgb8(); + assert_eq!((img.width, img.height), (128, 72)); + // a name of its own, and a portrait-free square check: the long side is the size + let r = export(&mut s, json!({"format": "jpeg", "path": out, "size": 64, "name": "Slide", "ids": [ids[0]]})); + assert_eq!(r["files"][0].as_str().unwrap(), Path::new(&out).join("Slide-001.jpg").to_string_lossy()); +} + +/// MP4 boxes of a byte range: (type, body range). +fn boxes(d: &[u8], r: std::ops::Range<usize>) -> Vec<([u8; 4], std::ops::Range<usize>)> { + let mut out = Vec::new(); + let mut p = r.start; + while p + 8 <= r.end { + let size32 = u32::from_be_bytes(d[p..p + 4].try_into().unwrap()) as usize; + let kind: [u8; 4] = d[p + 4..p + 8].try_into().unwrap(); + let (hdr, size) = if size32 == 1 { (16, u64::from_be_bytes(d[p + 8..p + 16].try_into().unwrap()) as usize) } else { (8, size32) }; + assert!(size >= hdr && p + size <= r.end); + out.push((kind, p + hdr..p + size)); + p += size; + } + assert_eq!(p, r.end); + out +} + +fn child(d: &[u8], r: std::ops::Range<usize>, path: &[&[u8; 4]]) -> std::ops::Range<usize> { + path.iter().fold(r, |r, k| boxes(d, r).into_iter().find(|(t, _)| t == *k).unwrap().1) +} + +#[test] +fn video_is_av1_in_mp4_with_fades_as_frames() { + let dir = Scratch::new("mp4"); + let (mut s, ids) = session(2); + let path = dir.path("show.mp4"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": 64, "fps": 24, "quality": 40, "ids": ids})); + // 2 slides × 1 s at 24 fps; the second fades in over 12 frames + assert_eq!((r["frames"].as_u64(), r["seconds"].as_f64(), r["width"].as_u64(), r["height"].as_u64()), (Some(48), Some(2.0), Some(114), Some(64))); + assert_eq!(r["samples"].as_u64(), Some(1 + 12 + 1), "held slides are one sample each, fades a frame each"); + assert_eq!((r["codec"].as_str(), r["audio"].as_bool(), r["container"].as_str()), (Some("av1"), Some(false), Some("mp4"))); + let d = std::fs::read(&path).unwrap(); + let top: Vec<_> = boxes(&d, 0..d.len()).into_iter().map(|(k, _)| k).collect(); + assert_eq!(top, vec![*b"ftyp", *b"mdat", *b"moov"]); + let stbl = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"minf", b"stbl"]); + let be = |at: usize| u32::from_be_bytes(d[at..at + 4].try_into().unwrap()); + let stsz = child(&d, stbl.clone(), &[b"stsz"]); + assert_eq!(be(stsz.start + 8), 14, "sample count"); + let stts = child(&d, stbl.clone(), &[b"stts"]); + let runs = be(stts.start + 4) as usize; + let ticks: u32 = (0..runs).map(|i| be(stts.start + 8 + i * 8) * be(stts.start + 12 + i * 8)).sum(); + let mdhd = child(&d, 0..d.len(), &[b"moov", b"trak", b"mdia", b"mdhd"]); + assert_eq!(ticks / be(mdhd.start + 12), 2, "duration: 2 s"); + let stsd = child(&d, stbl, &[b"stsd"]); + let entry = &boxes(&d, stsd.start + 8..stsd.end)[0]; + assert_eq!(&entry.0, b"av01"); + let inner = boxes(&d, entry.1.start + 78..entry.1.end); + let av1c = &inner.iter().find(|(k, _)| k == b"av1C").unwrap().1; + assert_eq!(d[av1c.start], 0x81, "av1C marker and version"); + assert_eq!((d[av1c.start + 4] >> 3) & 0x0f, 1, "the sequence header OBU follows"); + assert!(dir.files("").iter().all(|f| !f.ends_with(".part"))); +} + +#[test] +fn video_in_webm_with_a_fade_colour() { + let dir = Scratch::new("webm"); + let (mut s, ids) = session(2); + s.execute("slideshow.playback", &json!({"fadeColor": "#ffffff"})).unwrap(); + let path = dir.path("show.webm"); + let r = export(&mut s, json!({"format": "video", "path": path, "size": "64p", "fps": 25, "container": "webm", "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["container"].as_str()), (Some(50), Some("webm"))); + let d = std::fs::read(&path).unwrap(); + assert!(d.starts_with(&[0x1a, 0x45, 0xdf, 0xa3]), "EBML header"); + assert!(d.windows(4).any(|w| w == b"webm") && d.windows(5).any(|w| w == b"V_AV1")); + // Segment: the 8-byte size spans the rest of the file + let seg = d.windows(4).position(|w| w == [0x18, 0x53, 0x80, 0x67]).unwrap(); + let size = u64::from_be_bytes(d[seg + 4..seg + 12].try_into().unwrap()) & 0x00ff_ffff_ffff_ffff; + assert_eq!(seg + 12 + size as usize, d.len()); +} + +fn wait_for(s: &Session) -> Value { + let deadline = Instant::now() + Duration::from_secs(120); + loop { + let run = s.slideshow_export.clone().unwrap(); + if !run.running() { + return run.json(); + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(10)); + } +} + +#[test] +fn background_export_has_an_activity_row_and_reports_its_result() { + let dir = Scratch::new("bg"); + let (mut s, ids) = session(4); + let path = dir.path("bg.pdf"); + let r = s.execute("slideshow.export", &json!({"format": "pdf", "path": path, "size": 96, "ids": ids})).unwrap(); + assert_eq!((r["background"].as_bool(), r["slides"].as_u64(), r["total"].as_u64()), (Some(true), Some(4), Some(4))); + let rows = s.activity.list(); + let row = rows.iter().find(|t| t.id == r["taskId"].as_u64().unwrap()).expect("a row in the activity stack"); + assert_eq!((row.kind, row.label.as_str(), row.total), ("export", "Exporting slideshow", 4)); + assert!(row.cancellable); + assert!(s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("other.pdf"), "ids": ids})).is_err(), "one at a time"); + let status = wait_for(&s); + assert_eq!(status["result"]["pages"], 4, "{status}"); + assert_eq!(status["done"], 4); + assert!(s.activity.list().is_empty(), "the row goes when the export ends"); + assert_eq!(s.execute("slideshow.exportStatus", &json!({})).unwrap()["result"]["path"], json!(path)); + assert!(Path::new(&path).exists()); +} + +#[test] +fn cancel_leaves_no_file_behind() { + let dir = Scratch::new("cancel"); + for (format, name, extra) in [("pdf", "c.pdf", json!({})), ("jpeg", "seq", json!({})), ("video", "c.mp4", json!({"size": 64}))] { + let (mut s, ids) = session(12); + let path = dir.path(name); + let mut p = json!({"format": format, "path": path, "ids": ids}); + p.as_object_mut().unwrap().extend(extra.as_object().unwrap().clone()); + let r = s.execute("slideshow.export", &p).unwrap(); + s.execute("activity.cancel", &json!({"id": r["taskId"]})).unwrap(); + let status = wait_for(&s); + assert!(status["error"].as_str().is_some_and(|e| e.contains("cancelled")), "{format}: {status}"); + assert!(status["cancelled"].as_bool().unwrap()); + assert!(!Path::new(&path).is_file(), "{format}: no output"); + let left: Vec<_> = dir.files("").into_iter().chain(dir.files("seq")).filter(|f| f.ends_with(".part") || f.ends_with(".jpg")).collect(); + assert!(left.is_empty(), "{format}: {left:?}"); + assert!(s.activity.list().is_empty()); + } +} + +#[test] +fn a_photo_that_cannot_render_is_skipped_with_a_warning() { + let dir = Scratch::new("skip"); + let (mut s, mut ids) = session(2); + let id = s.catalog.alloc_photo_id(); + let gone = dir.path("gone.jpg"); + s.catalog + .apply(Op::AddPhoto { photo: Box::new(Photo::new(id, Source::File { path: gone }, "gone.jpg", "JPEG", 60, 40, "2026-01-01T10:00:00")) }) + .unwrap(); + ids.insert(1, id.0); + let r = export(&mut s, json!({"format": "pdf", "path": dir.path("s.pdf"), "size": 64, "ids": ids})); + assert_eq!((r["pages"].as_u64(), r["skipped"].as_u64()), (Some(2), Some(1))); + assert!(r["warnings"][0].as_str().unwrap().contains("gone.jpg"), "{r}"); + // video: the skipped slide takes no time + let r = export(&mut s, json!({"format": "video", "path": dir.path("s.mp4"), "size": 64, "fps": 24, "ids": ids})); + assert_eq!((r["frames"].as_u64(), r["skipped"].as_u64()), (Some(48), Some(1))); + // only the broken photo: nothing to write, and nothing written + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("none.pdf"), "ids": [id.0], "wait": true})).unwrap_err(); + assert!(e.to_string().contains("no slide"), "{e}"); + assert!(dir.files("").iter().all(|f| f != "none.pdf" && !f.ends_with(".part"))); +} + +#[test] +fn unwritable_targets_and_long_videos_are_errors() { + let dir = Scratch::new("bad"); + let (mut s, ids) = session(2); + // a folder where the file should go + std::fs::create_dir_all(dir.path("taken.pdf")).unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "pdf", "path": dir.path("taken.pdf"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + // a file where the folder should go + std::fs::write(dir.path("file"), b"x").unwrap(); + let e = s.execute("slideshow.export", &json!({"format": "jpeg", "path": dir.path("file"), "size": 64, "ids": ids, "wait": true})); + assert!(e.is_err()); + assert!(s.activity.list().is_empty()); + // more than 2 hours of video: 120 s slides × 61 + s.execute("slideshow.playback", &json!({"slideDuration": 120})).unwrap(); + let many: Vec<u64> = std::iter::repeat_n(ids[0], 61).collect(); + let e = s.execute("slideshow.export", &json!({"format": "video", "path": dir.path("long.mp4"), "ids": many})).unwrap_err(); + assert!(e.to_string().contains("limited to 120 min"), "{e}"); +} diff --git a/crates/engine/src/tests_view_follow.rs b/crates/engine/src/tests_view_follow.rs new file mode 100644 index 000000000..391e38b3b --- /dev/null +++ b/crates/engine/src/tests_view_follow.rs @@ -0,0 +1,295 @@ +//! The view follows a small edit from the list it had ([`Session::visible`]): whatever the edit, the result has to +//! be the list worked out from every photo. + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::{Value, json}; + +use crate::Session; + +/// A small generator of numbers that is the same on every run. +struct Dice(u64); + +impl Dice { + fn roll(&mut self, sides: usize) -> usize { + self.0 = lightcraft_catalog::mix64(self.0); + (self.0 % sides.max(1) as u64) as usize + } +} + +/// A library of `n` photos in three folders, with ratings, flags, keywords and dates spread over them, an album +/// holding every third photo, a smart album of the photos without keywords, and a stack. +fn library(n: usize) -> (Session, Vec<PhotoId>, u64, u64) { + let mut s = Session::new(); + let mut ids = Vec::new(); + for i in 0..n { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{:04}.jpg", (i * 37) % n); + let mut p = Photo::new( + id, + Source::File { path: format!("/pics/{}/{name}", i % 3) }, + &name, + "JPEG", + 60, + 40, + &format!("2026-01-{:02}T10:00:00", 1 + i % 9), + ); + p.captured = Some(format!("2025-{:02}-{:02}T10:00:{:02}", 1 + i % 12, 1 + i % 28, i % 60)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id); + } + let pick = |s: &mut Session, ids: Vec<u64>, command: &str, params: Value| { + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute(command, &params).unwrap(); + }; + let every = |k: usize, of: usize| ids.iter().enumerate().filter(|(i, _)| i % of == k).map(|(_, id)| id.0).collect::<Vec<u64>>(); + pick(&mut s, every(0, 4), "photo.rate", json!({"rating": 3})); + pick(&mut s, every(1, 4), "photo.rate", json!({"rating": 5})); + pick(&mut s, every(0, 5), "photo.flag", json!({"flag": "pick"})); + pick(&mut s, every(0, 2), "photo.setMeta", json!({"addKeywords": ["travel"]})); + pick(&mut s, every(0, 7), "photo.setMeta", json!({"addKeywords": ["weddings"]})); + let album = s.execute("album.create", &json!({"name": "Trip"})).unwrap()["id"].as_u64().unwrap(); + s.execute("album.addPhotos", &json!({"id": album, "ids": every(0, 3)})).unwrap(); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let smart = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + pick(&mut s, vec![ids[10].0, ids[11].0, ids[12].0], "stack.group", json!({})); + (s, ids, album, smart) +} + +/// One edit at random: most touch a photo or a few (what it is rated, flagged, keyworded, developed; when it was +/// taken; where its file is; whether it is in the library at all), some change an album, a stack or the library's +/// past, and now and then a photo is added or removed for good. +fn edit(s: &mut Session, dice: &mut Dice, ids: &mut Vec<PhotoId>, album: u64) { + let chosen: Vec<u64> = (0..1 + dice.roll(3)).map(|_| ids[dice.roll(ids.len())].0).collect(); + let one = PhotoId(chosen[0]); + let n = dice.roll(1000); + // (ops a command would refuse here, or on a photo that is gone, are edits that were not made: also a case) + match dice.roll(9) { + 0 => { + let _ = + s.commit("Capture time", Op::SetCaptured { id: one, captured: Some(format!("2025-{:02}-{:02}T08:00:00", 1 + n % 12, 1 + n % 28)) }); + return; + } + 1 => { + let name = format!("REN_{n:04}.jpg"); + let source = Source::File { path: format!("/pics/{}/{name}", n % 3) }; + let _ = s.commit("Locate", Op::Relink { id: one, file_name: name, source, format: None }); + return; + } + 2 if n.is_multiple_of(4) => { + let id = s.catalog.alloc_photo_id(); + let name = format!("NEW_{n:04}.jpg"); + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", n % 3) }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00"); + p.captured = Some(format!("2025-{:02}-11T09:00:00", 1 + n % 12)); + if s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).is_ok() { + ids.push(id); + } + return; + } + 2 if n % 4 == 1 => { + let _ = s.commit("Remove", Op::RemovePhoto { id: one }); + return; + } + 2 => { + let _ = s.commit("Local", Op::SetLocal { id: one, local: n.is_multiple_of(2) }); + return; + } + _ => {} + } + // (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too) + let _ = s.execute("library.select", &json!({"ids": chosen})); + let flag = ["pick", "reject", "none"][dice.roll(3)]; + let keyword = ["travel", "weddings", "pets"][dice.roll(3)]; + let _ = match dice.roll(12) { + 0 | 1 => s.execute("photo.rate", &json!({"rating": dice.roll(6)})), + 2 => s.execute("photo.flag", &json!({"flag": flag})), + 3 | 4 => s.execute("photo.setMeta", &json!({"addKeywords": [keyword]})), + 5 | 6 => s.execute("photo.setMeta", &json!({"keywords": []})), + 7 if n.is_multiple_of(2) => s.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * (n % 9) as f64})), + 7 => s.execute("stack.group", &json!({})), + 8 => s.execute("photo.delete", &json!({})), + 9 => s.execute("album.addPhotos", &json!({"id": album, "ids": chosen})), + 10 => s.execute("album.removePhotos", &json!({"id": album, "ids": chosen})), + _ => s.undo_step().map(|_| Value::Null), + }; +} + +#[test] +fn following_an_edit_gives_the_list_worked_out_from_every_photo() { + let (mut s, mut ids, album, smart) = library(240); + let mut dice = Dice(7); + let sources = [ + json!({"kind": "all"}), + json!({"kind": "picks"}), + json!({"kind": "album", "id": smart}), + json!({"kind": "album", "id": album}), + json!({"kind": "libraryFolder", "path": "/pics/1"}), + json!({"kind": "recentlyDeleted"}), + ]; + let sorts = [ + json!({"key": "random", "seed": 11}), + json!({"key": "captureDate", "ascending": false}), + json!({"key": "rating", "ascending": true}), + json!({"key": "fileName", "ascending": true}), + json!({"key": "editDate", "ascending": false}), + json!({"key": "importDate", "ascending": true}), + ]; + let filters = [json!({"rating": 0, "text": ""}), json!({"rating": 3, "text": ""}), json!({"rating": 0, "text": "travel"})]; + let mut checked = 0; + for (i, source) in sources.iter().enumerate() { + for (j, sort) in sorts.iter().enumerate() { + let filter = &filters[(i + j) % filters.len()]; + s.execute("library.source", source).unwrap(); + s.execute("library.sort", sort).unwrap(); + s.execute("library.filter", filter).unwrap(); + let _ = s.visible_cloned(); + // (every other view holds its photos until refreshed: what stays then has to be what asking every photo + // would keep) + s.execute("library.holdView", &json!({"on": (i + j) % 2 == 1})).unwrap(); + let _ = s.visible_cloned(); + for step in 0..40 { + edit(&mut s, &mut dice, &mut ids, album); + let followed = s.visible_cloned(); + let whole = s.visible_in_full(); + assert_eq!(followed, whole, "source {source}, sort {sort}, filter {filter}, hold {}, edit {step}", s.hold_view); + checked += 1; + } + } + } + assert_eq!(checked, 1440); + // and it was following that was checked: most edits touch a photo or two, and the view kept up with those + // without asking every photo again + assert!(s.view_followed > 400, "followed {} times, worked out {} times", s.view_followed, s.view_worked_out); +} + +#[test] +fn a_view_that_asks_about_the_last_days_follows_the_clock_too() { + let (mut s, ids, _, _) = library(60); + // Given a smart album of the photos taken in the last 30 days, a day after the latest of them + let now = std::sync::Arc::new(std::sync::Mutex::new("2026-01-01T12:00:00".to_string())); + let clock = now.clone(); + s.clock = Box::new(move || clock.lock().unwrap().clone()); + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "captureDate", "op": "inLast", "value": 30}]}}); + let recent = s.execute("album.createSmart", &json!({"name": "Recent", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": recent})).unwrap(); + s.execute("library.sort", &json!({"key": "fileName", "ascending": true})).unwrap(); + let shown = s.visible_cloned(); + assert!(!shown.is_empty() && shown.len() < ids.len(), "{} of {}", shown.len(), ids.len()); + // When a photo is rated two months later + *now.lock().unwrap() = "2026-03-05T12:00:00".to_string(); + let followed = s.view_followed; + s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + // Then the photos nobody touched have left with the days: the view was asked whole, not followed + assert!(s.visible_cloned().is_empty(), "{:?}", s.visible_cloned()); + assert_eq!(s.visible_cloned(), s.visible_in_full()); + assert_eq!(s.view_followed, followed); + // And the same through a view that only looks into that album + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "album", "op": "is", "value": recent}]}}); + if let Ok(r) = s.execute("album.createSmart", &json!({"name": "In Recent", "rules": rules})) { + s.execute("library.source", &json!({"kind": "album", "id": r["id"]})).unwrap(); + let _ = s.visible_cloned(); + s.execute("library.select", &json!({"ids": [ids[0].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.visible_cloned(), s.visible_in_full()); + assert_eq!(s.view_followed, followed, "a view that looks into a clocked album is asked whole too"); + } +} + +#[test] +fn an_edit_to_many_photos_or_to_more_than_photos_is_worked_out_whole() { + let (mut s, ids, album, _) = library(600); + s.execute("library.sort", &json!({"key": "random", "seed": 3})).unwrap(); + let _ = s.visible_cloned(); + let (followed, whole) = (s.view_followed, s.view_worked_out); + // one photo rated: followed + s.execute("library.select", &json!({"ids": [ids[5].0]})).unwrap(); + s.execute("photo.rate", &json!({"rating": 1})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole)); + // five hundred photos rated at once: worked out from every photo + s.execute("library.select", &json!({"ids": ids.iter().take(500).map(|id| id.0).collect::<Vec<_>>()})).unwrap(); + s.execute("photo.rate", &json!({"rating": 2})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 1)); + // an album's photos changed: what that does to which view is not asked photo by photo + s.execute("album.addPhotos", &json!({"id": album, "ids": [ids[1].0]})).unwrap(); + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2)); + // nothing changed: neither + let _ = s.visible_cloned(); + assert_eq!((s.view_followed, s.view_worked_out), (followed + 1, whole + 2)); +} + +#[test] +fn the_albums_counts_followed_through_edits_are_the_counts_made_from_every_photo() { + use lightcraft_catalog::tally::AlbumTally; + let (mut s, mut ids, album, smart) = library(240); + // more smart albums: picks, well rated or keyworded ones, one that looks into an album of chosen photos + for rules in [ + json!({"ruleSet": {"match": "all", "rules": [{"field": "flag", "op": "is", "value": "pick"}]}}), + json!({"ruleSet": {"match": "any", "rules": [{"field": "rating", "op": "gte", "value": 4}, {"field": "keywords", "op": "contains", "value": "pets"}]}}), + json!({"album": album, "rating": 3}), + // what the edits below change besides: a label, a slider, when a photo was taken, what its file is called + json!({"label": "red"}), + json!({"edited": true}), + json!({"dateFrom": "2025-06-01"}), + json!({"text": "REN"}), + ] { + let made = s.execute("album.createSmart", &json!({"name": "More", "rules": rules})); + assert!(made.is_ok(), "{rules}: {made:?}"); + } + assert!(s.catalog.album(lightcraft_catalog::AlbumId(smart)).is_some_and(|a| a.is_smart())); + let fresh = |s: &Session| -> Vec<(u64, usize)> { + let mut v: Vec<(u64, usize)> = s.catalog.albums().map(|a| (a.id.0, s.catalog.album_count(a.id))).collect(); + v.sort_unstable(); + v + }; + let shown = |tally: &AlbumTally| -> Vec<(u64, usize)> { + let mut v: Vec<(u64, usize)> = tally.counts().iter().map(|(id, n)| (id.0, *n)).collect(); + v.sort_unstable(); + v + }; + let mut tally = AlbumTally::of(&s.catalog); + assert_eq!(shown(&tally), fresh(&s)); + let mut dice = Dice(23); + let (mut followed, mut whole) = (0, 0); + for step in 0..900 { + edit(&mut s, &mut dice, &mut ids, album); + // (not every frame draws the albums: sometimes several edits pass between two looks) + if dice.roll(4) == 0 { + continue; + } + let moved = tally.revision() != s.catalog.revision; + match (moved, tally.update(&s.catalog)) { + (true, true) => followed += 1, + (true, false) => whole += 1, + // (an edit that was refused changed nothing: nothing to follow) + (false, _) => {} + } + assert_eq!(shown(&tally), fresh(&s), "after edit {step}"); + } + assert!(followed > 300 && whole > 20, "followed {followed} times, counted whole {whole} times"); + // a photo deleted: the album of chosen photos it is in counts one less, without every photo being asked + let in_album = s + .catalog + .album(lightcraft_catalog::AlbumId(album)) + .and_then(|a| a.photos.iter().copied().find(|id| s.catalog.photo(*id).is_some_and(|p| !p.deleted))); + if let Some(id) = in_album { + let before = tally.counts()[&lightcraft_catalog::AlbumId(album)]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + s.execute("photo.delete", &json!({})).unwrap(); + assert!(tally.update(&s.catalog), "followed"); + assert_eq!(tally.counts()[&lightcraft_catalog::AlbumId(album)], before - 1); + } + // three hundred photos rated at once: counted from every photo, and right + let many: Vec<u64> = s.visible_cloned().iter().take(300).map(|id| id.0).collect(); + s.execute("library.source", &json!({"kind": "all"})).unwrap(); + s.execute("library.filter", &json!({"rating": 0, "text": ""})).unwrap(); + let many: Vec<u64> = if many.len() >= 300 { many } else { s.visible_cloned().iter().take(300).map(|id| id.0).collect() }; + if many.len() > 256 { + s.execute("library.select", &json!({"ids": many})).unwrap(); + s.execute("photo.rate", &json!({"rating": 5})).unwrap(); + assert!(!tally.update(&s.catalog), "more photos than are followed one by one"); + assert_eq!(shown(&tally), fresh(&s)); + } +} diff --git a/crates/engine/src/tests_xmp.rs b/crates/engine/src/tests_xmp.rs index 58d0d7a14..231a7bee3 100644 --- a/crates/engine/src/tests_xmp.rs +++ b/crates/engine/src/tests_xmp.rs @@ -584,8 +584,10 @@ pub(crate) fn synthetic_dng_of(w: usize, h: usize, cfa: &str, data: Vec<u16>, xm }, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; write_dng(&raw, &DngWriteOptions { xmp: xmp.map(str::to_string), ..Default::default() }).unwrap() } @@ -666,7 +668,7 @@ fn convert_raw_to_dng() { let r = s.execute("photo.convertToDng", &json!({})).unwrap(); assert_eq!((r["converted"].as_array().unwrap().len(), r["skipped"].as_array().unwrap().len()), (0, 1)); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let nef = dir.join("shot.nef"); @@ -928,14 +930,14 @@ fn dng_export_compression_choices() { .unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) .join("raw/nef-nikon-d5100-uncompressed.nef"); if !corpus.exists() { - eprintln!("skip: {} absent", corpus.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", corpus.display()); return; } let mut s = Session::new().with_fs(); s.execute("library.import", &json!({"paths": [corpus.to_string_lossy()]})).unwrap(); let id = s.active().unwrap(); let mut sizes = Vec::new(); - for c in ["lossless", "deflate", "uncompressed"] { + for c in ["lossless", "deflate", "uncompressed", "lossy"] { let o = crate::export::ExportOptions::from_json(&json!({"format": "dng", "dngCompression": c})); let mut out = Vec::new(); let mut write = |_: &str, b: &[u8]| { @@ -948,6 +950,57 @@ fn dng_export_compression_choices() { sizes.push((c, out.len())); } assert!(sizes[2].1 > sizes[0].1 && sizes[2].1 > sizes[1].1, "uncompressed is the biggest: {sizes:?}"); + assert!(sizes[3].1 < sizes[0].1 / 2, "lossy is much smaller than lossless: {sizes:?}"); +} + +/// Issue #1745: the DNG of a raw without colour matrices of its own (a Nikon Z 9 NEF) had no `ColorMatrix1`, and +/// other raw converters refused it. Export as DNG and Convert / Copy as DNG now write the colour the raw opens with: +/// the measured matrices of a camera that has them (Z f), the neutral fallback of one that doesn't (Z 9, whose +/// look is fitted to its JPEG when there is one). +#[test] +fn dngs_of_raws_without_colour_matrices_carry_the_colour_they_open_with() { + use lightcraft_raw::color::{as_shot_white_xy, camera_transform, has_matrix}; + let dir = temp_dir("dng-colour"); + let samples = [[0.2, 0.3, 0.1], [0.05, 0.02, 0.4], [0.6, 0.6, 0.6], [0.01, 0.5, 0.03]]; + let render = |t: &lightcraft_raw::color::CameraTransform, c: [f64; 3]| { + t.matrix.apply(std::array::from_fn(|i| c[i] * t.wb[i] as f64)).map(|v| v * t.baseline_exposure.exp2()) + }; + for (model, measured) in [("NIKON Z f", true), ("NIKON Z 9", false)] { + let metadata = lightcraft_meta::Metadata { make: Some("NIKON CORPORATION".into()), model: Some(model.into()), ..Default::default() }; + let mut raw = lightcraft_raw::decode(&synthetic_dng_with(None, metadata)).unwrap(); + // a vendor raw's colour: a white balance, no matrices + raw.color = lightcraft_raw::ColorData { as_shot_neutral: Some([0.5, 1.0, 0.7]), ..Default::default() }; + let bytes = lightcraft_raw::write_dng(&raw, &Default::default()).unwrap(); + let path = dir.join(format!("{model}.dng")); + std::fs::write(&path, &bytes).unwrap(); + let mut opened = lightcraft_raw::decode(&bytes).unwrap(); + assert!(!has_matrix(&opened.color)); + let (_, want, look) = crate::camera_preview::starting_colour(&mut opened, &bytes, None, None); + assert_eq!((want.matrix_is_fallback, look.is_none()), (!measured, true), "{model}"); + + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [path.to_string_lossy()]})).unwrap(); + let id = s.active().unwrap(); + let o = crate::export::ExportOptions::from_json(&json!({"format": "dng"})); + let mut exported = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + exported = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + let converted = crate::cmd::convert::write_dng_with(None, &path.to_string_lossy(), String::new()).unwrap(); + for (how, dng) in [("export", exported), ("convert", std::fs::read(&converted).unwrap())] { + let back = lightcraft_raw::decode(&dng).unwrap(); + assert!(back.color.color_matrix[0].is_some() && back.color.illuminant[0] != 0, "{model} {how}: {:?}", back.color); + let got = camera_transform(&back, as_shot_white_xy(&back)); + for c in samples { + let (a, b) = (render(&want, c), render(&got, c)); + assert!(a.iter().zip(b).all(|(a, b)| (a - b).abs() < 1e-3), "{model} {how} {c:?}: {a:?} vs {b:?}"); + } + } + std::fs::remove_file(&converted).unwrap(); + } + let _ = std::fs::remove_dir_all(&dir); } fn write_jpeg(path: &Path, capture: Option<&str>) { @@ -1209,3 +1262,69 @@ fn files_sharing_a_stem_get_their_own_sidecars() { let _ = std::fs::remove_dir_all(&src); let _ = std::fs::remove_dir_all(&lib); } + +/// Lossy DNG export: demosaiced JPEG tiles at the resize long edge, smaller than lossless, colour tags kept. +#[test] +fn lossy_dng_export_scales_and_shrinks() { + let src = temp_dir("lossydng"); + std::fs::write(src.join("synth.dng"), synthetic_dng_with(None, Default::default())).unwrap(); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + let id = s.catalog.photos().next().unwrap().id; + let mut export = |p: serde_json::Value| { + let o = crate::export::ExportOptions::from_json(&p); + let mut out = Vec::new(); + let mut write = |_: &str, b: &[u8]| { + out = b.to_vec(); + Ok(()) + }; + crate::export::export_batch(&mut s, &[id], &o, &crate::export::Destination { dir: "x".into(), exact: None }, &mut write, &|_| false).unwrap(); + out + }; + let lossless = export(json!({"format": "dng"})); + let lossy = export(json!({"format": "dng", "dngCompression": "lossy", "quality": 80, "longEdge": 32})); + let raw = lightcraft_raw::decode(&lossy).unwrap(); + assert_eq!((raw.cpp, raw.cfa.is_none(), raw.width.max(raw.height)), (3, true, 32)); + assert_eq!(raw.color.as_shot_neutral, Some([0.5, 1.0, 0.7])); + assert!(lossy.len() < lossless.len(), "{} vs {}", lossy.len(), lossless.len()); + let _ = std::fs::remove_dir_all(&src); +} + +/// An automatic sidecar write that fails (here the folder is read-only, as a network share that dropped out or went +/// read-only) waits instead of being forgotten, and `library.retrySidecars` writes it once the folder takes writes +/// again. +#[cfg(unix)] +#[test] +fn a_failed_auto_write_waits_and_is_written_on_retry() { + use std::os::unix::fs::PermissionsExt; + let src = temp_dir("auto-retry"); + write_png(&src.join("a.png"), 4); + let mut s = Session::new().with_fs(); + s.execute("library.import", &json!({"paths": [src.to_string_lossy()]})).unwrap(); + s.execute("library.xmpPreferences", &json!({"autoWrite": true})).unwrap(); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap(); + // (root writes anyway: nothing to test there) + if std::fs::write(src.join(".probe"), b"x").is_ok() { + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + return; + } + s.execute("photo.rate", &json!({"rating": 4})).unwrap(); + let sidecar = src.join("a.xmp"); + assert!(!sidecar.exists()); + assert_eq!(s.unsaved_sidecars(), 1, "the failed write waits"); + let r = s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(r, json!({"written": 0, "waiting": 1}), "still read-only"); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + let r = s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(r, json!({"written": 1, "waiting": 0})); + assert!(std::fs::read_to_string(&sidecar).unwrap().contains("<xmp:Rating>4</xmp:Rating>")); + // a later edit that writes fine leaves nothing waiting; turning auto-write off drops what still waits + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o555)).unwrap(); + s.execute("photo.rate", &json!({"rating": 3})).unwrap(); + assert_eq!(s.unsaved_sidecars(), 1); + s.execute("library.xmpPreferences", &json!({"autoWrite": false})).unwrap(); + s.execute("library.retrySidecars", &json!({})).unwrap(); + assert_eq!(s.unsaved_sidecars(), 0); + std::fs::set_permissions(&src, std::fs::Permissions::from_mode(0o755)).unwrap(); + let _ = std::fs::remove_dir_all(&src); +} diff --git a/crates/engine/src/text.rs b/crates/engine/src/text.rs new file mode 100644 index 000000000..12974247f --- /dev/null +++ b/crates/engine/src/text.rs @@ -0,0 +1,225 @@ +//! Text drawing in pure Rust, shared by the export watermark and the slideshow overlays: Inter +//! SemiBold (bundled) first, then the craft-fonts CJK faces as fallbacks ([`faces`]); glyph outlines +//! from `ab_glyph`, vertical CJK cells shaped with `harfrust`. [`TextLayout`] lays a string out +//! once (lines split at `\n`, kerning within a face) and reports its size, so callers can anchor +//! it before drawing; drawing hands out per-pixel coverage, and the caller blends. +//! +//! RTL scripts are laid out in logical order without bidi reordering, and scripts no face covers +//! draw as Inter's missing-glyph box: wrong-looking, never a crash. + +use ab_glyph::{Font, FontRef, Glyph, PxScale, ScaleFont, point}; + +/// Inter SemiBold (OFL, see assets/ATTRIBUTION.md). +pub static TEXT_FONT: &[u8] = include_bytes!("../../../assets/fonts/Inter-SemiBold.ttf"); + +/// Most characters one layout draws (longer text is cut there), so hostile text stays cheap. +pub const MAX_CHARS: usize = 4000; + +/// The text faces: Inter first, then the craft-fonts CJK faces (Mincho first, the watermark's +/// serif look; then any other CJK face — drawn text has no UI language, so every CJK script is +/// offered). Without craft-fonts that is Inter alone, and CJK characters draw as Inter's +/// missing-glyph box. +pub fn faces(craft: &'static [crate::fonts::CraftFont]) -> Vec<FontRef<'static>> { + let mut cjk: Vec<_> = crate::fonts::cjk(craft).collect(); + cjk.sort_by_key(|f| !f.is_mincho()); + std::iter::once(TEXT_FONT).chain(cjk.into_iter().map(|f| f.bytes)).filter_map(|bytes| FontRef::try_from_slice(bytes).ok()).collect() +} + +/// Shape a whole grapheme in one cell. CJK uses vertical origins and forms; Latin stays upright. +/// Unlike Unicode presentation-form characters, GSUB works with BIZ UD fonts too. +pub fn cell_glyphs(font: &FontRef<'_>, data: &harfrust::ShaperData, text: &str, vertical: bool, px: f32, left: f32, top: f32) -> Option<Vec<Glyph>> { + let face = harfrust::FontRef::new(font.font_data()).ok()?; + let mut buf = harfrust::UnicodeBuffer::new(); + buf.push_str(text); + buf.set_direction(if vertical { harfrust::Direction::TopToBottom } else { harfrust::Direction::LeftToRight }); + buf.guess_segment_properties(); + let shaped = data.shaper(&face).build().shape(buf, harfrust::ShapeOptions::new()); + if shaped.glyph_infos().is_empty() { + return None; + } + let sf = font.as_scaled(px); + let (sx, sy) = (sf.h_scale_factor(), sf.v_scale_factor()); + let advance_x = shaped.glyph_positions().iter().map(|p| p.x_advance as f32 * sx).sum::<f32>(); + let advance_y = shaped.glyph_positions().iter().map(|p| -(p.y_advance as f32) * sy).sum::<f32>(); + let (mut x, mut y) = if vertical { (left + px / 2.0, top + (px - advance_y) / 2.0) } else { (left + (px - advance_x) / 2.0, top + sf.ascent()) }; + let mut glyphs = Vec::new(); + for (info, pos) in shaped.glyph_infos().iter().zip(shaped.glyph_positions()) { + let gid = u16::try_from(info.glyph_id).ok().filter(|g| *g != 0)?; + let pen = point(x + pos.x_offset as f32 * sx, y - pos.y_offset as f32 * sy); + glyphs.push(ab_glyph::GlyphId(gid).with_scale_and_position(px, pen)); + x += pos.x_advance as f32 * sx; + y -= pos.y_advance as f32 * sy; + } + Some(glyphs) +} + +/// A string laid out at one size: its glyphs relative to the box's top-left corner and the box's +/// size in pixels (lines are `px` apart; vertical text: columns `px` wide, right to left). +pub struct TextLayout { + fonts: Vec<FontRef<'static>>, + /// (face, glyph, rotation centre for glyphs turned 90° in vertical text). + glyphs: Vec<(usize, Glyph, Option<(f32, f32)>)>, + pub width: f32, + pub height: f32, +} + +impl TextLayout { + /// `text` (cut at [`MAX_CHARS`]) at `px` pixels a line; `None` when there is nothing to draw + /// (empty text, a non-finite or non-positive size). + pub fn new(text: &str, px: f32, vertical: bool, craft: &'static [crate::fonts::CraftFont]) -> Option<TextLayout> { + use unicode_segmentation::UnicodeSegmentation; + if text.is_empty() || !px.is_finite() || px <= 0.0 { + return None; + } + let text = match text.char_indices().nth(MAX_CHARS) { + Some((i, _)) => text.get(..i).unwrap_or(text), + None => text, + }; + let fonts = faces(craft); + let latin = fonts.first()?.clone(); + let vertical_data: Vec<_> = if vertical { + fonts.iter().map(|font| harfrust::FontRef::new(font.font_data()).ok().map(|face| harfrust::ShaperData::new(&face))).collect() + } else { + Vec::new() + }; + let mut glyphs = Vec::new(); + let mut x = 0.0f32; + let mut y = 0.0f32; + let mut tw = 0.0f32; + let mut prev = None; + let columns = text.split('\n').count(); + let mut column = 0usize; + // Horizontal text keeps scalar layout and kerning; only vertical cells use graphemes. + let cells: Box<dyn Iterator<Item = &str>> = + if vertical { Box::new(text.graphemes(true)) } else { Box::new(text.char_indices().filter_map(|(i, c)| text.get(i..i + c.len_utf8()))) }; + for cell in cells { + let Some(original) = cell.chars().next() else { continue }; + if cell == "\n" || (vertical && cell == "\r\n") { + if vertical { + column = column.saturating_add(1); + tw = tw.max(y); + y = 0.0; + } else { + tw = tw.max(x); + x = 0.0; + y += px; + } + prev = None; + continue; + } + if original.is_control() { + continue; + } + let cjk = matches!(original as u32, 0x3000..=0x30FF | 0x3400..=0x9FFF | 0xFF01..=0xFF60); + if vertical && (cjk || cell.chars().count() > 1) { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let shaped = fonts.iter().zip(&vertical_data).enumerate().find_map(|(face, (font, data))| { + let data = data.as_ref()?; + let cluster = cell_glyphs(font, data, cell, cjk, px, left, y)?; + let substituted = cluster.len() != 1 || cluster.first().is_some_and(|g| g.id != font.glyph_id(original)); + if cjk && !substituted && matches!(original, '、' | '。' | 'ー' | '(' | ')' | '「' | '」' | '『' | '』' | '【' | '】') + { + return None; + } + Some((face, cluster)) + }); + if let Some((face, cluster)) = shaped { + glyphs.extend(cluster.into_iter().map(|g| (face, g, None))); + y += px; + continue; + } + } + let vertical_form = match original { + '、' => '︑', + '。' => '︒', + '(' => '︵', + ')' => '︶', + '「' => '﹁', + '」' => '﹂', + '『' => '﹃', + '』' => '﹄', + '【' => '︻', + '】' => '︼', + '…' => '︙', + other => other, + }; + let has = |font: &FontRef<'_>, c: char| font.glyph_id(c).0 != 0; + let ch = if vertical && fonts.iter().skip(1).any(|f| has(f, vertical_form)) { vertical_form } else { original }; + // The first face with the glyph; Inter (its missing-glyph box) when none has it. + let face = fonts.iter().position(|f| has(f, ch)).unwrap_or(0); + let sf = fonts.get(face).unwrap_or(&latin).as_scaled(PxScale::from(px)); + let id = sf.glyph_id(ch); + if vertical { + let left = columns.saturating_sub(column.saturating_add(1)) as f32 * px; + let pen = point(left + (px - sf.h_advance(id)) / 2.0, y + sf.ascent()); + let rotation = matches!(original, 'ー' | '—' | '–').then_some((left + px / 2.0, y + px / 2.0)); + glyphs.push((face, id.with_scale_and_position(px, pen), rotation)); + y += px; + } else { + if let Some((previous_face, previous)) = prev + && previous_face == face + { + x += sf.kern(previous, id); + } + glyphs.push((face, id.with_scale_and_position(px, point(x, y + sf.ascent())), None)); + x += sf.h_advance(id); + prev = Some((face, id)); + } + } + let (width, height) = if vertical { (columns as f32 * px, tw.max(y)) } else { (tw.max(x), y + px) }; + Some(TextLayout { fonts, glyphs, width, height }) + } + + /// Call `f(x, y, coverage)` for every pixel the text covers with its top-left corner at + /// (`ox`, `oy`); coordinates may be negative or past the image (the caller clips). + pub fn draw(&self, ox: f32, oy: f32, f: impl FnMut(i32, i32, f32)) { + self.draw_in(ox, oy, f32::INFINITY, f32::INFINITY, f); + } + + /// [`Self::draw`], skipping the glyphs that fall wholly outside `0..clip_w × 0..clip_h` + /// (so huge text costs what shows, not what is laid out). + /// Call `f(x, y, coverage)` for every pixel the text covers, in unrounded coordinates relative + /// to the box's top-left corner (for callers that transform them, e.g. a turned watermark). + pub fn draw_points(&self, mut f: impl FnMut(f32, f32, f32)) { + for (face, g, rotation) in &self.glyphs { + if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { + let b = o.px_bounds(); + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + f(sx, sy, cov); + }); + } + } + } + + pub fn draw_in(&self, ox: f32, oy: f32, clip_w: f32, clip_h: f32, mut f: impl FnMut(i32, i32, f32)) { + for (face, g, rotation) in &self.glyphs { + if rotation.is_none() { + // a glyph lies within one line height below its pen's top and an em or so after it + let (px, py) = (ox + g.position.x, oy + g.position.y); + let em = g.scale.y.max(g.scale.x); + if px + 2.0 * em < 0.0 || px - em > clip_w || py + em < 0.0 || py - 2.0 * em > clip_h { + continue; + } + } + if let Some(o) = self.fonts.get(*face).and_then(|font| font.outline_glyph(g.clone())) { + let b = o.px_bounds(); + if rotation.is_none() && (ox + b.max.x < 0.0 || ox + b.min.x > clip_w || oy + b.max.y < 0.0 || oy + b.min.y > clip_h) { + continue; + } + o.draw(|gx, gy, cov| { + let (sx, sy) = (b.min.x + gx as f32, b.min.y + gy as f32); + let (sx, sy) = match rotation { + Some((cx, cy)) => (cx - (sy - cy), cy + (sx - cx)), + None => (sx, sy), + }; + f((ox + sx) as i32, (oy + sy) as i32, cov); + }); + } + } + } +} diff --git a/crates/engine/src/walk.rs b/crates/engine/src/walk.rs index 1c1b53b4a..f8a3ba251 100644 --- a/crates/engine/src/walk.rs +++ b/crates/engine/src/walk.rs @@ -47,10 +47,35 @@ fn hidden(p: &Path) -> bool { p.file_name().is_some_and(|n| n.to_string_lossy().starts_with('.')) } +/// What a folder's listing says an entry is. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum EntryKind { + File, + Folder, + /// A link, or an entry whose kind the listing did not say. + Other, +} + +/// What [`files_in_noting`] hands over for each folder it lists: the folder, the name and kind +/// of every entry in it (hidden ones too), and whether that is all of them (not when the walk +/// stopped inside the folder, or an entry could not be read). +pub type NoteFolder<'a> = &'a mut dyn FnMut(&Path, Vec<(std::ffi::OsString, EntryKind)>, bool); + /// The files below folder `root` that `keep` accepts. Hidden entries (names starting with `.`) /// are left out, the folder `skip` (e.g. the library's own) is never entered, and the walk stops /// at `limits` (see [`Walked::truncated`]). Unreadable folders are skipped. -pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool) -> Walked { +pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool) -> Walked { + walk(root, skip, limits, keep, None) +} + +/// [`files_in`], handing the listing of every folder it reads to `note`: who walks a folder to +/// find its files has, with that, the answer to "is this name there" for everything in it, and +/// need not ask the disk again (a round trip for each name on a network share). +pub fn files_in_noting(root: &Path, skip: Option<&Path>, limits: Limits, keep: impl FnMut(&Path) -> bool, note: NoteFolder) -> Walked { + walk(root, skip, limits, keep, Some(note)) +} + +fn walk(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl FnMut(&Path) -> bool, mut note: Option<NoteFolder>) -> Walked { let mut out = Walked::default(); // canonical folders reached through a link, so a link loop is walked at most once let mut linked: HashSet<PathBuf> = HashSet::new(); @@ -77,17 +102,41 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl } let Ok(rd) = std::fs::read_dir(&path) else { continue }; let mut children: Vec<(PathBuf, bool)> = Vec::new(); - for e in rd.flatten() { + // (the folder's listing, for who asked for it; all of it unless something below says otherwise) + let mut listed = note.is_some().then(Vec::new); + let mut complete = true; + for e in rd { + let Ok(e) = e else { + complete = false; + continue; + }; if out.entries >= limits.max_entries { out.truncated = true; + complete = false; break; } out.entries = out.entries.saturating_add(1); let p = e.path(); - if hidden(&p) { + // (a hidden entry's kind is only asked for when the listing is wanted; every other + // entry's is asked once) + let is_hidden = hidden(&p); + if is_hidden && listed.is_none() { continue; } - let Ok(ft) = e.file_type() else { continue }; + let ft = e.file_type(); + if let Some(listed) = listed.as_mut() { + let kind = match &ft { + Ok(t) if t.is_symlink() => EntryKind::Other, + Ok(t) if t.is_dir() => EntryKind::Folder, + Ok(t) if t.is_file() => EntryKind::File, + _ => EntryKind::Other, + }; + listed.push((e.file_name(), kind)); + } + if is_hidden { + continue; + } + let Ok(ft) = ft else { continue }; let dir = if ft.is_symlink() { // a link (or a Windows junction): a folder is entered once, a file is a file match std::fs::metadata(&p) { @@ -105,6 +154,9 @@ pub fn files_in(root: &Path, skip: Option<&Path>, limits: Limits, mut keep: impl }; children.push((p, dir)); } + if let (Some(note), Some(listed)) = (note.as_mut(), listed) { + note(&path, listed, complete); + } children.sort(); let below = depth.saturating_add(1); stack.extend(children.into_iter().rev().map(|(p, d)| (p, below, d))); @@ -123,6 +175,43 @@ mod tests { d } + /// Given a folder with files, a folder inside it, a hidden file and a link, when it is walked + /// with its listings noted, then the walk finds what it finds without them, and each folder's + /// listing holds every entry by name with its kind (hidden ones too, a link as neither file nor + /// folder) and says it is all of them; a walk that stops inside a folder says it is not. + #[test] + fn a_walk_notes_each_folders_listing() { + let root = tmp("noting"); + std::fs::create_dir_all(root.join("sub")).unwrap(); + for name in ["a.jpg", ".hidden", "sub/b.jpg"] { + std::fs::write(root.join(name), b"x").unwrap(); + } + #[cfg(unix)] + std::os::unix::fs::symlink(root.join("a.jpg"), root.join("link.jpg")).unwrap(); + let mut noted: Vec<(PathBuf, Vec<(String, EntryKind)>, bool)> = Vec::new(); + let walked = files_in_noting(&root, None, Limits::default(), |_| true, &mut |dir, entries, complete| { + let mut entries: Vec<(String, EntryKind)> = entries.into_iter().map(|(n, k)| (n.to_string_lossy().to_string(), k)).collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + noted.push((dir.to_path_buf(), entries, complete)); + }); + assert_eq!(walked, files_in(&root, None, Limits::default(), |_| true), "the same walk, noted or not"); + let named = |n: &str, k: EntryKind| (n.to_string(), k); + let mut top = vec![named(".hidden", EntryKind::File), named("a.jpg", EntryKind::File), named("sub", EntryKind::Folder)]; + if cfg!(unix) { + top.insert(2, named("link.jpg", EntryKind::Other)); + } + assert_eq!(noted, [(root.clone(), top, true), (root.join("sub"), vec![named("b.jpg", EntryKind::File)], true)]); + + // stopped inside the first folder: its listing is not all of it + let mut cut = Vec::new(); + let walked = files_in_noting(&root, None, Limits { max_depth: 8, max_entries: 2 }, |_| true, &mut |dir, entries, complete| { + cut.push((dir.to_path_buf(), entries.len(), complete)) + }); + assert!(walked.truncated); + assert_eq!(cut.first().map(|(dir, n, complete)| (dir.clone(), *n, *complete)), Some((root.clone(), 2, false))); + let _ = std::fs::remove_dir_all(&root); + } + #[test] fn files_come_in_path_order_without_hidden_ones_or_the_skipped_folder() { let d = tmp("order"); diff --git a/crates/engine/tests/preview_read.rs b/crates/engine/tests/preview_read.rs index 5c950069b..eedebc2c1 100644 --- a/crates/engine/tests/preview_read.rs +++ b/crates/engine/tests/preview_read.rs @@ -13,7 +13,7 @@ use std::time::Instant; #[ignore = "needs LC_PREVIEW_RAW pointing at a real raw file"] fn a_preview_reads_only_the_header_and_the_preview() { let Ok(path) = std::env::var("LC_PREVIEW_RAW") else { - eprintln!("skipped: set LC_PREVIEW_RAW=<path to a raw file>"); + lightcraft_testkit::skip!(Input, "set LC_PREVIEW_RAW=<path to a raw file>"); return; }; let file_len = std::fs::metadata(&path).expect("the file exists").len(); diff --git a/crates/faces/Cargo.toml b/crates/faces/Cargo.toml index 112967cee..78ea0b798 100644 --- a/crates/faces/Cargo.toml +++ b/crates/faces/Cargo.toml @@ -22,4 +22,6 @@ sha2 = { workspace = true } thiserror = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } diff --git a/crates/faces/src/lib.rs b/crates/faces/src/lib.rs index b4b7066d5..030cda088 100644 --- a/crates/faces/src/lib.rs +++ b/crates/faces/src/lib.rs @@ -5,7 +5,8 @@ //! //! Everything here treats model files and manifests as hostile input: sizes are capped, numbers must be //! finite and sane, and a malformed file is an error, never a panic. Detection and recognition run -//! on the same checked, single-thread pure-Rust CPU interpreter. +//! on the same checked, single-thread pure-Rust CPU interpreter; an [`net::Accelerator`] (the GPU, in `lightcraft-gpu`) can +//! take its convolutions once it agrees with it. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/faces/src/net.rs b/crates/faces/src/net.rs index 6222314f2..430335840 100644 --- a/crates/faces/src/net.rs +++ b/crates/faces/src/net.rs @@ -9,8 +9,10 @@ use std::collections::HashMap; use std::rc::Rc; +use std::sync::{Arc, PoisonError, RwLock}; mod kernels; +pub mod program; mod shape; use crate::graph::{Data, Graph, Node}; @@ -64,9 +66,64 @@ impl Tensor { } } +/// One convolution, as handed to an [`Accelerator`]: batch 1, planar (NCHW) float32, already checked. +/// +/// `out[co][oy][ox] = bias[co] + Σ weights[co][ci][ky][kx] · input[g·cg + ci][oy·sh + ky − pt][ox·sw + kx − pl]` +/// with `g = co / (cout / groups)` and zero outside the input. +#[derive(Clone, Copy, Debug)] +pub struct ConvJob<'a> { + /// The node's index in the graph: the same for every run of a net, so its weights can stay on the device. + pub layer: usize, + pub cin: usize, + pub h: usize, + pub w: usize, + pub cout: usize, + pub groups: usize, + pub kh: usize, + pub kw: usize, + pub sh: usize, + pub sw: usize, + pub pt: usize, + pub pl: usize, + pub oh: usize, + pub ow: usize, + /// `[cout][cin / groups][kh][kw]`. + pub weights: &'a [f32], + /// `[cout]`, if the node has one. + pub bias: Option<&'a [f32]>, + /// `[cin][h][w]`. + pub input: &'a [f32], +} + +impl ConvJob<'_> { + /// Multiply-adds the convolution takes. + pub fn macs(&self) -> usize { + let per_group = (self.cin / self.groups.max(1)).saturating_mul(self.kh).saturating_mul(self.kw); + self.cout.saturating_mul(per_group).saturating_mul(self.oh).saturating_mul(self.ow) + } +} + +/// Something that can run a network's convolutions faster than the CPU kernels (the GPU, in `lightcraft-gpu`). +/// +/// The CPU interpreter stays the reference: a net only uses an accelerator that agreed with it on a test input +/// ([`Net::attach_checked`]), and the first error makes the net drop it and run on the CPU again. +pub trait Accelerator: Send + Sync { + /// What it runs on, for the user ("AMD Radeon RX 7900 (Vulkan)"). + fn device(&self) -> String; + /// `[cout][oh][ow]`, or `Ok(None)` to leave this one to the CPU (too small to be worth a dispatch, say). + fn conv(&self, job: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String>; + /// The whole network on the device, keeping every value there between steps, for each of `inputs` (all of + /// `program.lens[INPUT]` values) in one go: per input, each of `program.outputs` in order. `Ok(None)` leaves the + /// run to the CPU path (which may still use [`Accelerator::conv`]). + fn run_program(&self, _program: &program::Program, _inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> { + Ok(None) + } +} + /// A loaded network, ready to run. pub struct Net { graph: Graph, + accelerator: RwLock<Option<Arc<dyn Accelerator>>>, } /// Validate semantics rather than silently ignoring unknown or mistyped attributes. @@ -80,7 +137,75 @@ impl Net { for n in &graph.nodes { check(n, &graph)?; } - Ok(Net { graph }) + Ok(Net { graph, accelerator: RwLock::new(None) }) + } + + /// The accelerator in use, if any. + pub fn accelerator(&self) -> Option<Arc<dyn Accelerator>> { + self.accelerator.read().unwrap_or_else(PoisonError::into_inner).clone() + } + + /// Use `accelerator` for this net's convolutions, but only after it gave the CPU's outputs on `probe` (every + /// output value within `tolerance` of the CPU's, relative to that output's largest magnitude) and, with + /// `require_faster`, ran it faster than the CPU did (a GPU's per-layer round trip can cost more than a small + /// model's layers). Returns why it was not used, so the caller can say so; the net then stays on the CPU. + pub fn attach_checked( + &self, + accelerator: Arc<dyn Accelerator>, + probe: &Tensor, + tolerance: f32, + require_faster: bool, + ) -> std::result::Result<(), String> { + self.detach(); + let started = web_time::Instant::now(); + let cpu = self.run(probe.clone()).map_err(|e| e.to_string())?; + let cpu_time = started.elapsed(); + *self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = Some(accelerator); + // the first run also uploads the weights and builds the kernels: time a second one + let fast = self.run(probe.clone()).map_err(|e| e.to_string()); + let mut verdict = match fast { + Err(e) => Err(e), + Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()), + Ok(fast) => agree(&cpu, &fast, tolerance), + }; + if verdict.is_ok() && require_faster { + let started = web_time::Instant::now(); + let again = self.run(probe.clone()).map_err(|e| e.to_string()); + let fast_time = started.elapsed(); + verdict = match again { + Err(e) => Err(e), + Ok(_) if self.accelerator().is_none() => Err("the device failed on the test input".into()), + Ok(_) if fast_time >= cpu_time => Err(format!( + "the device is not faster for this model ({:.0} ms against {:.0} ms on the CPU)", + fast_time.as_secs_f64() * 1000.0, + cpu_time.as_secs_f64() * 1000.0 + )), + Ok(_) => Ok(()), + }; + } + if verdict.is_err() { + self.detach(); + } + verdict + } + + /// Run on the CPU only from now on. + pub fn detach(&self) { + *self.accelerator.write().unwrap_or_else(PoisonError::into_inner) = None; + } + + /// A convolution on the accelerator, if one is attached and takes it. A failing accelerator is dropped. + fn accelerated_conv(&self, layer: usize, node: &Node, x: &Tensor) -> Result<Option<Tensor>> { + let Some(accelerator) = self.accelerator() else { return Ok(None) }; + let job = kernels::conv_job(layer, node, &x.shape, &x.data, &self.graph)?; + match accelerator.conv(&job) { + Ok(Some(out)) if out.len() == job.cout * job.oh * job.ow => Ok(Some(Tensor::new(vec![1, job.cout, job.oh, job.ow], out)?)), + Ok(None) => Ok(None), + Ok(Some(_)) | Err(_) => { + self.detach(); + Ok(None) + } + } } /// The shape the model declares for its input (0 for a dynamic size). @@ -96,7 +221,67 @@ impl Net { /// Run the network on `input` (its single input); returns each output by name. pub fn run(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> { - shape::tensor(&input.shape, input.data.len(), &input.data)?; + self.run_batch(vec![input])?.pop().ok_or_else(|| NetError::Shape("the run gave nothing".into())) + } + + /// Run the network on each of `inputs`; an accelerator that keeps the values on its device runs inputs of one shape + /// together (one upload and one read-back for all of them). + pub fn run_batch(&self, inputs: Vec<Tensor>) -> Result<Vec<Vec<(String, Tensor)>>> { + for input in &inputs { + shape::tensor(&input.shape, input.data.len(), &input.data)?; + } + if let Some(out) = self.run_on_device(&inputs)? { + return Ok(out); + } + inputs.into_iter().map(|input| self.run_one(input)).collect() + } + + /// The whole batch on the attached accelerator, or `None` to run it on the CPU path. A failing or misbehaving + /// accelerator is dropped. + fn run_on_device(&self, inputs: &[Tensor]) -> Result<Option<Vec<Vec<(String, Tensor)>>>> { + let (Some(accelerator), Some(first)) = (self.accelerator(), inputs.first()) else { return Ok(None) }; + if inputs.iter().any(|t| t.shape != first.shape) { + return Ok(None); + } + let Some(program) = self.program(&first.shape)? else { return Ok(None) }; + let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect(); + let started = web_time::Instant::now(); + let outs = match accelerator.run_program(&program, &slices) { + Ok(Some(outs)) => outs, + Ok(None) => return Ok(None), + Err(_) => { + self.detach(); + return Ok(None); + } + }; + let gathered: Option<Vec<Vec<(String, Tensor)>>> = (outs.len() == inputs.len()) + .then(|| { + outs.into_iter() + .map(|values| { + (values.len() == program.outputs.len()) + .then(|| { + values + .into_iter() + .zip(&program.outputs) + .map(|(data, (name, _, shape))| Tensor::new(shape.clone(), data).ok().map(|t| (name.clone(), t))) + .collect::<Option<Vec<_>>>() + }) + .flatten() + }) + .collect() + }) + .flatten(); + if std::env::var_os("LIGHTCRAFT_PROFILE").is_some() { + eprintln!("[faces-gpu] {} steps x {}: {:.3} ms", program.steps.len(), inputs.len(), started.elapsed().as_secs_f64() * 1000.0); + } + if gathered.is_none() { + // wrong sizes or values that are not finite: the device is not trusted any more + self.detach(); + } + Ok(gathered) + } + + fn run_one(&self, input: Tensor) -> Result<Vec<(String, Tensor)>> { self.validate_input(&input.shape)?; let mut scratch = kernels::Scratch::default(); let profile = std::env::var_os("LIGHTCRAFT_PROFILE").is_some(); @@ -127,7 +312,13 @@ impl Net { }; let started = web_time::Instant::now(); let out = match node.op.as_str() { - "Conv" => kernels::conv(node, &*get(0)?, g, &mut scratch)?, + "Conv" => { + let x = get(0)?; + match self.accelerated_conv(i, node, &x)? { + Some(out) => out, + None => kernels::conv(node, &x, g, &mut scratch)?, + } + } "Relu" => map(&*get(0)?, |v| v.max(0.0)), "Sigmoid" => map(&*get(0)?, |v| 1.0 / (1.0 + (-v).exp())), "Add" | "Sub" | "Mul" => kernels::binary(node.op.as_str(), &*get(0)?, &*get(1)?)?, @@ -168,6 +359,25 @@ impl Net { } } +/// Whether two runs' outputs agree: same names and shapes, values within `tolerance` of each other relative to the +/// output's largest magnitude. +fn agree(a: &[(String, Tensor)], b: &[(String, Tensor)], tolerance: f32) -> std::result::Result<(), String> { + if a.len() != b.len() { + return Err("the device gave a different number of outputs".into()); + } + for ((name, x), (other, y)) in a.iter().zip(b) { + if name != other || x.shape != y.shape { + return Err(format!("the device gave `{other}` a different shape")); + } + let scale = x.data.iter().fold(0f32, |m, v| m.max(v.abs())).max(1e-6); + let worst = x.data.iter().zip(&y.data).fold(0f32, |m, (p, q)| m.max((p - q).abs())); + if worst.is_nan() || worst > tolerance * scale { + return Err(format!("the device's `{name}` differs from the CPU's by {:.2e} (of {scale:.2e})", worst)); + } + } + Ok(()) +} + fn map(x: &Tensor, f: impl Fn(f32) -> f32) -> Tensor { Tensor { shape: x.shape.clone(), data: x.data.iter().map(|v| f(*v)).collect() } } @@ -534,6 +744,244 @@ mod tests { assert_eq!(rs.shape, vec![1, 6, 2]); } + /// An accelerator built on the slow reference convolution, optionally wrong or failing. + struct Fake { + skew: f32, + fail_after: std::sync::atomic::AtomicUsize, + } + impl Accelerator for Fake { + fn device(&self) -> String { + "test".into() + } + fn conv(&self, j: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> { + if self.fail_after.fetch_sub(1, std::sync::atomic::Ordering::Relaxed) == 0 { + return Err("device lost".into()); + } + assert!(j.pt == j.pl && j.sh == j.sw); + let x = Tensor::new(vec![1, j.cin, j.h, j.w], j.input.to_vec()).unwrap(); + let zero = vec![0.0; j.cout]; + let dims = [j.cout, j.cin / j.groups, j.kh, j.kw]; + let out = reference_conv(&x, j.weights, dims, j.bias.unwrap_or(&zero), j.sh, j.pt, j.groups); + Ok(Some(out.data.iter().map(|v| v + self.skew).collect())) + } + } + + fn two_convs() -> (Graph, Tensor) { + let attrs = [("strides", Attr::Ints(vec![2, 2])), ("pads", Attr::Ints(vec![1; 4]))]; + let mut a = node("Conv", &["x", "w1", "b1"], &attrs); + a.outputs = vec!["h".into()]; + let b = node("Conv", &["h", "w2"], &[("group", Attr::Int(2)), ("pads", Attr::Ints(vec![1; 4]))]); + let mut g = graph( + vec![a, b], + vec![ + ("w1", w(&[4, 3, 3, 3], (0..108).map(|i| ((i * 7 % 13) as f32 - 6.0) * 0.05).collect())), + ("b1", w(&[4], vec![0.1, -0.2, 0.3, 0.0])), + ("w2", w(&[4, 2, 3, 3], (0..72).map(|i| ((i * 5 % 11) as f32 - 5.0) * 0.05).collect())), + ], + ); + g.inputs = vec![("x".into(), vec![1, 3, 9, 8])]; + (g, input(3, 9, 8, |i| ((i * 13 % 17) as f32 - 8.0) * 0.1)) + } + + #[test] + fn an_accelerator_is_used_only_when_it_agrees_with_the_cpu() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + let good = Arc::new(Fake { skew: 0.0, fail_after: usize::MAX.into() }); + net.attach_checked(good, &x, 1e-4, false).unwrap(); + assert!(net.accelerator().is_some()); + assert_eq!(net.run(x.clone()).unwrap()[0].1.shape, cpu[0].1.shape); + let wrong = Arc::new(Fake { skew: 0.5, fail_after: usize::MAX.into() }); + assert!(net.attach_checked(wrong, &x, 1e-4, false).is_err()); + assert!(net.accelerator().is_none(), "a device that disagrees is not used"); + assert_eq!(net.run(x).unwrap(), cpu); + } + + #[test] + fn a_failing_accelerator_falls_back_to_the_cpu_mid_run() { + let (g, x) = two_convs(); + let net = Net::new(g).unwrap(); + let cpu = net.run(x.clone()).unwrap(); + // agrees on the probe (two convs), then fails on the second conv of the next run + net.attach_checked(Arc::new(Fake { skew: 0.0, fail_after: 3.into() }), &x, 1e-4, false).unwrap(); + let out = net.run(x).unwrap(); + assert!(net.accelerator().is_none()); + for (a, b) in out[0].1.data.iter().zip(&cpu[0].1.data) { + assert!((a - b).abs() < 1e-4); + } + } + + /// An accelerator that runs whole programs with plain loops (the definition of each step), counting its calls; + /// `poison` makes it give a value that is not finite. + struct ProgramFake { + calls: std::sync::atomic::AtomicUsize, + poison: bool, + } + + fn run_steps(p: &program::Program, input: &[f32]) -> Vec<Vec<f32>> { + use program::{Arg, Binary, Op, Unary}; + let mut values: Vec<Vec<f32>> = vec![Vec::new(); p.lens.len()]; + values[program::INPUT] = input.to_vec(); + for step in &p.steps { + let arg = |i: usize| -> Vec<f32> { + match &step.args[i] { + Arg::Value(v) => values[*v].clone(), + Arg::Const { data, .. } => data.to_vec(), + } + }; + let len = p.lens[step.out]; + let x = arg(0); + let out: Vec<f32> = match &step.op { + Op::Conv(j) => { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; len]; + for co in 0..j.cout { + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * x[((co / m * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + Op::Unary(Unary::Relu) => x.iter().map(|v| v.max(0.0)).collect(), + Op::Unary(Unary::Sigmoid) => x.iter().map(|v| 1.0 / (1.0 + (-v).exp())).collect(), + Op::ChannelAffine { channels, plane } => { + let (s, o) = (arg(1), arg(2)); + x.iter().enumerate().map(|(i, v)| v * s[(i / plane) % channels] + o[(i / plane) % channels]).collect() + } + Op::ChannelPrelu { channels, plane } => { + let a = arg(1); + x.iter().enumerate().map(|(i, v)| if *v < 0.0 { v * a[(i / plane) % channels] } else { *v }).collect() + } + Op::Binary { op, dims, a, b } => { + let y = arg(1); + (0..len) + .map(|i| { + let (mut rest, mut ia, mut ib) = (i, 0, 0); + for d in (0..4).rev() { + let at = rest % dims[d]; + rest /= dims[d]; + ia += at * a[d]; + ib += at * b[d]; + } + match op { + Binary::Add => x[ia] + y[ib], + Binary::Sub => x[ia] - y[ib], + Binary::Mul => x[ia] * y[ib], + } + }) + .collect() + } + Op::Transpose { dims, strides } => (0..len) + .map(|i| { + let (mut rest, mut from) = (i, 0); + for d in (0..4).rev() { + from += (rest % dims[d]) * strides[d]; + rest /= dims[d]; + } + x[from] + }) + .collect(), + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => (0..len) + .map(|i| { + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + let mut m = f32::NEG_INFINITY; + for ky in 0..*kh { + for kx in 0..*kw { + m = m.max(x[(c * h + oy * sh + ky) * w + ox * sw + kx]); + } + } + m + }) + .collect(), + Op::Resize { h, w, sh, sw, .. } => (0..len) + .map(|i| { + let (ow, oh) = (w * sw, h * sh); + let (ox, oy, c) = (i % ow, (i / ow) % oh, i / (ow * oh)); + x[(c * h + oy / sh) * w + ox / sw] + }) + .collect(), + Op::GlobalAverage { plane, .. } => x.chunks(*plane).map(|c| c.iter().sum::<f32>() / *plane as f32).collect(), + Op::Gemv { k, n, trans_b, alpha } => { + let (wt, bias) = (arg(1), arg(2)); + (0..*n).map(|o| alpha * (0..*k).map(|j| x[j] * wt[if *trans_b { o * k + j } else { j * n + o }]).sum::<f32>() + bias[o]).collect() + } + }; + assert_eq!(out.len(), len, "{:?}", step.op); + values[step.out] = out; + for f in &step.free { + values[*f] = Vec::new(); + } + } + p.outputs.iter().map(|(_, v, _)| values[*v].clone()).collect() + } + + impl Accelerator for ProgramFake { + fn device(&self) -> String { + "program test".into() + } + fn conv(&self, _: &ConvJob) -> std::result::Result<Option<Vec<f32>>, String> { + Ok(None) + } + fn run_program(&self, p: &program::Program, inputs: &[&[f32]]) -> std::result::Result<Option<Vec<Vec<Vec<f32>>>>, String> { + self.calls.fetch_add(1, std::sync::atomic::Ordering::Relaxed); + let mut out: Vec<Vec<Vec<f32>>> = inputs.iter().map(|x| run_steps(p, x)).collect(); + if self.poison { + out[0][0][0] = f32::NAN; + } + Ok(Some(out)) + } + } + + fn every_operator_input(seed: usize) -> Tensor { + input(4, 8, 8, |i| (((i * 31 + seed * 7) % 37) as f32 - 18.0) * 0.05) + } + + #[test] + fn a_program_of_every_operator_gives_what_the_interpreter_gives() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(0); + let cpu = net.run(x.clone()).unwrap(); + let fake = Arc::new(ProgramFake { calls: 0.into(), poison: false }); + net.attach_checked(fake.clone(), &x, 1e-5, false).unwrap(); + let calls = fake.calls.load(std::sync::atomic::Ordering::Relaxed); + assert!(calls > 0, "the whole network ran as one program"); + // several inputs go to the device together, and each gets its own outputs + let inputs: Vec<Tensor> = (0..3).map(every_operator_input).collect(); + let together = net.run_batch(inputs.clone()).unwrap(); + assert_eq!(fake.calls.load(std::sync::atomic::Ordering::Relaxed), calls + 1, "one dispatch for the batch"); + net.detach(); + for (x, got) in inputs.into_iter().zip(&together) { + let want = net.run(x).unwrap(); + agree(&want, got, 1e-5).unwrap(); + } + assert_eq!(cpu.len(), 2); + } + + #[test] + fn a_device_that_gives_values_that_are_not_finite_is_dropped() { + let net = Net::new(crate::synthetic::every_operator_graph()).unwrap(); + let x = every_operator_input(1); + let cpu = net.run(x.clone()).unwrap(); + assert!(net.attach_checked(Arc::new(ProgramFake { calls: 0.into(), poison: true }), &x, 1e-5, false).is_err()); + assert!(net.accelerator().is_none()); + assert_eq!(net.run(x).unwrap(), cpu); + } + #[test] fn unsupported_and_inconsistent_models_are_errors() { let x = input(1, 3, 3, |i| i as f32); diff --git a/crates/faces/src/net/kernels.rs b/crates/faces/src/net/kernels.rs index 73cc72b93..71ae95874 100644 --- a/crates/faces/src/net/kernels.rs +++ b/crates/faces/src/net/kernels.rs @@ -59,6 +59,33 @@ fn shifted_plane(plane: &[f32], width: usize, ky: usize, kx: usize, pt: usize, p } Ok(()) } +/// The checked geometry of a convolution node, for an accelerator. +pub(super) fn conv_job<'a>(layer: usize, n: &Node, shape: &[usize], input: &'a [f32], g: &'a Graph) -> Result<ConvJob<'a>> { + let (cin, h, w) = program::chw_of(shape)?; + let wt = shape::weight(n, g, 1)?; + let weights = shape::floats(n, g, 1)?; + let [cout, cg, kh, kw] = wt.dims.as_slice() else { + return shape_err("invalid convolution weights"); + }; + let (cout, cg, kh, kw) = (*cout, *cg, *kh, *kw); + let groups = usize::try_from(n.int("group").unwrap_or(1)).map_err(|_| NetError::Shape("invalid group".into()))?; + let (sh, sw) = pair(n, "strides", 1)?; + let (pt, pl, pb, pr) = pads(n)?; + let ph = h.checked_add(pt).and_then(|v| v.checked_add(pb)).ok_or_else(|| NetError::Shape("padding overflow".into()))?; + let pw = w.checked_add(pl).and_then(|v| v.checked_add(pr)).ok_or_else(|| NetError::Shape("padding overflow".into()))?; + if ph < kh || pw < kw || groups == 0 || cg == 0 || kh == 0 || kw == 0 || cin != groups * cg || cout % groups != 0 { + return shape_err("invalid convolution shape"); + } + if cout.checked_mul(cg).and_then(|v| v.checked_mul(kh * kw)) != Some(weights.len()) { + return shape_err("short convolution weights"); + } + let bias = if n.inputs.get(2).is_some_and(|s| !s.is_empty()) { Some(shape::floats(n, g, 2)?) } else { None }; + if bias.is_some_and(|b| b.len() != cout) { + return shape_err("the convolution bias does not match"); + } + let (oh, ow) = ((ph - kh) / sh + 1, (pw - kw) / sw + 1); + Ok(ConvJob { layer, cin, h, w, cout, groups, kh, kw, sh, sw, pt, pl, oh, ow, weights, bias, input }) +} pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Result<Tensor> { let (cin, h, w) = chw(x)?; let wt = shape::weight(n, g, 1)?; @@ -181,7 +208,7 @@ pub(super) fn conv(n: &Node, x: &Tensor, g: &Graph, scratch: &mut Scratch) -> Re Ok(out) } /// Flat offset under ONNX's trailing-dimension broadcasting; shapes are checked before execution. -fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize { +pub(super) fn broadcast_offset(mut at: usize, out: &[usize], input: &[usize]) -> usize { let mut offset = 0; let mut stride = 1; for (axis, &size) in out.iter().enumerate().rev() { diff --git a/crates/faces/src/net/program.rs b/crates/faces/src/net/program.rs new file mode 100644 index 000000000..e00e51da1 --- /dev/null +++ b/crates/faces/src/net/program.rs @@ -0,0 +1,315 @@ +//! A network lowered to a flat list of steps over numbered values, for an [`Accelerator`] that keeps every activation +//! on its device between layers (one upload, one read-back per run instead of one per convolution). +//! +//! Shapes are resolved for one input shape; every step names the values it reads and the one it writes, and which +//! values nothing reads after it. Shape-only operators (`Reshape`, `Flatten`, `Identity`, `Dropout`) make no step: their +//! output is the same value under another name. Anything this cannot express (a broadcast past four dimensions, a +//! transposed or batched `Gemm`, a slope that is not per channel) makes the whole lowering `None`, and the net runs +//! the CPU path instead. + +use std::borrow::Cow; + +use super::*; + +/// Value 0 is the network's input. +pub const INPUT: usize = 0; + +/// An operand: a value computed on the device, or a constant (uploaded once and kept, by `id`). +#[derive(Clone, Debug)] +pub enum Arg<'a> { + Value(usize), + /// `id` is the same for every run of the same net, so the device can keep the data. + Const { + id: usize, + data: Cow<'a, [f32]>, + }, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Unary { + Relu, + Sigmoid, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Binary { + Add, + Sub, + Mul, +} + +/// What a step computes. Element counts come from the program's `lens` of the step's output. +#[derive(Clone, Debug)] +pub enum Op<'a> { + /// A convolution; its `input` is empty (it reads the step's first argument). + Conv(ConvJob<'a>), + /// `out[i] = f(x[i])`. Args: `[x]`. + Unary(Unary), + /// `out[i] = x[i] * scale[c] + offset[c]`, `c = (i / plane) % channels`. Args: `[x, scale, offset]`. + ChannelAffine { channels: usize, plane: usize }, + /// `out[i] = x[i] < 0 ? x[i] * slope[c] : x[i]`. Args: `[x, slope]` (`channels` values). + ChannelPrelu { channels: usize, plane: usize }, + /// Broadcasting over four dimensions: `dims` of the output, element strides of each operand (0 where it repeats). + /// Args: `[a, b]`. + Binary { op: Binary, dims: [usize; 4], a: [usize; 4], b: [usize; 4] }, + /// `out[n] = alpha · Σk x[k] · B(k, n) + bias[n]`, `B(k, n) = weights[n·k_len + k]` when `trans_b`, else + /// `weights[k·n_len + n]`. Args: `[x, weights, bias]` (bias already times beta; zeros when the node has none). + Gemv { k: usize, n: usize, trans_b: bool, alpha: f32 }, + /// The mean of each `plane`-sized run. Args: `[x]`. + GlobalAverage { channels: usize, plane: usize }, + /// Unpadded max pooling. Args: `[x]`. + MaxPool { c: usize, h: usize, w: usize, kh: usize, kw: usize, sh: usize, sw: usize, oh: usize, ow: usize }, + /// Nearest-neighbour upscale by whole numbers. Args: `[x]`. + Resize { c: usize, h: usize, w: usize, sh: usize, sw: usize }, + /// `out` in its own order (`dims`), read from the input at `strides` (the input's strides, permuted). Args: `[x]`. + Transpose { dims: [usize; 4], strides: [usize; 4] }, +} + +#[derive(Clone, Debug)] +pub struct Step<'a> { + pub op: Op<'a>, + pub args: Vec<Arg<'a>>, + pub out: usize, + /// Values nothing reads after this step (never an output): their memory can be reused. + pub free: Vec<usize>, +} + +/// A whole network for one input shape. +#[derive(Clone, Debug)] +pub struct Program<'a> { + /// Elements of each value; `lens[INPUT]` is the input's. + pub lens: Vec<usize>, + pub steps: Vec<Step<'a>>, + /// Each graph output: its name, the value that holds it and its shape. + pub outputs: Vec<(String, usize, Vec<usize>)>, +} + +/// Leading ones up to rank four, or `None` past four. +fn rank4(s: &[usize]) -> Option<[usize; 4]> { + let pad = 4usize.checked_sub(s.len())?; + let mut out = [1; 4]; + for (i, d) in s.iter().enumerate() { + *out.get_mut(pad + i)? = *d; + } + Some(out) +} + +/// Element strides of `input` read as `out` under trailing-dimension broadcasting (0 along repeated axes). +fn broadcast_strides(out: [usize; 4], input: &[usize]) -> Option<[usize; 4]> { + let dims = rank4(input)?; + let mut strides = [0; 4]; + let mut step = 1; + for axis in (0..4).rev() { + let (d, o) = (*dims.get(axis)?, *out.get(axis)?); + if d == o && d != 1 { + *strides.get_mut(axis)? = step; + } else if d != 1 { + return None; + } + step *= d; + } + Some(strides) +} + +impl Net { + /// This network lowered for `input` (its shape), or `None` when a step has no device form. + pub fn program(&self, input: &[usize]) -> Result<Option<Program<'_>>> { + let g = &self.graph; + let (shapes, _) = shape::infer_all(g, input)?; + let Some((input_name, _)) = g.inputs.first() else { return shape_err("the model has no input") }; + let mut lens = vec![shape::elements(input)?]; + let mut names: HashMap<&str, (usize, Vec<usize>)> = HashMap::new(); + names.insert(input_name.as_str(), (INPUT, input.to_vec())); + let mut steps: Vec<Step> = Vec::new(); + for (i, node) in g.nodes.iter().enumerate() { + let Some(out_shape) = shapes.get(i) else { return shape_err("a node has no shape") }; + let Some(out_name) = node.outputs.first() else { return shape_err("a node has no output") }; + let value = |idx: usize| -> Option<&(usize, Vec<usize>)> { node.inputs.get(idx).and_then(|n| names.get(n.as_str())) }; + let constant = |idx: usize| -> Option<(Cow<'_, [f32]>, &[usize])> { + let w = node.inputs.get(idx).and_then(|n| g.weights.get(n))?; + match &w.data { + Data::F32(v) => Some((Cow::Borrowed(v.as_slice()), w.dims.as_slice())), + _ => None, + } + }; + let id = |k: usize| i * 4 + k; + let Some((x, x_shape)) = value(0).cloned() else { return Ok(None) }; + let (op, args) = match node.op.as_str() { + "Reshape" | "Flatten" | "Identity" | "Dropout" => { + names.insert(out_name.as_str(), (x, out_shape.clone())); + continue; + } + "Conv" => (Op::Conv(kernels::conv_job(i, node, &x_shape, &[], g)?), vec![Arg::Value(x)]), + "Relu" => (Op::Unary(Unary::Relu), vec![Arg::Value(x)]), + "Sigmoid" => (Op::Unary(Unary::Sigmoid), vec![Arg::Value(x)]), + "BatchNormalization" => { + let channels = *x_shape.get(1).ok_or_else(|| NetError::Shape("BatchNormalization rank".into()))?; + let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?; + let (s, b, m, v) = + (shape::floats(node, g, 1)?, shape::floats(node, g, 2)?, shape::floats(node, g, 3)?, shape::floats(node, g, 4)?); + let eps = shape::float(node, "epsilon", 1e-5); + let mut scale = Vec::with_capacity(channels); + let mut offset = Vec::with_capacity(channels); + for c in 0..channels { + let (Some(s), Some(b), Some(m), Some(v)) = (s.get(c), b.get(c), m.get(c), v.get(c)) else { + return shape_err("short BatchNormalization parameters"); + }; + let k = s / (v + eps).sqrt(); + scale.push(k); + offset.push(b - m * k); + } + ( + Op::ChannelAffine { channels, plane }, + vec![Arg::Value(x), Arg::Const { id: id(1), data: scale.into() }, Arg::Const { id: id(2), data: offset.into() }], + ) + } + "PRelu" => { + let Some((slope, dims)) = constant(1) else { return Ok(None) }; + let channels = x_shape.get(1).copied().unwrap_or(1); + let plane = shape::elements(x_shape.get(2..).unwrap_or(&[]))?; + let mut expanded = vec![1; x_shape.len().saturating_sub(dims.len())]; + expanded.extend_from_slice(dims); + let per_channel = expanded.iter().enumerate().all(|(i, &d)| d == 1 || i == 1) && slope.len() == channels; + let slope: Cow<[f32]> = if per_channel { + slope + } else if let [one] = slope.as_ref() { + vec![*one; channels].into() + } else { + return Ok(None); + }; + (Op::ChannelPrelu { channels, plane }, vec![Arg::Value(x), Arg::Const { id: id(1), data: slope }]) + } + "Add" | "Sub" | "Mul" => { + let op = match node.op.as_str() { + "Add" => Binary::Add, + "Sub" => Binary::Sub, + _ => Binary::Mul, + }; + let (b, b_shape) = match (value(1), constant(1)) { + (Some((v, s)), _) => (Arg::Value(*v), s.clone()), + (None, Some((data, dims))) => (Arg::Const { id: id(1), data }, dims.to_vec()), + _ => return Ok(None), + }; + let Some(dims) = rank4(out_shape) else { return Ok(None) }; + let (Some(sa), Some(sb)) = (broadcast_strides(dims, &x_shape), broadcast_strides(dims, &b_shape)) else { return Ok(None) }; + (Op::Binary { op, dims, a: sa, b: sb }, vec![Arg::Value(x), b]) + } + "Gemm" => { + let Some((weights, wdims)) = constant(1) else { return Ok(None) }; + let ([1, k], [n_out]) = (x_shape.as_slice(), out_shape.get(1..).unwrap_or(&[])) else { return Ok(None) }; + if node.int("transA").unwrap_or(0) != 0 || out_shape.first() != Some(&1) { + return Ok(None); + } + let trans_b = node.int("transB").unwrap_or(0) != 0; + if wdims.len() != 2 || weights.len() != k * n_out { + return shape_err("Gemm weights do not match"); + } + let mut bias = vec![0.0f32; *n_out]; + if node.inputs.get(2).is_some_and(|s| !s.is_empty()) { + let wt = shape::weight(node, g, 2)?; + let b = shape::floats(node, g, 2)?; + let beta = shape::float(node, "beta", 1.0); + for (j, to) in bias.iter_mut().enumerate() { + *to = beta + * b.get(kernels::broadcast_offset(j, out_shape, &wt.dims)) + .copied() + .ok_or_else(|| NetError::Shape("short matrix bias".into()))?; + } + } + ( + Op::Gemv { k: *k, n: *n_out, trans_b, alpha: shape::float(node, "alpha", 1.0) }, + vec![Arg::Value(x), Arg::Const { id: id(1), data: weights }, Arg::Const { id: id(2), data: bias.into() }], + ) + } + "GlobalAveragePool" => { + let (c, h, w) = chw_of(&x_shape)?; + (Op::GlobalAverage { channels: c, plane: h * w }, vec![Arg::Value(x)]) + } + "MaxPool" => { + let (c, h, w) = chw_of(&x_shape)?; + let (kh, kw) = pair(node, "kernel_shape", 1)?; + let (sh, sw) = pair(node, "strides", 1)?; + if pads(node)? != (0, 0, 0, 0) { + return Ok(None); + } + let (_, oh, ow) = chw_of(out_shape)?; + (Op::MaxPool { c, h, w, kh, kw, sh, sw, oh, ow }, vec![Arg::Value(x)]) + } + "Resize" => { + let (c, h, w) = chw_of(&x_shape)?; + let (_, oh, ow) = chw_of(out_shape)?; + if oh % h != 0 || ow % w != 0 { + return shape_err("a Resize that is not by whole numbers"); + } + (Op::Resize { c, h, w, sh: oh / h, sw: ow / w }, vec![Arg::Value(x)]) + } + "Transpose" => { + let rank = x_shape.len(); + let default_perm: Vec<i64> = (0..rank as i64).rev().collect(); + let perm = node.ints("perm").unwrap_or(&default_perm); + if rank > 4 || perm.len() != rank { + return Ok(None); + } + let mut in_strides = vec![1usize; rank]; + for a in (0..rank.saturating_sub(1)).rev() { + let next = in_strides.get(a + 1).copied().unwrap_or(1) * x_shape.get(a + 1).copied().unwrap_or(1); + if let Some(s) = in_strides.get_mut(a) { + *s = next; + } + } + let Some(dims) = rank4(out_shape) else { return Ok(None) }; + let mut strides = [0usize; 4]; + for (j, p) in perm.iter().enumerate() { + let p = usize::try_from(*p).map_err(|_| NetError::Shape("a negative permutation".into()))?; + let s = in_strides.get(p).copied().ok_or_else(|| NetError::Shape("a bad permutation".into()))?; + *strides.get_mut(4 - rank + j).ok_or_else(|| NetError::Shape("a bad permutation".into()))? = s; + } + (Op::Transpose { dims, strides }, vec![Arg::Value(x)]) + } + _ => return Ok(None), + }; + let out = lens.len(); + lens.push(shape::elements(out_shape)?); + names.insert(out_name.as_str(), (out, out_shape.clone())); + steps.push(Step { op, args, out, free: Vec::new() }); + } + let outputs = g + .outputs + .iter() + .map(|name| { + let (v, s) = names.get(name.as_str()).ok_or_else(|| NetError::Shape(format!("output `{name}` was never computed")))?; + Ok((name.clone(), *v, s.clone())) + }) + .collect::<Result<Vec<_>>>()?; + // the last step that reads each value; a value nothing reads is free right after it is made + let mut last: Vec<Option<usize>> = vec![None; lens.len()]; + for (at, step) in steps.iter().enumerate() { + if let Some(l) = last.get_mut(step.out) { + *l = Some(at); + } + for a in &step.args { + if let Arg::Value(v) = a + && let Some(l) = last.get_mut(*v) + { + *l = Some(at); + } + } + } + for (v, at) in last.iter().enumerate() { + if outputs.iter().any(|(_, o, _)| *o == v) { + continue; + } + if let Some(step) = at.and_then(|at| steps.get_mut(at)) { + step.free.push(v); + } + } + Ok(Some(Program { lens, steps, outputs })) + } +} + +pub(super) fn chw_of(s: &[usize]) -> Result<(usize, usize, usize)> { + match s { + [1, c, h, w] => Ok((*c, *h, *w)), + s => unsupported(format!("a tensor of shape {s:?} (only [1, channels, height, width])")), + } +} diff --git a/crates/faces/src/net/shape.rs b/crates/faces/src/net/shape.rs index d76271bc7..a1594ca6b 100644 --- a/crates/faces/src/net/shape.rs +++ b/crates/faces/src/net/shape.rs @@ -186,11 +186,7 @@ pub(super) fn check(n: &Node, g: &Graph) -> Result<()> { floats(n, g, 2)?; } } - "Dropout" => { - if !(0.0..1.0).contains(&float(n, "ratio", 0.5)) { - return shape_err("Dropout ratio is invalid"); - } - } + "Dropout" if !(0.0..1.0).contains(&float(n, "ratio", 0.5)) => return shape_err("Dropout ratio is invalid"), "MaxPool" => { pair(n, "kernel_shape", 1)?; pair(n, "strides", 1)?; @@ -261,7 +257,12 @@ fn image(s: &[usize]) -> Result<(usize, usize, usize)> { } } pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { + Ok(infer_all(g, input)?.1) +} +/// Each node's output shape, and the graph's outputs' shapes. +pub(super) fn infer_all(g: &Graph, input: &[usize]) -> Result<(Vec<Vec<usize>>, Vec<Vec<usize>>)> { elements(input)?; + let mut per_node = Vec::with_capacity(g.nodes.len()); let mut shapes: HashMap<&str, Vec<usize>> = g.weights.iter().map(|(n, w)| (n.as_str(), w.dims.clone())).collect(); shapes.insert(g.inputs.first().ok_or_else(|| NetError::Shape("no input".into()))?.0.as_str(), input.to_vec()); let mut live = elements(input)?; @@ -484,6 +485,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { .checked_add(out_count) .filter(|&n| n <= 128 * 1024 * 1024) .ok_or_else(|| NetError::Shape("live tensors exceed the 512 MiB working limit".into()))?; + per_node.push(out.clone()); shapes.insert(n.outputs.first().ok_or_else(|| NetError::Shape("no output".into()))?, out); let mut removed = std::collections::HashSet::new(); for name in &n.inputs { @@ -492,5 +494,7 @@ pub(super) fn infer(g: &Graph, input: &[usize]) -> Result<Vec<Vec<usize>>> { } } } - g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect() + let outputs = + g.outputs.iter().map(|s| shapes.get(s.as_str()).cloned().ok_or_else(|| NetError::Shape("missing output".into()))).collect::<Result<_>>()?; + Ok((per_node, outputs)) } diff --git a/crates/faces/src/runtime.rs b/crates/faces/src/runtime.rs index bbb1457eb..92f9e8134 100644 --- a/crates/faces/src/runtime.rs +++ b/crates/faces/src/runtime.rs @@ -40,6 +40,9 @@ pub struct SelfTest { type Plan = Net; +/// Most faces [`Embedder::embed_batch`] sends to a GPU in one go. +pub const BATCH: usize = 16; + /// A loaded recognition model. #[derive(Clone)] pub struct Embedder { @@ -99,6 +102,31 @@ impl Embedder { }) } + /// The network, e.g. to see which device it runs on. + pub fn net(&self) -> &Net { + &self.plan + } + + /// Run the model's convolutions on `accelerator` if it gives the CPU's embedding on a test face (and, with + /// `require_faster`, runs it faster than the CPU); otherwise (or on + /// its first error) recognition stays on the CPU. Returns why it was not used. + pub fn accelerate(&self, accelerator: Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), RuntimeError> { + let (mean, std) = (self.manifest.input.mean, self.manifest.input.std); + let plane = self.width * self.height; + let data: Vec<f32> = crate::yunet::probe_pixels(3 * plane) + .into_iter() + .enumerate() + .map(|(i, v)| { + let c = (i / plane.max(1)).min(2); + (v - mean.get(c).copied().unwrap_or(0.0)) / std.get(c).copied().filter(|s| *s != 0.0).unwrap_or(1.0) + }) + .collect(); + let probe = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?; + guarded("checking the device", || { + self.plan.attach_checked(accelerator, &probe, crate::yunet::ACCELERATOR_TOLERANCE, require_faster).map_err(RuntimeError::Run) + }) + } + /// The size of the aligned face picture the model wants (width, height). pub fn input_size(&self) -> (usize, usize) { (self.width, self.height) @@ -110,6 +138,66 @@ impl Embedder { /// The face as a unit-length vector. `rgb` is an aligned face at exactly [`Self::input_size`], 8-bit RGB. pub fn embed(&self, rgb: &[u8]) -> Result<Vec<f32>, RuntimeError> { + let input = self.input(rgb)?; + let out = guarded("running it", || { + let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?; + if result.len() != 1 { + return Err(RuntimeError::Run("the model must give one embedding".into())); + } + Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data) + })?; + self.unit(out) + } + + /// Several faces at once, each as [`Self::embed`] gives it. On a GPU they run together, up to [`BATCH`] per + /// dispatch (one upload and one read-back for all of them); on the CPU one after another. + pub fn embed_batch(&self, faces: &[&[u8]]) -> Vec<Result<Vec<f32>, RuntimeError>> { + if self.plan.accelerator().is_none() || faces.len() < 2 { + return faces.iter().map(|f| self.embed(f)).collect(); + } + let mut results: Vec<Option<Result<Vec<f32>, RuntimeError>>> = faces.iter().map(|_| None).collect(); + let mut ready: Vec<(usize, Tensor)> = Vec::new(); + for (i, face) in faces.iter().enumerate() { + match self.input(face) { + Ok(t) => ready.push((i, t)), + Err(e) => { + if let Some(r) = results.get_mut(i) { + *r = Some(Err(e)); + } + } + } + } + while !ready.is_empty() { + let chunk: Vec<(usize, Tensor)> = ready.drain(..ready.len().min(BATCH)).collect(); + let (at, inputs): (Vec<usize>, Vec<Tensor>) = chunk.into_iter().unzip(); + let ran = guarded("running it", || self.plan.run_batch(inputs).map_err(|e| RuntimeError::Run(e.to_string()))); + match ran { + Ok(outs) if outs.len() == at.len() => { + for (i, mut out) in at.into_iter().zip(outs) { + let r = match (out.len(), out.pop()) { + (1, Some((_, t))) => self.unit(t.data), + _ => Err(RuntimeError::Run("the model must give one embedding".into())), + }; + if let Some(slot) = results.get_mut(i) { + *slot = Some(r); + } + } + } + // one bad face spoils a batch: run them one by one so the others still get theirs + _ => { + for i in at { + if let (Some(slot), Some(face)) = (results.get_mut(i), faces.get(i)) { + *slot = Some(self.embed(face)); + } + } + } + } + } + results.into_iter().map(|r| r.unwrap_or_else(|| Err(RuntimeError::Run("the face was not embedded".into())))).collect() + } + + /// The model's input for an aligned face. + fn input(&self, rgb: &[u8]) -> Result<Tensor, RuntimeError> { if rgb.len() != self.width * self.height * 3 { return Err(RuntimeError::Input("the face picture is not the size the model wants".into())); } @@ -124,14 +212,11 @@ impl Embedder { } } } - let out = guarded("running it", || { - let input = Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string()))?; - let mut result = self.plan.run(input).map_err(|e| RuntimeError::Run(e.to_string()))?; - if result.len() != 1 { - return Err(RuntimeError::Run("the model must give one embedding".into())); - } - Ok(result.pop().ok_or_else(|| RuntimeError::Run("no output".into()))?.1.data) - })?; + Tensor::new(vec![1, 3, self.height, self.width], data).map_err(|e| RuntimeError::Input(e.to_string())) + } + + /// The model's output, checked and scaled to unit length. + fn unit(&self, out: Vec<f32>) -> Result<Vec<f32>, RuntimeError> { if out.len() != self.dim { return Err(RuntimeError::Run(format!("the model gave {} numbers per face, not the {} its description says", out.len(), self.dim))); } diff --git a/crates/faces/src/synthetic.rs b/crates/faces/src/synthetic.rs index 61010a243..6bb792a1f 100644 --- a/crates/faces/src/synthetic.rs +++ b/crates/faces/src/synthetic.rs @@ -248,3 +248,63 @@ pub fn tiny_detector_manifest() -> crate::manifest::ModelManifest { m.size_bytes = Some(bytes.len() as u64); m } + +/// A small network that uses every operator the interpreter runs (both kinds of convolution, batch norm, PRelu, +/// sigmoid, broadcasting add/sub/mul with values and constants, max pooling, nearest upscaling, transposes, reshape, +/// global pooling, flatten and a fully connected layer), with two outputs, for checking an accelerator's program +/// against the CPU. Its input is `[1, 4, 8, 8]`. +pub fn every_operator_graph() -> crate::graph::Graph { + use crate::graph::{Attr, Data, Graph, Node, Weight}; + let node = |op: &str, inputs: &[&str], output: &str, attrs: &[(&str, Attr)]| Node { + op: op.into(), + inputs: inputs.iter().map(|s| s.to_string()).collect(), + outputs: vec![output.into()], + attrs: attrs.iter().map(|(k, v)| (k.to_string(), v.clone())).collect(), + }; + let wave = |n: usize, k: usize| -> Vec<f32> { (0..n).map(|i| (((i * k) % 23) as f32 - 11.0) * 0.04).collect() }; + let w = |dims: &[usize], v: Vec<f32>| Weight { dims: dims.to_vec(), data: Data::F32(v) }; + let weights = [ + ("w1", w(&[8, 4, 3, 3], wave(288, 7))), + ("b1", w(&[8], wave(8, 5))), + ("bn_s", w(&[8], (0..8).map(|i| 0.5 + i as f32 * 0.1).collect())), + ("bn_b", w(&[8], wave(8, 3))), + ("bn_m", w(&[8], wave(8, 11))), + ("bn_v", w(&[8], (0..8).map(|i| 0.2 + i as f32 * 0.05).collect())), + ("slope", w(&[8, 1, 1], (0..8).map(|i| 0.05 * i as f32).collect())), + ("w2", w(&[8, 1, 3, 3], wave(72, 13))), + ("scales", w(&[4], vec![1.0, 1.0, 2.0, 2.0])), + ("half", w(&[1], vec![0.5])), + ("per_channel", w(&[1, 8, 1, 1], wave(8, 17))), + ("wg", w(&[5, 8], wave(40, 19))), + ("bg", w(&[5], wave(5, 2))), + ("flat", Weight { dims: vec![2], data: Data::I64(vec![1, -1]) }), + ]; + let pads = |p: i64| ("pads", Attr::Ints(vec![p; 4])); + let nodes = vec![ + node("Conv", &["x", "w1", "b1"], "c1", &[pads(1)]), + node("BatchNormalization", &["c1", "bn_s", "bn_b", "bn_m", "bn_v"], "bn", &[]), + node("PRelu", &["bn", "slope"], "pr", &[]), + node("Conv", &["pr", "w2"], "c2", &[("group", Attr::Int(8)), ("strides", Attr::Ints(vec![2, 2])), pads(1)]), + node("Sigmoid", &["c2"], "sg", &[]), + node("Mul", &["c2", "sg"], "swish", &[]), + node("Resize", &["swish", "", "scales"], "up", &[]), + node("Add", &["up", "pr"], "sum", &[]), + node("Sub", &["sum", "half"], "less", &[]), + node("MaxPool", &["less"], "pool", &[("kernel_shape", Attr::Ints(vec![2, 2])), ("strides", Attr::Ints(vec![2, 2]))]), + node("Relu", &["pool"], "relu", &[]), + node("Transpose", &["relu"], "nhwc", &[("perm", Attr::Ints(vec![0, 2, 3, 1]))]), + node("Transpose", &["nhwc"], "nchw", &[("perm", Attr::Ints(vec![0, 3, 1, 2]))]), + node("Mul", &["nchw", "per_channel"], "scaled", &[]), + node("Identity", &["scaled"], "same", &[]), + node("GlobalAveragePool", &["same"], "avg", &[]), + node("Flatten", &["avg"], "row", &[]), + node("Reshape", &["row", "flat"], "row2", &[]), + node("Gemm", &["row2", "wg", "bg"], "y", &[("transB", Attr::Int(1)), ("alpha", Attr::Float(0.5))]), + ]; + Graph { + nodes, + weights: weights.into_iter().map(|(k, v)| (k.to_string(), v)).collect(), + inputs: vec![("x".into(), vec![1, 4, 8, 8])], + outputs: vec!["y".into(), "nhwc".into()], + } +} diff --git a/crates/faces/src/yunet.rs b/crates/faces/src/yunet.rs index b77f318bf..145410774 100644 --- a/crates/faces/src/yunet.rs +++ b/crates/faces/src/yunet.rs @@ -60,6 +60,14 @@ pub struct Face { pub landmarks: [(f32, f32); 5], } +/// How far an accelerator's outputs may stray from the CPU's on the test input, relative to each output's largest value. +pub const ACCELERATOR_TOLERANCE: f32 = 1e-3; + +/// A deterministic, photo-like test input (smooth gradients plus texture, in 0..255). +pub fn probe_pixels(n: usize) -> Vec<f32> { + (0..n).map(|i| ((i % 640) as f32 * 0.3 + ((i * 7919) % 97) as f32 + (i / 640 % 480) as f32 * 0.2) % 255.0).collect() +} + pub struct Detector { net: Net, /// Side of the square network input. @@ -90,6 +98,20 @@ impl Detector { Ok(Detector { net, side }) } + /// The network, e.g. to see which device it runs on. + pub fn net(&self) -> &Net { + &self.net + } + + /// Run the network's convolutions on `accelerator` if it gives the CPU's outputs on a test picture (and, with `require_faster`, + /// runs it faster than the CPU); otherwise + /// (or on its first error) detection stays on the CPU. Returns why it was not used. + pub fn accelerate(&self, accelerator: std::sync::Arc<dyn crate::net::Accelerator>, require_faster: bool) -> Result<(), String> { + let side = self.side; + let probe = Tensor::new(vec![1, 3, side, side], probe_pixels(3 * side * side)).map_err(|e| e.to_string())?; + self.net.attach_checked(accelerator, &probe, ACCELERATOR_TOLERANCE, require_faster) + } + /// The square input side, used to check an installed manifest. pub fn input_side(&self) -> usize { self.side @@ -263,7 +285,7 @@ mod tests { fn detector() -> Option<Detector> { let Some(bytes) = model() else { - eprintln!("LC_YUNET_MODEL is not set: skipping"); + lightcraft_testkit::skip!(Model, "LC_YUNET_MODEL is not set"); return None; }; Some(Detector::new(&bytes).unwrap()) diff --git a/crates/fetch/src/http.rs b/crates/fetch/src/http.rs index c2b5bee1e..0b1b0ae98 100644 --- a/crates/fetch/src/http.rs +++ b/crates/fetch/src/http.rs @@ -10,7 +10,7 @@ //! the downloading thread. No proxies (see docs/ai-masks.md). use std::io::{ErrorKind, Read, Write}; -use std::net::{TcpStream, ToSocketAddrs}; +use std::net::{SocketAddr, TcpStream, ToSocketAddrs}; use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::{Duration, Instant}; @@ -88,6 +88,9 @@ impl Url { let end = v6.find(']').ok_or_else(|| bad("unterminated IPv6 address"))?; let host = v6.get(..end).unwrap_or_default(); let after = v6.get(end + 1..).unwrap_or_default(); + if !after.is_empty() && !after.starts_with(':') { + return Err(bad("unexpected text after IPv6 address")); + } (host.to_string(), after.strip_prefix(':')) } else { match authority.rsplit_once(':') { @@ -182,27 +185,68 @@ fn tls_config() -> Result<Arc<rustls::ClientConfig>, HttpError> { Ok(Arc::new(config)) } +/// Resolve a host name within `deadline`, checking the cancel flag while it runs. The system resolver blocks with no +/// timeout of its own, so it runs on a helper thread (which ends when the resolver returns); a lookup that takes too +/// long is given up on, and Cancel works during it (issue #1532). +fn lookup( + host: &str, + resolve: impl FnOnce() -> std::io::Result<Vec<SocketAddr>> + Send + 'static, + deadline: Instant, + limits: &Limits, +) -> Result<Vec<SocketAddr>, HttpError> { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::Builder::new() + .name("lc-fetch-lookup".into()) + .spawn(move || { + let _ = tx.send(resolve()); + }) + .map_err(|e| HttpError::Connect(format!("{host}: {e}")))?; + loop { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: the name lookup timed out"))); + } + match rx.recv_timeout(left.min(TICK)) { + Ok(r) => return r.map_err(|e| HttpError::Connect(format!("{host}: {e}"))), + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {} + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => return Err(HttpError::Connect(format!("{host}: the name lookup failed"))), + } + } +} + +/// Connect to the first of `addrs` that answers, all of them together within `deadline` (each used to get the whole +/// connect timeout of its own, issue #1532). +fn connect_any(host: &str, addrs: &[SocketAddr], deadline: Instant, limits: &Limits) -> Result<TcpStream, HttpError> { + let mut last = format!("{host}: no address"); + for a in addrs { + if limits.cancel.load(Ordering::Relaxed) { + return Err(HttpError::Cancelled); + } + let left = deadline.saturating_duration_since(Instant::now()); + if left.is_zero() { + return Err(HttpError::Connect(format!("{host}: connecting timed out"))); + } + match TcpStream::connect_timeout(a, left) { + Ok(s) => return Ok(s), + Err(e) => last = format!("{host}: {e}"), + } + } + Err(HttpError::Connect(last)) +} + impl Stream { fn connect(url: &Url, limits: &Limits) -> Result<Stream, HttpError> { - let addrs: Vec<_> = (url.host.as_str(), url.port).to_socket_addrs().map_err(|e| HttpError::Connect(format!("{}: {e}", url.host)))?.collect(); - if addrs.is_empty() { - return Err(HttpError::Connect(format!("{}: no address", url.host))); - } - let mut last = String::new(); - let mut sock = None; - for a in addrs { - if limits.cancel.load(Ordering::Relaxed) { - return Err(HttpError::Cancelled); - } - match TcpStream::connect_timeout(&a, limits.connect) { - Ok(s) => { - sock = Some(s); - break; - } - Err(e) => last = format!("{}: {e}", url.host), - } - } - let sock = sock.ok_or(HttpError::Connect(last))?; + // the lookup and the connection attempts share one connect timeout + let deadline = Instant::now() + limits.connect; + let (host, port) = (url.host.clone(), url.port); + let addrs = lookup(&url.host, move || (host.as_str(), port).to_socket_addrs().map(Iterator::collect), deadline, limits)?; + let sock = connect_any(&url.host, &addrs, deadline, limits)?; sock.set_read_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; sock.set_write_timeout(Some(TICK)).map_err(|e| HttpError::Io(e.to_string()))?; let _ = sock.set_nodelay(true); @@ -512,6 +556,58 @@ pub fn get(url: &Url, headers: &[(&str, String)], limits: &Limits) -> Result<Res Ok(Response { status, headers, conn, body }) } +#[cfg(test)] +mod tests_connect { + use super::*; + + fn limits(cancel: &AtomicBool) -> Limits<'_> { + Limits { connect: Duration::from_millis(300), stall: Duration::from_secs(1), cancel } + } + + /// A name lookup that hangs is given up on at the connect timeout, and Cancel stops it at once (issue #1532). + #[test] + fn a_hung_name_lookup_times_out_and_can_be_cancelled() { + let cancel = AtomicBool::new(false); + let slow = || { + std::thread::sleep(Duration::from_secs(10)); + Ok(Vec::new()) + }; + let t = Instant::now(); + let r = lookup("slow.example", slow, Instant::now() + Duration::from_millis(300), &limits(&cancel)); + assert!(matches!(&r, Err(HttpError::Connect(m)) if m.contains("timed out")), "{r:?}"); + assert!(t.elapsed() < Duration::from_secs(2), "{:?}", t.elapsed()); + // Cancel while the lookup is waiting on the resolver, not before it starts + let t = Instant::now(); + let r = std::thread::scope(|scope| { + scope.spawn(|| { + std::thread::sleep(Duration::from_millis(100)); + cancel.store(true, Ordering::Relaxed); + }); + lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)) + }); + assert!(matches!(r, Err(HttpError::Cancelled)), "{r:?}"); + assert!(t.elapsed() >= Duration::from_millis(100) && t.elapsed() < Duration::from_secs(1), "{:?}", t.elapsed()); + // and a lookup asked for after Cancel doesn't start + assert!(matches!(lookup("slow.example", slow, Instant::now() + Duration::from_secs(30), &limits(&cancel)), Err(HttpError::Cancelled))); + // a lookup that answers is used + let cancel = AtomicBool::new(false); + let local: SocketAddr = "127.0.0.1:9".parse().unwrap(); + assert_eq!(lookup("local", move || Ok(vec![local]), Instant::now() + Duration::from_secs(5), &limits(&cancel)).unwrap(), [local]); + } + + /// Several addresses that don't answer share one deadline instead of each getting the full timeout. + #[test] + fn addresses_share_one_connect_deadline() { + let cancel = AtomicBool::new(false); + // non-routable addresses: each either hangs until the deadline or fails at once, never longer + let addrs: Vec<SocketAddr> = (1..=5).map(|i| format!("10.255.255.{i}:81").parse().unwrap()).collect(); + let t = Instant::now(); + let r = connect_any("blackhole", &addrs, Instant::now() + Duration::from_millis(400), &limits(&cancel)); + assert!(r.is_err()); + assert!(t.elapsed() < Duration::from_millis(1500), "{:?}", t.elapsed()); + } +} + #[cfg(test)] mod tests { use super::*; @@ -537,6 +633,17 @@ mod tests { } } + #[test] + fn rejects_text_after_a_bracketed_ipv6_host() { + for bad in ["http://[::1]junk/x", "https://[::1]junk/x", "http://[::1]8080/x", "http://[::1]8080"] { + assert!(matches!(Url::parse(bad), Err(HttpError::BadUrl(_))), "{bad}"); + } + let u = Url::parse("http://[::1]:8080/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 8080)); + let u = Url::parse("http://[::1]/x").unwrap(); + assert_eq!((u.host.as_str(), u.port), ("::1", 80)); + } + #[test] fn resolves_redirects() { let u = Url::parse("https://a.example/x/y/file?q").unwrap(); diff --git a/crates/fetch/src/lib.rs b/crates/fetch/src/lib.rs index c5c96a902..12daff541 100644 --- a/crates/fetch/src/lib.rs +++ b/crates/fetch/src/lib.rs @@ -314,6 +314,15 @@ fn sha256_file(path: &Path) -> Result<String, String> { Ok(h.finalize().iter().map(|b| format!("{b:02x}")).collect()) } +/// A new, empty partial download at `part`, created exclusively after removing whatever was there (a link itself, +/// never its target). +fn new_part(part: &Path) -> std::io::Result<File> { + if std::fs::symlink_metadata(part).is_ok() { + std::fs::remove_file(part)?; + } + OpenOptions::new().write(true).create_new(true).open(part) +} + fn part_path(dir: &Path, name: &str) -> PathBuf { dir.join(format!("{name}.part")) } @@ -324,9 +333,10 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & let part = part_path(dir, f.name); let limit = f.size.unwrap_or(f.max); let disk = |e: std::io::Error| Fail::Disk(format!("{}: {e}", part.display())); - // resume only a pinned file (an unpinned one can't be checked after resuming) - let mut have = match std::fs::metadata(&part) { - Ok(m) if f.size.is_some() && m.len() <= limit => m.len(), + // resume only a pinned file (an unpinned one can't be checked after resuming), and only a plain file: anything + // else at that name (a link, which `metadata` would follow) is removed, never written through (issue #1502) + let mut have = match std::fs::symlink_metadata(&part) { + Ok(m) if m.is_file() && f.size.is_some() && m.len() <= limit => m.len(), Ok(_) => { std::fs::remove_file(&part).map_err(disk)?; 0 @@ -368,7 +378,7 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & return Err(Fail::NextMirror(format!("the server's file has the wrong size ({:?} bytes)", resp.content_length()))); } have = 0; - File::create(&part).map_err(disk)? + new_part(&part).map_err(disk)? } 206 if have > 0 => { let range = resp.header("content-range").unwrap_or_default(); @@ -376,6 +386,10 @@ fn fetch_file(f: &FileSpec<'_>, url: &str, dir: &Path, opts: &Options, cancel: & if start != have || total.is_some_and(|t| Some(t) != f.size) { return Err(Fail::NextMirror(format!("the server resumed at the wrong place ({range})"))); } + // (still the plain file checked above: a link put there meanwhile is not appended to) + if !std::fs::symlink_metadata(&part).is_ok_and(|m| m.is_file()) { + return Err(Fail::Retry("the partial download changed; starting over".into())); + } OpenOptions::new().append(true).open(&part).map_err(disk)? } 416 => { diff --git a/crates/fetch/src/tests.rs b/crates/fetch/src/tests.rs index 4a828c6d6..08f869dcc 100644 --- a/crates/fetch/src/tests.rs +++ b/crates/fetch/src/tests.rs @@ -210,6 +210,28 @@ fn resumes_partial_files_and_restarts_bad_ones() { let _ = std::fs::remove_dir_all(&dir); } +/// A link planted at the partial download's name is removed, never written or appended through: the file it points +/// to keeps its bytes and the download completes (issue #1502). +#[cfg(unix)] +#[test] +fn a_link_at_the_partial_name_is_never_written_through() { + let srv = server(); + let dir = tmp("part-link"); + std::fs::create_dir_all(&dir).unwrap(); + let victim = dir.join("victim.txt"); + for target_len in [10usize, 0] { + std::fs::write(&victim, vec![b'k'; target_len.max(1)]).unwrap(); + let before = std::fs::read(&victim).unwrap(); + let _ = std::fs::remove_file(dir.join("model.bin")); + let _ = std::fs::remove_file(part_path(&dir, "model.bin")); + std::os::unix::fs::symlink(&victim, part_path(&dir, "model.bin")).unwrap(); + run(&[pinned()], &[format!("{}/good", srv.base)], &dir, &AtomicBool::new(false)).0.unwrap(); + assert_eq!(std::fs::read(&victim).unwrap(), before, "the linked file is untouched"); + assert_eq!(std::fs::read(dir.join("model.bin")).unwrap(), payload()); + } + let _ = std::fs::remove_dir_all(&dir); +} + #[test] fn a_416_or_a_bad_complete_part_starts_over() { let srv = server(); diff --git a/crates/geocode/src/lib.rs b/crates/geocode/src/lib.rs index 3acb6b5f6..de0bea5c9 100644 --- a/crates/geocode/src/lib.rs +++ b/crates/geocode/src/lib.rs @@ -104,7 +104,8 @@ pub struct GeoNames { fn cell(p: LatLon) -> (i32, i32) { // valid positions only: |lat| ≤ 90, |lon| ≤ 180, so the casts are in range - (p.lat.floor() as i32, p.lon.floor() as i32) + // longitude 180 is the same meridian as -180: key it as column -180, the wrap `reverse` probes + (p.lat.floor() as i32, (p.lon.floor() as i32 + 180).rem_euclid(360) - 180) } fn short(s: &str) -> String { @@ -173,17 +174,22 @@ impl GeoNames { if !p.is_valid() { return None; } - let (cy, cx) = cell(p); + let (cy, _) = cell(p); let mut best: Option<(f64, &City)> = None; - // ±1° covers 50 km everywhere except very near the poles (where there are no cities) + // ±1° of latitude covers 50 km everywhere. Longitude degrees shrink with latitude: a point + // 50 km away differs by at most asin(sin(50 km / R) / cos(lat)) degrees of longitude + // (every longitude once the circle of latitude is that small). + let reach = (MAX_KM * 1000.0 / EARTH_RADIUS_M).sin(); + let scale = p.lat.to_radians().cos(); + let (x0, x1) = if scale <= reach { + (-180, 179) + } else { + let span = (reach / scale).asin().to_degrees(); + ((p.lon - span).floor() as i32, (p.lon + span).floor() as i32) + }; for dy in -1..=1 { - for dx in -1..=1 { - let mut x = cx + dx; - if x < -180 { - x += 360; - } else if x >= 180 { - x -= 360; - } + for dx in x0..=x1 { + let x = (dx + 180).rem_euclid(360) - 180; for &i in self.cells.get(&(cy + dy, x)).into_iter().flatten() { let Some(c) = self.cities.get(i as usize) else { continue }; let d = distance_m(p, c.at); @@ -248,6 +254,31 @@ mod tests { assert_eq!(fiji.reverse(LatLon::new(-17.0, -179.95)).unwrap().city, "X"); } + #[test] + fn city_on_the_antimeridian_is_found() { + let g = GeoNames::parse("1\tTaveuni\tTaveuni\t\t0.0\t180.0\tP\tPPL\tFJ\t\t01\n", None, None); + for lon in [179.9, -179.9, 180.0, -180.0] { + assert_eq!(g.reverse(LatLon::new(0.0, lon)).map(|p| p.city).as_deref(), Some("Taveuni"), "from {lon}"); + } + } + + #[test] + fn high_latitude_cities_two_cells_away() { + let city = |lat: f64, lon: f64| GeoNames::parse(&format!("1\tX\tX\t\t{lat}\t{lon}\tP\tP\tNO\t\t\n"), None, None); + // 70°N: 1° of longitude is ~38 km, so 45.6 km is two cells east + assert_eq!(city(70.0, 0.9).reverse(LatLon::new(70.0, 2.1)).unwrap().city, "X"); + assert_eq!(city(70.0, 2.1).reverse(LatLon::new(70.0, 0.9)).unwrap().city, "X"); + // 64°N: 1° is ~48.7 km, so 1.02° is just inside range yet two cells apart + assert_eq!(city(64.0, 0.99).reverse(LatLon::new(64.0, 2.01)).unwrap().city, "X"); + // across the antimeridian, two cells apart + assert_eq!(city(70.0, 178.9).reverse(LatLon::new(70.0, -179.9)).unwrap().city, "X"); + assert_eq!(city(70.0, -179.9).reverse(LatLon::new(70.0, 178.9)).unwrap().city, "X"); + // still out of range (~80 km) + assert!(city(70.0, 0.0).reverse(LatLon::new(70.0, 2.1)).is_none()); + // very near the pole every longitude is close + assert_eq!(city(89.9, 0.5).reverse(LatLon::new(89.9, 100.0)).unwrap().city, "X"); + } + #[test] fn junk_text_parses_to_nothing() { let g = GeoNames::parse("\t\t\t\t\t\n\u{0}\t\u{feff}\n", Some("\n\t\n"), Some("x")); diff --git a/crates/geom/src/crop.rs b/crates/geom/src/crop.rs index 857ea19b9..cc26b40fe 100644 --- a/crates/geom/src/crop.rs +++ b/crates/geom/src/crop.rs @@ -7,7 +7,7 @@ use serde::{Deserialize, Serialize}; -use crate::{Affine, Point, Rect, Vec2}; +use crate::{Affine, MIN_CROP_FRACTION, Point, Rect, Vec2}; #[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] pub struct CropGeometry { @@ -77,16 +77,25 @@ impl CropGeometry { let s = max_inscribed_scale(w, h, self.angle, r.width(), r.height()); let nr = Rect::from_center(c, r.width() * s, r.height() * s); let _ = best; - CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle } + let out = CropGeometry { rect: nr.scale(1.0 / w, 1.0 / h), angle: self.angle }; + if [out.rect.x0, out.rect.y0, out.rect.x1, out.rect.y1].iter().all(|v| v.is_finite()) { + return out; + } + // A rect too large for pixel maths (`inf * 0` above): the largest crop of its aspect that + // fits, else of the image's own. + let aspect = Some(self.rect.width() / self.rect.height() * (w / h)).filter(|a| a.is_finite() && *a > 0.0); + crop_fit_angle(w, h, self.angle, aspect) } /// This crop turned to `angle_deg` on a `w × h` image: kept as it is when it still fits inside - /// the rotated image, else the largest crop of its aspect that fits, centred (Lightroom's - /// "Constrain to image"). Every way of setting the angle goes through here (the rotate handle, - /// the Straighten slider and Angle field, `crop.straighten`, `develop.set` on `crop.angle`), so - /// they all give the same crop (issue #742). A crop that is the largest of its aspect at its - /// own angle (the full photo, or one an earlier turn shrank to fit) stays the largest at the new - /// angle, so it grows back as the angle returns towards 0° (issue #1388). + /// the rotated image, else shrunk about its own centre, keeping its aspect, until it fits + /// (Lightroom's "Constrain to image"). Every way of setting the angle goes through here (the + /// rotate handle, the Straighten slider and Angle field, `crop.straighten`, `develop.set` on + /// `crop.angle`), so they all give the same crop (issue #742). A crop that is the largest of its + /// aspect at its own angle (the full photo, or one an earlier turn shrank to fit) stays the + /// largest at the new angle, so it grows back as the angle returns towards 0° (issue #1388). + /// Any other crop never grows: a crop dragged in from a corner still touches two edges of the + /// photo, and turning it used to swap it for the largest crop of its aspect (issue #1794). pub fn with_angle(&self, angle_deg: f64, w: f64, h: f64) -> CropGeometry { let r = self.rect_px(w, h); // a degenerate rect has no aspect to keep: fall back to the image's own @@ -99,7 +108,45 @@ impl CropGeometry { if !is_largest && cand.is_within_image(w, h) { return cand; } - crop_fit_angle(w, h, angle_deg, aspect) + let fitted = crop_fit_angle(w, h, angle_deg, aspect); + if is_largest || aspect.is_none() { + return fitted; + } + // shrunk in place, as long as that leaves a crop the drag model would allow + let min_side = MIN_CROP_FRACTION * w.min(h); + if let Some(s) = cand.fit_scale_about_centre(w, h).filter(|s| r.width().min(r.height()) * s >= min_side) { + return CropGeometry { rect: Rect::from_center(r.center(), r.width() * s, r.height() * s).scale(1.0 / w, 1.0 / h), angle: angle_deg }; + } + // its centre is off the turned photo (a sliver in a corner): centred, at its own size at most + let f = fitted.rect_px(w, h); + let Some(s) = Some(r.width() / f.width()).filter(|s| s.is_finite() && *s < 1.0) else { + return fitted; + }; + CropGeometry { rect: Rect::from_center(f.center(), f.width() * s, f.height() * s).scale(1.0 / w, 1.0 / h), angle: angle_deg } + } + + /// The largest scale, at most 1, at which this crop shrunk about its own centre lies inside the + /// rotated image; `None` when its centre is outside it. + fn fit_scale_about_centre(&self, w: f64, h: f64) -> Option<f64> { + let r = self.rect_px(w, h); + let back = Affine::rotate_about(-self.angle.to_radians(), Point::new(w / 2.0, h / 2.0)); + let c = back.apply(r.center()); + if !(0.0..=w).contains(&c.x) || !(0.0..=h).contains(&c.y) { + return None; + } + // each corner, in the image frame, is c + s·d: keep both coordinates in 0..w and 0..h + let mut s: f64 = 1.0; + for corner in r.corners() { + let d = back.apply(corner) - c; + for (q, dq, max) in [(c.x, d.x, w), (c.y, d.y, h)] { + if dq > 0.0 { + s = s.min((max - q) / dq); + } else if dq < 0.0 { + s = s.min(q / -dq); + } + } + } + Some(s).filter(|s| s.is_finite() && *s > 0.0) } } @@ -195,6 +242,45 @@ mod tests { } } + #[test] + fn with_angle_shrinks_a_crop_against_the_edge_about_its_own_centre() { + // issue #1794: a crop dragged in from one corner keeps the opposite corner on the photo's, + // so it stops fitting at the first fraction of a degree; it used to become the largest crop + // of its aspect, nearly the whole photo + let (w, h) = (600.0, 400.0); + let c = CropGeometry { rect: Rect::new(0.0, 0.0, 0.6, 0.6), angle: 0.0 }; + let a = c.rect_px(w, h); + for ang in [-10.0, -1.0, 0.5, 3.0, 20.0] { + let t = c.with_angle(ang, w, h); + let b = t.rect_px(w, h); + assert!(t.is_within_image(w, h), "{ang}°"); + assert_eq!(t.angle, ang); + assert!(b.width() <= a.width() + 1e-9, "{ang}°: never larger than the crop made, {} vs {}", b.width(), a.width()); + assert!((a.aspect() - b.aspect()).abs() < 1e-9, "{ang}°: aspect kept"); + assert!(b.center().dist(a.center()) < 1e-9, "{ang}°: shrunk about its own centre"); + // as large as fits there: 1 % bigger about the same centre does not + let big = CropGeometry { rect: Rect::from_center(t.rect.center(), t.rect.width() * 1.01, t.rect.height() * 1.01), angle: ang }; + assert!(!big.is_within_image(w, h), "{ang}°: the largest about its centre"); + } + // a small turn is a small change, not a jump + let t = c.with_angle(0.5, w, h).rect_px(w, h); + assert!(t.width() > 0.97 * a.width(), "0.5° keeps nearly all of the crop: {} of {}", t.width(), a.width()); + } + + #[test] + fn with_angle_never_grows_a_crop_whose_centre_leaves_the_photo() { + // a sliver in the corner: at 45° its centre is outside the turned photo, so it can't shrink + // in place; it moves to the centre at its own size, not at the largest of its aspect + let (w, h) = (600.0, 400.0); + let c = CropGeometry { rect: Rect::new(0.0, 0.0, 0.05, 0.05), angle: 0.0 }; + let a = c.rect_px(w, h); + let t = c.with_angle(45.0, w, h); + let b = t.rect_px(w, h); + assert!(t.is_within_image(w, h)); + assert!(b.width() <= a.width() + 1e-9 && (a.aspect() - b.aspect()).abs() < 1e-9, "{b:?} from {a:?}"); + assert!(b.center().dist(Point::new(w / 2.0, h / 2.0)) < 1e-9, "centred: {b:?}"); + } + #[test] fn with_angle_survives_a_degenerate_crop() { // zero height: no aspect to keep (width / height = ∞) @@ -211,4 +297,16 @@ mod tests { let p = t.apply(Point::new(0.0, 0.0)); assert!((p.x - 25.0).abs() < 1e-9 && (p.y - 25.0).abs() < 1e-9); } + + #[test] + fn constrained_huge_rect_is_finite_and_inside() { + for rect in [Rect::new(0.0, 0.0, 1e308, 1e308), Rect::new(-1e308, -1e308, 1e308, 1e308), Rect::new(0.0, 0.0, 1e308, 0.5)] { + for angle in [0.0, 30.0] { + let c = CropGeometry { rect, angle }.constrained(400.0, 300.0); + let r = c.rect; + assert!([r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()), "{rect:?} {angle}: {r:?}"); + assert!(c.is_within_image(400.0, 300.0), "{rect:?} {angle}: {r:?}"); + } + } + } } diff --git a/crates/geom/src/lib.rs b/crates/geom/src/lib.rs index 4b8f623e2..14eaaf065 100644 --- a/crates/geom/src/lib.rs +++ b/crates/geom/src/lib.rs @@ -178,7 +178,7 @@ impl Rect { } /// Largest rectangle of aspect `a` (w/h) centred in `self`. pub fn fit_aspect(&self, a: f64) -> Rect { - if a <= 0.0 || self.is_empty() { + if !(a.is_finite() && a > 0.0) || self.is_empty() { return *self; } let (w, h) = (self.width(), self.height()); diff --git a/crates/gpu/Cargo.toml b/crates/gpu/Cargo.toml index dc348ee88..02edcefd1 100644 --- a/crates/gpu/Cargo.toml +++ b/crates/gpu/Cargo.toml @@ -14,6 +14,7 @@ lightcraft-geom = { workspace = true } lightcraft-color = { workspace = true } lightcraft-raster = { workspace = true } lightcraft-develop = { workspace = true } +lightcraft-faces = { workspace = true } lightcraft-pipeline = { workspace = true } log = { workspace = true } @@ -29,5 +30,7 @@ default = [] denoise = ["dep:lightcraft-denoise", "lightcraft-denoise/runtime"] [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } lightcraft-scenes = { workspace = true } lightcraft-raw = { workspace = true } diff --git a/crates/gpu/src/backend.rs b/crates/gpu/src/backend.rs index 57e47f22e..d970003e7 100644 --- a/crates/gpu/src/backend.rs +++ b/crates/gpu/src/backend.rs @@ -213,12 +213,12 @@ fn disarm(marker: &std::path::Path, active: &Mutex<u32>) { } /// Removes the marker on the way out, a panic included (the process survived). -struct Armed(Option<PathBuf>); +struct Armed<'a>(Option<PathBuf>, &'a Mutex<u32>); -impl Drop for Armed { +impl Drop for Armed<'_> { fn drop(&mut self) { if let Some(m) = &self.0 { - disarm(m, &ACTIVE); + disarm(m, self.1); } } } @@ -227,10 +227,16 @@ impl Drop for Armed { /// removing the init marker (if one is set). pub(crate) fn with_marker<T>(what: &str, f: impl FnOnce() -> T) -> T { let marker = MARKER.lock().unwrap_or_else(|e| e.into_inner()).clone(); + with_marker_on(marker, &ACTIVE, what, f) +} + +/// [`with_marker`] on an explicit marker path and scope counter (the tests use their own, so that +/// device-creating tests running in parallel cannot join their scopes). +fn with_marker_on<T>(marker: Option<PathBuf>, active: &Mutex<u32>, what: &str, f: impl FnOnce() -> T) -> T { if let Some(m) = &marker { - arm(m, &ACTIVE, what); + arm(m, active, what); } - let _armed = Armed(marker); + let _armed = Armed(marker, active); f() } @@ -352,9 +358,6 @@ mod tests { } use super::*; - /// The tests that set the process-wide marker take turns. - static GLOBAL: Mutex<()> = Mutex::new(()); - #[test] fn parses_backend_names() { assert_eq!(parse_backends("dx12"), Some(BackendChoice::Use(Backends::DX12))); @@ -421,13 +424,12 @@ mod tests { #[test] fn init_marker_is_written_during_creation_and_removed_after() { - let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("lc-gpu-marker-{}", std::process::id())); let m = dir.join("gpu-init.marker"); let _ = std::fs::remove_dir_all(&dir); - set_init_marker(Some(m.clone())); - let seen = with_init_marker(Backends::DX12, || m.exists()); - set_init_marker(None); + // a local scope counter: other tests create real devices through the process-wide one + let active = Mutex::new(0); + let seen = with_marker_on(Some(m.clone()), &active, "device creation", || m.exists()); assert!(seen, "the marker exists while the device is created"); assert!(!m.exists(), "and is removed afterwards"); // a marker left behind by a crashed process is reported once; reading it leaves it in place @@ -440,6 +442,20 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + #[test] + fn with_init_marker_writes_the_process_wide_marker() { + let dir = std::env::temp_dir().join(format!("lc-gpu-global-{}", std::process::id())); + let m = dir.join("gpu-init.marker"); + let _ = std::fs::remove_dir_all(&dir); + set_init_marker(Some(m.clone())); + let seen = with_init_marker(Backends::DX12, || m.exists()); + set_init_marker(None); + // not checked for removal: device-creating tests running in parallel may still hold the file + // (the test above checks removal on its own scope counter) + assert!(seen, "the marker set with set_init_marker exists while the device is created"); + let _ = std::fs::remove_dir_all(&dir); + } + /// The window's renderer fallback: forced, remembered, the platform chain, the failed one last. #[test] fn window_renderer_fallback_order() { @@ -505,13 +521,11 @@ mod tests { #[test] fn a_panic_inside_the_scope_still_removes_the_marker() { - let _turn = GLOBAL.lock().unwrap_or_else(|e| e.into_inner()); let dir = std::env::temp_dir().join(format!("lc-gpu-panic-{}", std::process::id())); let m = dir.join("gpu-init.marker"); let _ = std::fs::remove_dir_all(&dir); - set_init_marker(Some(m.clone())); - let r = std::panic::catch_unwind(|| with_marker("kernel compilation", || panic!("compile"))); - set_init_marker(None); + let active = Mutex::new(0); + let r = std::panic::catch_unwind(|| with_marker_on(Some(m.clone()), &active, "kernel compilation", || panic!("compile"))); assert!(r.is_err()); assert!(!m.exists(), "the process survived, so nothing is left to report"); let _ = std::fs::remove_dir_all(&dir); diff --git a/crates/gpu/src/faces.rs b/crates/gpu/src/faces.rs new file mode 100644 index 000000000..bee9929ef --- /dev/null +++ b/crates/gpu/src/faces.rs @@ -0,0 +1,1018 @@ +//! Face networks on the GPU: an [`lightcraft_faces::net::Accelerator`] that runs the interpreter's convolutions (most of +//! YuNet's, SFace's and AuraFace's time) with a WGSL kernel (`wgsl/faces_conv.wgsl`). Everything else in the graph +//! stays on the CPU interpreter, which is the reference: a net attaches this only after it agreed with the CPU on a +//! test input (`Net::attach_checked`), and drops it on the first error. +//! +//! The device is a separate one (like denoise's), so a face scan and the interactive renders never share a queue, and +//! it respects the same switches as GPU rendering (`LIGHTCRAFT_GPU`, `LIGHTCRAFT_GPU_BACKEND`, the GPU preference) +//! and the crash sentinel around device creation. A model's weights are uploaded once, on first use, and stay on the +//! device while the accelerator (one per loaded model) lives. Every failure is an `Err`, never a panic. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock, PoisonError}; +use std::time::Duration; + +use bytemuck::{Pod, Zeroable}; +use lightcraft_faces::net::program::{Arg, Binary, INPUT, Op, Program, Unary}; +use lightcraft_faces::net::{Accelerator, ConvJob}; +use wgpu::util::DeviceExt; + +/// Longest wait for one convolution before the device is given up on. +const TIMEOUT: Duration = Duration::from_secs(30); +/// Below this many multiply-adds a convolution stays on the CPU: the upload, dispatch and read-back cost more. +const MIN_MACS: usize = 1 << 20; +/// Workgroups a convolution should make at least, cutting its K into slices when its tiles are too few. +const TARGET_WORKGROUPS: usize = 256; +/// Weights one model may keep on the device (AuraFace's are about 250 MB). +const MAX_RESIDENT: u64 = 768 << 20; + +struct Dev { + device: wgpu::Device, + queue: wgpu::Queue, + info: wgpu::AdapterInfo, + limits: wgpu::Limits, + layout: wgpu::BindGroupLayout, + tiled: wgpu::ComputePipeline, + direct: wgpu::ComputePipeline, + reduce: wgpu::ComputePipeline, + ops: Ops, + /// Bound where a kernel has fewer operands than bindings. + dummy: wgpu::Buffer, +} + +/// The kernels of `wgsl/faces_ops.wgsl`. +struct Ops { + unary: wgpu::ComputePipeline, + affine: wgpu::ComputePipeline, + prelu: wgpu::ComputePipeline, + binary: wgpu::ComputePipeline, + transpose: wgpu::ComputePipeline, + maxpool: wgpu::ComputePipeline, + resize: wgpu::ComputePipeline, + gavg: wgpu::ComputePipeline, + gemv: wgpu::ComputePipeline, +} + +static DEV: OnceLock<Result<Dev, String>> = OnceLock::new(); +static BROKEN: AtomicBool = AtomicBool::new(false); +static BROKEN_WHY: Mutex<Option<String>> = Mutex::new(None); + +fn mark_broken(why: &str) { + let mut w = BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner); + if w.is_none() { + *w = Some(why.to_string()); + } + BROKEN.store(true, Ordering::Relaxed); +} + +/// Why the GPU stopped being used for faces (a device error), if it did. +pub fn broken_reason() -> Option<String> { + BROKEN.load(Ordering::Relaxed).then(|| BROKEN_WHY.lock().unwrap_or_else(PoisonError::into_inner).clone().unwrap_or_else(|| "device error".into())) +} + +fn dev() -> Result<&'static Dev, String> { + if let Some(why) = broken_reason() { + return Err(format!("stopped after a GPU failure: {why}")); + } + if let Some(why) = crate::switched_off() { + return Err(why); + } + let _work = match DEV.get() { + Some(_) => None, + None => Some(crate::exit::enter().ok_or("the app is closing")?), + }; + DEV.get_or_init(create_device).as_ref().map_err(|e| e.clone()) +} + +fn create_device() -> Result<Dev, String> { + let Some(backends) = crate::backend::compute_backends() else { return Err("disabled by LIGHTCRAFT_GPU_BACKEND=off".into()) }; + crate::backend::with_init_marker(backends, || { + std::panic::catch_unwind(|| make_device(backends)).unwrap_or_else(|_| Err("device creation panicked".into())) + }) +} + +fn entry(binding: u32, ty: wgpu::BufferBindingType) -> wgpu::BindGroupLayoutEntry { + wgpu::BindGroupLayoutEntry { + binding, + visibility: wgpu::ShaderStages::COMPUTE, + ty: wgpu::BindingType::Buffer { ty, has_dynamic_offset: false, min_binding_size: None }, + count: None, + } +} + +fn make_device(backends: wgpu::Backends) -> Result<Dev, String> { + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = backends; + desc.backend_options = crate::backend::backend_options(); + desc.flags = crate::backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let wanted = std::env::var("LIGHTCRAFT_GPU_ADAPTER").ok().map(|s| s.trim().to_lowercase()).filter(|s| !s.is_empty()); + let adapter = match &wanted { + Some(w) => pollster::block_on(instance.enumerate_adapters(backends)) + .into_iter() + .find(|a| { + let i = a.get_info(); + i.device_type != wgpu::DeviceType::Cpu && i.name.to_lowercase().contains(w.as_str()) + }) + .ok_or_else(|| format!("no GPU adapter named like `{w}`"))?, + None => pollster::block_on( + instance.request_adapter(&wgpu::RequestAdapterOptions { power_preference: wgpu::PowerPreference::HighPerformance, ..Default::default() }), + ) + .map_err(|e| format!("no GPU adapter among {backends:?} ({e})"))?, + }; + let info = adapter.get_info(); + if info.device_type == wgpu::DeviceType::Cpu { + return Err(format!("software adapter ({}) skipped: the CPU is faster", info.name)); + } + let limits = adapter.limits(); + if limits.max_storage_buffers_per_shader_stage < 4 + || limits.max_compute_invocations_per_workgroup < 256 + || limits.max_compute_workgroup_size_x < 256 + || limits.max_compute_workgroup_storage_size < 8 * 1024 + { + return Err(format!("{} is too limited for the face kernels", info.name)); + } + let (device, queue) = pollster::block_on(adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("lightcraft-faces"), + required_limits: limits.clone(), + ..Default::default() + })) + .map_err(|e| format!("{}: device creation failed ({e})", info.name))?; + device.on_uncaptured_error(Arc::new(|e| { + log::error!("gpu (faces): {e}"); + mark_broken(&format!("device error: {e}")); + })); + device.set_device_lost_callback(|reason, msg| { + log::error!("gpu (faces): device lost ({reason:?}): {msg}"); + mark_broken(&format!("device lost ({reason:?}): {msg}")); + }); + use wgpu::BufferBindingType::{Storage, Uniform}; + let layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("faces conv"), + entries: &[ + entry(0, Uniform), + entry(1, Storage { read_only: true }), + entry(2, Storage { read_only: true }), + entry(3, Storage { read_only: true }), + entry(4, Storage { read_only: false }), + ], + }); + let module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces conv"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_conv.wgsl").into()), + }); + let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: None, bind_group_layouts: &[Some(&layout)], immediate_size: 0 }); + let ops_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("faces ops"), + source: wgpu::ShaderSource::Wgsl(include_str!("wgsl/faces_ops.wgsl").into()), + }); + let pipe_in = |module: &wgpu::ShaderModule, entry: &str| { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&pl), + module, + entry_point: Some(entry), + // both kernels write all of their workgroup memory before reading it + compilation_options: wgpu::PipelineCompilationOptions { zero_initialize_workgroup_memory: false, ..Default::default() }, + cache: None, + }) + }; + let pipe = |entry: &str| pipe_in(&module, entry); + let op = |entry: &str| pipe_in(&ops_module, entry); + let (tiled, direct, reduce) = (pipe("tiled"), pipe("direct"), pipe("reduce")); + let ops = Ops { + unary: op("unary"), + affine: op("affine"), + prelu: op("prelu"), + binary: op("binary"), + transpose: op("transpose"), + maxpool: op("maxpool"), + resize: op("resize"), + gavg: op("gavg"), + gemv: op("gemv"), + }; + let dummy = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces unused"), + size: 16, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + log::info!("gpu (faces): {} ({:?})", info.name, info.backend); + Ok(Dev { device, queue, info, limits, layout, tiled, direct, reduce, ops, dummy }) +} + +/// The adapter face scans would use ("AMD Radeon RX 7900 XTX (Vulkan)"), or why they cannot use one. Creates the +/// device on first use. +pub fn adapter() -> Result<String, String> { + dev().map(|d| format!("{} ({:?})", d.info.name, d.info.backend)) +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct Params { + m: u32, + n: u32, + k: u32, + cg: u32, + h: u32, + w: u32, + ow: u32, + kh: u32, + kw: u32, + sh: u32, + sw: u32, + pt: u32, + pl: u32, + bias: u32, + total: u32, + row: u32, + splits: u32, + kchunk: u32, + _a: u32, + _b: u32, +} + +/// A layer's weights and bias on the device. +struct Resident { + weights: wgpu::Buffer, + bias: wgpu::Buffer, +} + +/// The GPU accelerator for one loaded face model: its weights stay on the device while it lives. +pub struct FaceGpu { + dev: &'static Dev, + layers: Mutex<HashMap<usize, Arc<Resident>>>, + consts: Mutex<HashMap<usize, wgpu::Buffer>>, + resident: AtomicU64, +} + +/// A GPU accelerator for one face model, or why there is none (GPU off, missing or broken). +pub fn accelerator() -> Result<Arc<FaceGpu>, String> { + Ok(Arc::new(FaceGpu { dev: dev()?, layers: Mutex::new(HashMap::new()), consts: Mutex::new(HashMap::new()), resident: AtomicU64::new(0) })) +} + +fn u32_of(v: usize) -> Result<u32, String> { + u32::try_from(v).ok().filter(|v| *v < (1 << 31)).ok_or_else(|| "a layer too large for the GPU kernel".to_string()) +} + +/// How much of the GPU face work may take: at most `concurrent` runs at once, and busy for at most `share` of the time +/// (after a run of `t`, the next one waits `t · (1 − share) / share`). Set by the background scan's pace. +struct Pace { + concurrent: usize, + share: f32, + running: usize, +} + +static PACE: Mutex<Pace> = Mutex::new(Pace { concurrent: usize::MAX, share: 1.0, running: 0 }); +static PACE_FREED: std::sync::Condvar = std::sync::Condvar::new(); + +/// Limit the GPU work face models do: `concurrent` runs at once (at least one) and `share` of the time (0.05 to 1). +/// The background face scan sets it from its pace; anything else that runs a face model shares the same limit. +pub fn set_pace(concurrent: usize, share: f32) { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.concurrent = concurrent.max(1); + p.share = if share.is_finite() { share.clamp(0.05, 1.0) } else { 1.0 }; + PACE_FREED.notify_all(); +} + +/// A turn on the GPU under the pace; waits for one. Dropping it gives it back. +struct Turn { + share: f32, + started: std::time::Instant, +} + +impl Turn { + fn take() -> Turn { + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + while p.running >= p.concurrent { + p = PACE_FREED.wait_timeout(p, Duration::from_millis(200)).map(|(g, _)| g).unwrap_or_else(|e| e.into_inner().0); + } + p.running += 1; + Turn { share: p.share, started: std::time::Instant::now() } + } +} + +impl Drop for Turn { + fn drop(&mut self) { + // rest while still holding the turn, so the GPU stays idle for the rest of the period + if self.share < 1.0 { + let rest = self.started.elapsed().mul_f32((1.0 - self.share) / self.share).min(Duration::from_secs(2)); + std::thread::sleep(rest); + } + let mut p = PACE.lock().unwrap_or_else(PoisonError::into_inner); + p.running = p.running.saturating_sub(1); + PACE_FREED.notify_one(); + } +} + +/// One dispatch to record: a pipeline, its parameters, its four buffers (three read, the last written) and its +/// workgroup counts. +struct Dispatch { + pipeline: &'static wgpu::ComputePipeline, + params: Vec<u8>, + buffers: [wgpu::Buffer; 4], + groups: [u32; 3], +} + +/// Workgroups for `count` items of 256 (or `count` workgroups when `per_group`): x, y and the row length the kernel +/// reads back as `q(1)`. +fn flat(count: usize, limit: usize) -> Option<[u32; 3]> { + let blocks = count.max(1); + let row = blocks.clamp(1, limit.min(65_535)); + let rows = blocks.div_ceil(row); + (rows <= limit).then_some([u32::try_from(row).ok()?, u32::try_from(rows).ok()?, u32::try_from(row).ok()?]) +} + +fn params24(values: &[u32]) -> Vec<u8> { + let mut all = [0u32; 24]; + for (to, v) in all.iter_mut().zip(values) { + *to = *v; + } + bytemuck::cast_slice(&all).to_vec() +} + +impl FaceGpu { + /// Bytes of weights this model keeps on the device. + pub fn resident_bytes(&self) -> u64 { + self.resident.load(Ordering::Relaxed) + } + + fn upload(&self, label: &str, data: &[f32]) -> wgpu::Buffer { + let zero = [0f32; 4]; + self.dev.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some(label), + contents: bytemuck::cast_slice(if data.is_empty() { &zero } else { data }), + usage: wgpu::BufferUsages::STORAGE, + }) + } + + /// Whether `bytes` more may stay on the device; counts them if so. + fn keep(&self, bytes: u64) -> bool { + if self.resident_bytes().saturating_add(bytes) > MAX_RESIDENT { + return false; + } + self.resident.fetch_add(bytes, Ordering::Relaxed); + true + } + + /// A convolution's weights and bias: kept on the device while the budget lasts, uploaded for this run past it. + fn resident(&self, job: &ConvJob) -> Arc<Resident> { + let mut layers = self.layers.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(r) = layers.get(&job.layer) { + return r.clone(); + } + let r = Arc::new(Resident { weights: self.upload("faces weights", job.weights), bias: self.upload("faces bias", job.bias.unwrap_or(&[])) }); + if self.keep((job.weights.len() as u64 + job.cout as u64) * 4) { + layers.insert(job.layer, r.clone()); + } + r + } + + /// A program's constant, kept on the device like the weights. + fn constant(&self, id: usize, data: &[f32]) -> wgpu::Buffer { + let mut consts = self.consts.lock().unwrap_or_else(PoisonError::into_inner); + if let Some(b) = consts.get(&id) { + return b.clone(); + } + let b = self.upload("faces constant", data); + if self.keep(data.len() as u64 * 4) { + consts.insert(id, b.clone()); + } + b + } + + fn largest_binding(&self) -> u64 { + self.dev.limits.max_storage_buffer_binding_size.min(self.dev.limits.max_buffer_size) + } + + /// The dispatches of one convolution reading `input` and writing `output`, or `None` when it exceeds the device. + fn conv_dispatches(&self, job: &ConvJob, input: &wgpu::Buffer, output: &wgpu::Buffer) -> Result<Option<Vec<Dispatch>>, String> { + let d = self.dev; + let groups = job.groups.max(1); + let (m, cg) = (job.cout / groups, job.cin / groups); + let n = job.oh.checked_mul(job.ow).ok_or("a layer too large")?; + let k = cg.checked_mul(job.kh).and_then(|v| v.checked_mul(job.kw)).ok_or("a layer too large")?; + let total = job.cout.checked_mul(n).ok_or("a layer too large")?; + let direct = m < 8; + // Too few 64 x 64 tiles to keep the GPU busy (a 7 x 7 map of 512 channels makes 8): cut K into slices that + // run side by side, and sum them in a second pass. + let tiles = n.div_ceil(64) * m.div_ceil(64) * groups; + let splits = if direct { 1 } else { (TARGET_WORKGROUPS / tiles.max(1)).clamp(1, (k / 128).max(1)) }; + let kchunk = k.div_ceil(splits).div_ceil(16) * 16; + let splits = k.div_ceil(kchunk.max(1)).max(1); + let partial = total.checked_mul(splits).ok_or("a layer too large")?; + let cin_len = job.cin.checked_mul(job.h).and_then(|v| v.checked_mul(job.w)).ok_or("a layer too large")?; + let largest = [cin_len, job.weights.len(), partial].into_iter().max().unwrap_or(0) as u64 * 4; + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + let Some(sum) = flat(total.div_ceil(256), limit) else { return Ok(None) }; + let (wx, wy, wz) = if direct { (sum[0] as usize, sum[1] as usize, 1) } else { (n.div_ceil(64), m.div_ceil(64), groups * splits) }; + if largest > self.largest_binding() || [wx, wy, wz].into_iter().any(|v| v > limit) { + return Ok(None); + } + let res = self.resident(job); + let params = Params { + m: u32_of(m)?, + n: u32_of(n)?, + k: u32_of(k)?, + cg: u32_of(cg)?, + h: u32_of(job.h)?, + w: u32_of(job.w)?, + ow: u32_of(job.ow)?, + kh: u32_of(job.kh)?, + kw: u32_of(job.kw)?, + sh: u32_of(job.sh)?, + sw: u32_of(job.sw)?, + pt: u32_of(job.pt)?, + pl: u32_of(job.pl)?, + bias: u32::from(job.bias.is_some()), + total: u32_of(total)?, + row: sum[2], + splits: u32_of(splits)?, + kchunk: u32_of(kchunk)?, + _a: 0, + _b: 0, + }; + let params = bytemuck::bytes_of(&params).to_vec(); + let main = |out: &wgpu::Buffer| Dispatch { + pipeline: if direct { &d.direct } else { &d.tiled }, + params: params.clone(), + buffers: [input.clone(), res.weights.clone(), res.bias.clone(), out.clone()], + groups: [wx as u32, wy as u32, wz as u32], + }; + if splits == 1 { + return Ok(Some(vec![main(output)])); + } + let partials = d.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces partial sums"), + size: partial as u64 * 4, + usage: wgpu::BufferUsages::STORAGE, + mapped_at_creation: false, + }); + Ok(Some(vec![ + main(&partials), + Dispatch { + pipeline: &d.reduce, + params, + buffers: [partials, res.weights.clone(), res.bias.clone(), output.clone()], + groups: [sum[0], sum[1], 1], + }, + ])) + } + + /// Record `list` into `enc` as one compute pass, with all their parameters in one uniform buffer. + fn record(&self, enc: &mut wgpu::CommandEncoder, list: &[Dispatch]) -> Result<(), String> { + let d = self.dev; + if list.is_empty() { + return Ok(()); + } + let align = (d.limits.min_uniform_buffer_offset_alignment as usize).max(256); + let mut blob = vec![0u8; list.len() * align]; + for (i, x) in list.iter().enumerate() { + blob.get_mut(i * align..i * align + x.params.len()).ok_or("parameters too large")?.copy_from_slice(&x.params); + } + let uniform = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces params"), + contents: &blob, + usage: wgpu::BufferUsages::UNIFORM, + }); + let binds = list + .iter() + .enumerate() + .map(|(i, x)| { + let [a, b, c, out] = &x.buffers; + d.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &d.layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &uniform, + offset: (i * align) as u64, + size: std::num::NonZeroU64::new(x.params.len() as u64), + }), + }, + wgpu::BindGroupEntry { binding: 1, resource: a.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 2, resource: b.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 3, resource: c.as_entire_binding() }, + wgpu::BindGroupEntry { binding: 4, resource: out.as_entire_binding() }, + ], + }) + }) + .collect::<Vec<_>>(); + let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("faces"), timestamp_writes: None }); + for (x, bind) in list.iter().zip(&binds) { + pass.set_pipeline(x.pipeline); + pass.set_bind_group(0, bind, &[]); + let [gx, gy, gz] = x.groups; + pass.dispatch_workgroups(gx, gy, gz); + } + Ok(()) + } + + /// Submit `enc`, which ends by copying into `staging` (`bytes` long), wait for it and read `staging` back. + fn finish(&self, enc: wgpu::CommandEncoder, staging: &wgpu::Buffer, scopes: [wgpu::ErrorScopeGuard; 2]) -> Result<Vec<f32>, String> { + let d = self.dev; + let index = d.queue.submit([enc.finish()]); + let slice = staging.slice(..); + let (tx, rx) = std::sync::mpsc::channel(); + slice.map_async(wgpu::MapMode::Read, move |r| { + let _ = tx.send(r); + }); + let waited = d.device.poll(wgpu::PollType::Wait { submission_index: Some(index), timeout: Some(TIMEOUT) }); + let [oom, validation] = scopes; + let (validation, oom) = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + if let Some(e) = oom { + return Err(format!("the GPU ran out of memory: {e}")); + } + if let Some(e) = validation { + mark_broken(&format!("device error: {e}")); + return Err(format!("the GPU reported an error: {e}")); + } + if let Err(e) = waited { + mark_broken(&format!("the GPU did not finish a face model: {e}")); + return Err(format!("the GPU did not finish: {e}")); + } + match rx.recv_timeout(Duration::from_secs(10)) { + Ok(Ok(())) => {} + Ok(Err(e)) => return Err(format!("reading the result back failed: {e}")), + Err(e) => return Err(format!("reading the result back failed: {e}")), + } + let out = { + let data = slice.get_mapped_range().map_err(|e| format!("reading the result back failed: {e:?}"))?; + data.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect::<Vec<f32>>() + }; + staging.unmap(); + Ok(out) + } + + fn scopes(&self) -> [wgpu::ErrorScopeGuard; 2] { + [self.dev.device.push_error_scope(wgpu::ErrorFilter::OutOfMemory), self.dev.device.push_error_scope(wgpu::ErrorFilter::Validation)] + } + + fn staging(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces read-back"), + size: bytes.max(4), + usage: wgpu::BufferUsages::MAP_READ | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }) + } + + fn scratch(&self, bytes: u64) -> wgpu::Buffer { + self.dev.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("faces values"), + size: bytes.max(4), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + mapped_at_creation: false, + }) + } + + /// One convolution, uploaded and read back on its own. + fn run(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + let total = job.cout.checked_mul(job.oh).and_then(|v| v.checked_mul(job.ow)).ok_or("a layer too large")?; + let bytes = total as u64 * 4; + if bytes > self.largest_binding() || job.input.len() as u64 * 4 > self.largest_binding() { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let d = self.dev; + let scopes = self.scopes(); + let input = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(job.input), + usage: wgpu::BufferUsages::STORAGE, + }); + let output = self.scratch(bytes); + let Some(list) = self.conv_dispatches(job, &input, &output)? else { + // leave the scopes balanced + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + }; + let staging = self.staging(bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: None }); + self.record(&mut enc, &list)?; + enc.copy_buffer_to_buffer(&output, 0, &staging, 0, bytes); + self.finish(enc, &staging, scopes).map(Some) + } + + /// The dispatch of one non-convolution step. + fn op_dispatch(&self, op: &Op, out_len: usize, args: [wgpu::Buffer; 3], out: &wgpu::Buffer) -> Result<Option<Dispatch>, String> { + let d = self.dev; + let limit = d.limits.max_compute_workgroups_per_dimension as usize; + let u = u32_of; + let n = u(out_len)?; + let per_value = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option<Dispatch> { + let groups = flat(out_len.div_ceil(256), limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let per_group = |pipeline: &'static wgpu::ComputePipeline, rest: &[u32]| -> Option<Dispatch> { + let groups = flat(out_len, limit)?; + let mut all = vec![n, groups[2]]; + all.extend_from_slice(rest); + Some(Dispatch { + pipeline, + params: params24(&all), + buffers: [args[0].clone(), args[1].clone(), args[2].clone(), out.clone()], + groups: [groups[0], groups[1], 1], + }) + }; + let four = |v: &[usize; 4]| -> Result<[u32; 4], String> { Ok([u(v[0])?, u(v[1])?, u(v[2])?, u(v[3])?]) }; + let k = &d.ops; + Ok(match op { + Op::Conv(_) => None, + Op::Unary(f) => per_value(&k.unary, &[u32::from(*f == Unary::Sigmoid)]), + Op::ChannelAffine { channels, plane } => per_value(&k.affine, &[u(*plane)?, u(*channels)?]), + Op::ChannelPrelu { channels, plane } => per_value(&k.prelu, &[u(*plane)?, u(*channels)?]), + Op::Binary { op, dims, a, b } => { + let kind = match op { + Binary::Add => 0, + Binary::Sub => 1, + Binary::Mul => 2, + }; + let mut rest = vec![kind]; + rest.extend(four(dims)?); + rest.extend(four(a)?); + rest.extend(four(b)?); + per_value(&k.binary, &rest) + } + Op::Transpose { dims, strides } => { + let mut rest = vec![0]; + rest.extend(four(dims)?); + rest.extend(four(strides)?); + per_value(&k.transpose, &rest) + } + Op::MaxPool { h, w, kh, kw, sh, sw, oh, ow, .. } => { + per_value(&k.maxpool, &[u(*h)?, u(*w)?, u(*kh)?, u(*kw)?, u(*sh)?, u(*sw)?, u(*oh)?, u(*ow)?]) + } + Op::Resize { h, w, sh, sw, .. } => per_value(&k.resize, &[u(*h)?, u(*w)?, u(*sh)?, u(*sw)?]), + Op::GlobalAverage { plane, .. } => per_group(&k.gavg, &[u(*plane)?]), + Op::Gemv { k: kk, n: nn, trans_b, alpha } => per_group(&k.gemv, &[u(*kk)?, u(*nn)?, u32::from(*trans_b), alpha.to_bits()]), + }) + } + + /// A whole program for each input, every value staying on the device; one submission and one read-back. + fn program(&self, prog: &Program, inputs: &[&[f32]]) -> Result<Option<Vec<Vec<Vec<f32>>>>, String> { + let d = self.dev; + let largest = self.largest_binding(); + if prog.lens.iter().any(|l| *l as u64 * 4 > largest) || inputs.iter().any(|i| Some(&i.len()) != prog.lens.get(INPUT)) { + return Ok(None); + } + let out_bytes: u64 = prog.outputs.iter().map(|(_, v, _)| prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4).sum(); + let total_bytes = out_bytes.checked_mul(inputs.len() as u64).ok_or("too many faces at once")?; + if total_bytes > d.limits.max_buffer_size { + return Ok(None); + } + let Some(_work) = crate::exit::enter() else { return Err("the app is closing".into()) }; + let _turn = Turn::take(); + let scopes = self.scopes(); + let staging = self.staging(total_bytes); + let mut enc = d.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("faces program") }); + // buffers nothing reads any more, reused for later values (best fit) + let mut pool: Vec<(u64, wgpu::Buffer)> = Vec::new(); + let take = |pool: &mut Vec<(u64, wgpu::Buffer)>, bytes: u64| -> wgpu::Buffer { + let best = pool + .iter() + .enumerate() + .filter(|(_, (size, _))| *size >= bytes && *size <= bytes.saturating_mul(2)) + .min_by_key(|(_, (size, _))| *size) + .map(|(i, _)| i); + match best { + Some(i) => pool.swap_remove(i).1, + None => self.scratch(bytes), + } + }; + let mut at = 0u64; + let mut fits = true; + for input in inputs { + let mut values: Vec<Option<(u64, wgpu::Buffer)>> = vec![None; prog.lens.len()]; + // a fresh buffer: queued writes land before any of the encoder's work, so a pooled one could still be in use + let first = d.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("faces input"), + contents: bytemuck::cast_slice(input), + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, + }); + if let Some(v) = values.get_mut(INPUT) { + *v = Some((input.len() as u64 * 4, first)); + } + let mut list = Vec::new(); + for step in &prog.steps { + let len = prog.lens.get(step.out).copied().ok_or("a step writes a value the program does not have")?; + let out = take(&mut pool, len as u64 * 4); + let mut args = Vec::with_capacity(3); + for a in &step.args { + args.push(match a { + Arg::Value(v) => { + values.get(*v).and_then(|v| v.as_ref()).map(|(_, b)| b.clone()).ok_or("a step reads a value before it is made")? + } + Arg::Const { id, data } => self.constant(*id, data), + }); + } + let dispatches = match &step.op { + Op::Conv(job) => { + let input = args.first().ok_or("a convolution without input")?; + self.conv_dispatches(job, input, &out)? + } + op => { + let arg = |i: usize| args.get(i).cloned().unwrap_or_else(|| d.dummy.clone()); + self.op_dispatch(op, len, [arg(0), arg(1), arg(2)], &out)?.map(|x| vec![x]) + } + }; + let Some(dispatches) = dispatches else { + fits = false; + break; + }; + list.extend(dispatches); + if let Some(v) = values.get_mut(step.out) { + *v = Some((len as u64 * 4, out)); + } + for f in &step.free { + if let Some(b) = values.get_mut(*f).and_then(Option::take) { + pool.push(b); + } + } + } + if !fits { + break; + } + self.record(&mut enc, &list)?; + for (_, v, _) in &prog.outputs { + let (bytes, buffer) = values.get(*v).and_then(|v| v.as_ref()).ok_or("an output was never made")?; + let len = prog.lens.get(*v).copied().unwrap_or(0) as u64 * 4; + enc.copy_buffer_to_buffer(buffer, 0, &staging, at, len.min(*bytes)); + at += len; + } + pool.extend(values.into_iter().flatten()); + } + if !fits { + let [oom, validation] = scopes; + let _ = (pollster::block_on(validation.pop()), pollster::block_on(oom.pop())); + return Ok(None); + } + let flat_out = self.finish(enc, &staging, scopes)?; + let mut rest = flat_out.as_slice(); + let mut per_input = Vec::with_capacity(inputs.len()); + for _ in inputs { + let mut outs = Vec::with_capacity(prog.outputs.len()); + for (_, v, _) in &prog.outputs { + let len = prog.lens.get(*v).copied().unwrap_or(0); + let (this, next) = rest.split_at_checked(len).ok_or("the read-back is short")?; + outs.push(this.to_vec()); + rest = next; + } + per_input.push(outs); + } + Ok(Some(per_input)) + } +} + +/// Run `f`, turning a panic into a broken device. +fn guarded<T>(f: impl FnOnce() -> Result<T, String>) -> Result<T, String> { + std::panic::catch_unwind(std::panic::AssertUnwindSafe(f)).unwrap_or_else(|_| { + mark_broken("a face model on the GPU panicked"); + Err("the GPU runner gave up".into()) + }) +} + +impl Accelerator for FaceGpu { + fn device(&self) -> String { + format!("{} ({:?})", self.dev.info.name, self.dev.info.backend) + } + + fn conv(&self, job: &ConvJob) -> Result<Option<Vec<f32>>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || job.macs() < MIN_MACS { + return Ok(None); + } + guarded(|| self.run(job)) + } + + fn run_program(&self, program: &Program, inputs: &[&[f32]]) -> Result<Option<Vec<Vec<Vec<f32>>>>, String> { + if let Some(why) = broken_reason() { + return Err(format!("the GPU is not used any more: {why}")); + } + if crate::switched_off().is_some() || PER_CONV.load(Ordering::Relaxed) { + return Ok(None); + } + guarded(|| self.program(program, inputs)) + } +} + +/// Tests and benchmarks only: run convolutions one by one (the first step's way) instead of whole programs. +static PER_CONV: AtomicBool = AtomicBool::new(false); + +#[cfg(test)] +mod tests { + use super::*; + + fn gpu() -> Option<Arc<FaceGpu>> { + match accelerator() { + Ok(a) => Some(a), + Err(e) => { + eprintln!("no GPU for the face kernels, skipping: {e}"); + None + } + } + } + + /// The plain definition of a convolution, to check the kernels against. + fn reference(j: &ConvJob) -> Vec<f32> { + let (m, cg) = (j.cout / j.groups, j.cin / j.groups); + let mut out = vec![0f32; j.cout * j.oh * j.ow]; + for co in 0..j.cout { + let g = co / m; + for oy in 0..j.oh { + for ox in 0..j.ow { + let mut v = j.bias.map_or(0.0, |b| b[co]); + for ci in 0..cg { + for ky in 0..j.kh { + for kx in 0..j.kw { + let (iy, ix) = ((oy * j.sh + ky) as isize - j.pt as isize, (ox * j.sw + kx) as isize - j.pl as isize); + if iy >= 0 && ix >= 0 && (iy as usize) < j.h && (ix as usize) < j.w { + v += j.weights[((co * cg + ci) * j.kh + ky) * j.kw + kx] + * j.input[((g * cg + ci) * j.h + iy as usize) * j.w + ix as usize]; + } + } + } + } + out[(co * j.oh + oy) * j.ow + ox] = v; + } + } + } + out + } + + #[test] + fn the_gpu_convolution_gives_what_the_definition_gives() { + let Some(gpu) = gpu() else { return }; + // (cin, cout, groups, k, stride, pad, h, w): dense, strided, depthwise, grouped, pointwise, odd sizes + for (layer, (cin, cout, groups, k, s, p, h, w)) in [ + (3, 16, 1, 3, 2, 1, 41, 37), + (64, 64, 1, 3, 1, 1, 28, 28), + (32, 32, 32, 3, 1, 1, 30, 31), + (16, 32, 4, 3, 2, 1, 33, 20), + (48, 72, 1, 1, 1, 0, 50, 45), + (8, 130, 1, 5, 1, 2, 23, 19), + ] + .into_iter() + .enumerate() + { + let cg = cin / groups; + let weights: Vec<f32> = (0..cout * cg * k * k).map(|i| ((i * 37 % 23) as f32 - 11.0) * 0.03).collect(); + let bias: Vec<f32> = (0..cout).map(|i| i as f32 * 0.01 - 0.3).collect(); + let input: Vec<f32> = (0..cin * h * w).map(|i| ((i * 13 % 29) as f32 - 14.0) * 0.07).collect(); + let (oh, ow) = ((h + 2 * p - k) / s + 1, (w + 2 * p - k) / s + 1); + let job = ConvJob { + layer, + cin, + h, + w, + cout, + groups, + kh: k, + kw: k, + sh: s, + sw: s, + pt: p, + pl: p, + oh, + ow, + weights: &weights, + bias: Some(&bias), + input: &input, + }; + let got = gpu.run(&job).unwrap().unwrap(); + let want = reference(&job); + let worst = got.iter().zip(&want).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-3, "case {layer}: off by {worst}"); + } + assert!(gpu.resident_bytes() > 0, "the weights stay on the device"); + } + + #[test] + fn every_operator_on_the_gpu_gives_what_the_cpu_gives() { + let Some(gpu) = gpu() else { return }; + let net = lightcraft_faces::net::Net::new(lightcraft_faces::synthetic::every_operator_graph()).unwrap(); + let inputs: Vec<lightcraft_faces::net::Tensor> = (0..3) + .map(|s| { + lightcraft_faces::net::Tensor::new(vec![1, 4, 8, 8], (0..256).map(|i| (((i * 31 + s * 7) % 37) as f32 - 18.0) * 0.05).collect()) + .unwrap() + }) + .collect(); + let cpu: Vec<_> = inputs.iter().map(|x| net.run(x.clone()).unwrap()).collect(); + let program = net.program(&[1, 4, 8, 8]).unwrap().unwrap(); + let slices: Vec<&[f32]> = inputs.iter().map(|t| t.data.as_slice()).collect(); + let got = gpu.program(&program, &slices).unwrap().unwrap(); + for (want, got) in cpu.iter().zip(&got) { + for ((name, want), got) in want.iter().zip(got) { + let worst = want.data.iter().zip(got).fold(0f32, |m, (a, b)| m.max((a - b).abs())); + assert!(worst < 1e-4, "{name}: off by {worst}"); + } + } + } + + /// The real models on the GPU agree with the CPU interpreter: `LC_FACE_MODELS=<folder>` holding + /// `face_detection_yunet_2023mar.onnx`, `face_recognition_sface_2021dec.onnx` and (optionally) `glintr100.onnx`. + #[test] + fn real_face_models_on_the_gpu_agree_with_the_cpu() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { + eprintln!("LC_FACE_MODELS is not set: skipping"); + return; + }; + if gpu().is_none() { + return; + } + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (900, 700); + let rgb: Vec<u8> = (0..w * h * 3).map(|i| ((i / 3 % w) as f32 * 0.25 + (i / 3 / w) as f32 * 0.3 + (i * 7919 % 41) as f32) as u8).collect(); + let opts = lightcraft_faces::yunet::Options { score: 0.0, ..Default::default() }; + let cpu = det.detect(&rgb, w, h, &opts).unwrap(); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = det.detect(&rgb, w, h, &opts).unwrap(); + assert!(det.net().accelerator().is_some()); + assert_eq!(cpu.len(), fast.len()); + for (a, b) in cpu.iter().zip(&fast) { + assert!((a.score - b.score).abs() < 1e-3, "{} vs {}", a.score, b.score); + } + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let path = dir.join(file); + if !path.exists() { + continue; + } + let e = lightcraft_faces::runtime::Embedder::load(&path, &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let face: Vec<u8> = (0..ew * eh * 3).map(|i| ((i * 13) % 251) as u8).collect(); + let cpu = e.embed(&face).unwrap(); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let fast = e.embed(&face).unwrap(); + let cosine: f32 = cpu.iter().zip(&fast).map(|(a, b)| a * b).sum(); + assert!(cosine > 0.9999, "{file}: cosine {cosine}"); + } + } + + /// Timings, CPU against the GPU one convolution at a time and as whole programs (one face, and a batch of 16): + /// `LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests::timings -- --ignored --nocapture`. + #[test] + #[ignore = "a benchmark: needs LC_FACE_MODELS and a GPU"] + fn timings() { + let Some(dir) = std::env::var_os("LC_FACE_MODELS").map(std::path::PathBuf::from) else { return }; + let Some(_) = gpu() else { return }; + let time = |runs: usize, f: &mut dyn FnMut()| { + f(); + let t = std::time::Instant::now(); + for _ in 0..runs { + f(); + } + t.elapsed().as_secs_f64() * 1000.0 / runs as f64 + }; + let det = lightcraft_faces::yunet::Detector::new(&std::fs::read(dir.join("face_detection_yunet_2023mar.onnx")).unwrap()).unwrap(); + let (w, h) = (1024, 768); + let rgb: Vec<u8> = (0..w * h * 3).map(|i| (i * 7 % 251) as u8).collect(); + let opts = lightcraft_faces::yunet::Options::default(); + let cpu = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let per_conv = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + det.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(10, &mut || drop(det.detect(&rgb, w, h, &opts).unwrap())); + assert!(det.net().accelerator().is_some()); + eprintln!("YuNet 640: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms"); + for (file, sha) in [ + ("face_recognition_sface_2021dec.onnx", lightcraft_faces::known::SFACE_SHA256), + ("glintr100.onnx", lightcraft_faces::known::AURAFACE_SHA256), + ] { + let e = lightcraft_faces::runtime::Embedder::load(&dir.join(file), &lightcraft_faces::known::lookup(sha).unwrap()).unwrap(); + let (ew, eh) = e.input_size(); + let faces: Vec<Vec<u8>> = (0..16).map(|s| (0..ew * eh * 3).map(|i| ((i + s * 101) * 13 % 251) as u8).collect()).collect(); + let refs: Vec<&[u8]> = faces.iter().map(Vec::as_slice).collect(); + let cpu = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(true, Ordering::Relaxed); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let per_conv = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + PER_CONV.store(false, Ordering::Relaxed); + e.accelerate(accelerator().unwrap(), false).unwrap(); + let whole = time(5, &mut || drop(e.embed(&faces[0]).unwrap())); + let batch = time(3, &mut || drop(e.embed_batch(&refs))) / 16.0; + assert!(e.net().accelerator().is_some()); + eprintln!( + "{file}: CPU {cpu:.1} ms, GPU per convolution {per_conv:.1} ms, GPU whole network {whole:.1} ms, batch of 16 {batch:.1} ms a face" + ); + } + } +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index fc40acb24..c1f3dcc93 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -35,6 +35,8 @@ pub mod backend; mod ctx; #[cfg(not(target_arch = "wasm32"))] mod exit; +#[cfg(not(target_arch = "wasm32"))] +pub mod faces; #[cfg(all(feature = "denoise", not(target_arch = "wasm32")))] pub mod nn; #[cfg(not(target_arch = "wasm32"))] @@ -239,9 +241,9 @@ pub fn wait_idle(timeout: std::time::Duration) -> bool { } } -/// Why GPU compute that has a device of its own (denoise) is switched off by the user's settings, or `None`: the +/// Why GPU compute that has a device of its own (denoise, faces) 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")))] +#[cfg(not(target_arch = "wasm32"))] pub(crate) fn switched_off() -> Option<String> { if env_disabled() { return Some(if backend::env_off() { "disabled by LIGHTCRAFT_GPU_BACKEND=off" } else { "disabled by LIGHTCRAFT_GPU=0" }.into()); @@ -437,6 +439,15 @@ pub fn render(src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &R try_render(src, info, s, req, stages).ok() } +/// Whether the GPU renders requests of this kind at all (a given render can still fall back to +/// the CPU): the kernel writes 8-bit output, so high-bit-depth exports render on the CPU, and so do +/// soft proofs against an output-device profile (`device_proof`: its 3D table is CPU-side) and +/// Visualize HDR, which needs the float HDR render. [`try_render`] follows it, and the loupe sizes +/// its slider-drag drafts by it (issue #330), so the two can't drift apart. +pub fn handles(depth: lightcraft_pipeline::OutputDepth, device_proof: bool, overlay: lightcraft_pipeline::Overlay) -> bool { + depth == lightcraft_pipeline::OutputDepth::U8 && !device_proof && overlay != lightcraft_pipeline::Overlay::HdrRange +} + /// [`render`], reporting why this render made no GPU image: [`Fallback::NotTried`] when the GPU /// was not used (callers can ask [`unavailable_reason`]), [`Fallback::Failed`] with the reason /// when the GPU render failed and the CPU must render it. The reason belongs to this call, unlike @@ -453,13 +464,8 @@ pub fn try_render( // sections switched off with their eye render as if at their defaults (issue #316) let effective = s.effective(); let s: &DevelopSettings = &effective; - // the kernel writes 8-bit output: high-bit-depth exports render on the CPU, and so do soft - // proofs against an output-device profile (its 3D table); Visualize HDR needs the float HDR render: CPU too - if !enabled() - || req.depth != lightcraft_pipeline::OutputDepth::U8 - || req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some()) - || req.overlay == lightcraft_pipeline::Overlay::HdrRange - { + let device_proof = req.proof.is_some_and(|p| lightcraft_pipeline::proof_profile(p.profile).is_some()); + if !enabled() || !handles(req.depth, device_proof, req.overlay) { return Err(Fallback::NotTried); } let s = &*lightcraft_pipeline::settings_for(s, req); @@ -533,3 +539,19 @@ pub fn try_render( Err(Fallback::NotTried) } } + +#[cfg(test)] +mod tests { + use lightcraft_pipeline::{OutputDepth, Overlay}; + + // Given a request the 8-bit kernel can't draw (high bit depth, a soft proof, Visualize HDR), + // the GPU doesn't take it; the loupe and diagnostic overlays it can draw stay on the GPU + #[test] + fn the_gpu_handles_8_bit_unproofed_renders_without_the_hdr_range() { + assert!(super::handles(OutputDepth::U8, false, Overlay::None)); + assert!(super::handles(OutputDepth::U8, false, Overlay::Spots(50))); + assert!(!super::handles(OutputDepth::U16, false, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, true, Overlay::None)); + assert!(!super::handles(OutputDepth::U8, false, Overlay::HdrRange)); + } +} diff --git a/crates/gpu/src/nn.rs b/crates/gpu/src/nn.rs index 4f078ff93..5872bfaa9 100644 --- a/crates/gpu/src/nn.rs +++ b/crates/gpu/src/nn.rs @@ -907,7 +907,7 @@ mod tests { match runner(net, tile) { Ok(r) => Some(r), Err(e) => { - eprintln!("skipped: no GPU runner here ({e})"); + lightcraft_testkit::skip!(Gpu, "no GPU runner here ({e})"); None } } diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index ed3f7db65..5347de1e7 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -824,7 +824,7 @@ fn linear(cx: &mut Cx<'_>, sampled: &Buf, info: &SourceInfo, plan: &Plan<'_>, ho fn denoise(cx: &mut Cx<'_>, img: Buf, plan: &Plan<'_>) -> Buf { let (w, h) = (plan.w, plan.h); let n = w * h; - let (lum, col) = local::nr_params(&plan.settings, plan.src_long, plan.frame.output_long(w, h)); + let (lum, col) = local::nr_params(&plan.settings, plan.detail_scale); let mut img = img; if let Some(nr) = lum { let l = cx.gpu.buffer(n); @@ -1015,7 +1015,8 @@ fn masks(cx: &mut Cx<'_>, lin: &Buf, prep: &Prep, plan: &Plan<'_>, host: &mut Ho } MaskShape::Brush { strokes } => { let dabs: Vec<_> = strokes.iter().map(|st| lightcraft_pipeline::masks::brush_dabs(st, frame, w, h)).collect(); - if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS { + // pen pressure varies radius and flow per dab, which the kernel doesn't take: the CPU rasterizes it + if dabs.iter().map(|d| d.dabs.len()).sum::<usize>() > MAX_DABS || dabs.iter().any(|d| !d.pressure.is_empty()) { None } else if dabs.iter().any(|d| d.auto) { // Auto Mask: stroke by stroke (each one refined before it's combined) diff --git a/crates/gpu/src/wgsl/faces_conv.wgsl b/crates/gpu/src/wgsl/faces_conv.wgsl new file mode 100644 index 000000000..8384394e9 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_conv.wgsl @@ -0,0 +1,171 @@ +// Convolution for the face networks (lightcraft-faces), planar (NCHW) float32, batch 1, any stride, padding and grouping. +// +// `tiled` is an implicit matrix product per group: rows are the group's output channels (M), columns its output +// pixels (N), and K runs over input channel x kernel tap. The input is gathered (im2col) straight into workgroup +// memory, so the lowered matrix is never made. 256 threads make a 64 x 64 block, 4 x 4 values each. +// `direct` gives one output value per thread, for depthwise convolutions (one output channel per group), where a +// 64-row tile would be almost all waste. +// +// out[co][p] = bias[co] + sum_k W[co][k] * X[k][p] X[k][p] = input[g*cg + k/taps][oy*sh + ky - pt][ox*sw + kx - pl] + +struct P { + m: u32, // output channels per group + n: u32, // output pixels (oh * ow) + k: u32, // cg * kh * kw + cg: u32, // input channels per group + h: u32, + w: u32, + ow: u32, + kh: u32, + kw: u32, + sh: u32, + sw: u32, + pt: u32, + pl: u32, + bias: u32, // 1 when there is a bias + total: u32, // cout * n + row: u32, // workgroups along x, for `direct` and `reduce` (so a large layer can use y too) + splits: u32, // K is cut into this many slices (z = group * splits + slice), summed by `reduce` + kchunk: u32, // K values per slice, a multiple of BK + _a: u32, + _b: u32, +} + +@group(0) @binding(0) var<uniform> p: P; +@group(0) @binding(1) var<storage, read> src: array<f32>; +@group(0) @binding(2) var<storage, read> wgt: array<f32>; +@group(0) @binding(3) var<storage, read> bias: array<f32>; +@group(0) @binding(4) var<storage, read_write> dst: array<f32>; + +const BM: u32 = 64u; +const BN: u32 = 64u; +const BK: u32 = 16u; + +var<workgroup> As: array<f32, 1024>; // [BK][BM] +var<workgroup> Bs: array<f32, 1024>; // [BK][BN] + +fn gather(g: u32, k: u32, pix: u32) -> f32 { + let taps = p.kh * p.kw; + let ci = g * p.cg + k / taps; + let tap = k % taps; + let oy = pix / p.ow; + let ox = pix % p.ow; + let iy = i32(oy * p.sh + tap / p.kw) - i32(p.pt); + let ix = i32(ox * p.sw + tap % p.kw) - i32(p.pl); + if (iy < 0 || ix < 0 || iy >= i32(p.h) || ix >= i32(p.w)) { + return 0.0; + } + return src[(ci * p.h + u32(iy)) * p.w + u32(ix)]; +} + +@compute @workgroup_size(256) +fn tiled(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let n0 = wg.x * BN; + let m0 = wg.y * BM; + let g = wg.z / p.splits; + let slice = wg.z % p.splits; + let k_lo = slice * p.kchunk; + let k_hi = min(p.k, k_lo + p.kchunk); + let tx = tid % 16u; + let ty = tid / 16u; + var acc: array<array<f32, 4>, 4>; + for (var i = 0u; i < 4u; i++) { + for (var j = 0u; j < 4u; j++) { + acc[i][j] = 0.0; + } + } + for (var k0 = k_lo; k0 < k_hi; k0 += BK) { + // 1024 values of each tile, 4 per thread; every slot has exactly one writer + for (var e = tid; e < 1024u; e += 256u) { + // weights: neighbouring threads read neighbouring k of one row + let mm = e / BK; + let ka = k0 + e % BK; + let m = m0 + mm; + var a = 0.0; + if (ka < k_hi && m < p.m) { + a = wgt[(g * p.m + m) * p.k + ka]; + } + As[(e % BK) * BM + mm] = a; + // input: neighbouring threads read neighbouring pixels + let kk = e / BN; + let k = k0 + kk; + let pix = n0 + e % BN; + var b = 0.0; + if (k < k_hi && pix < p.n) { + b = gather(g, k, pix); + } + Bs[e] = b; + } + workgroupBarrier(); + for (var kk = 0u; kk < BK; kk++) { + var a: array<f32, 4>; + var b: array<f32, 4>; + for (var i = 0u; i < 4u; i++) { + a[i] = As[kk * BM + ty * 4u + i]; + b[i] = Bs[kk * BN + tx + 16u * i]; + } + for (var i = 0u; i < 4u; i++) { + for (var j = 0u; j < 4u; j++) { + acc[i][j] += a[i] * b[j]; + } + } + } + workgroupBarrier(); + } + for (var i = 0u; i < 4u; i++) { + let m = m0 + ty * 4u + i; + if (m >= p.m) { + continue; + } + let co = g * p.m + m; + var b0 = 0.0; + if (p.bias != 0u && p.splits == 1u) { + b0 = bias[co]; + } + // with slices, each writes a partial sum of its own: [slice][cout][n] + let base = slice * p.total + co * p.n; + for (var j = 0u; j < 4u; j++) { + // columns tx, tx + 16, tx + 32, tx + 48: neighbouring threads write neighbouring pixels + let pix = n0 + tx + 16u * j; + if (pix < p.n) { + dst[base + pix] = acc[i][j] + b0; + } + } + } +} + +@compute @workgroup_size(256) +fn direct(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let at = (wg.y * p.row + wg.x) * 256u + tid; + if (at >= p.total) { + return; + } + let co = at / p.n; + let pix = at % p.n; + let g = co / p.m; + var v = 0.0; + if (p.bias != 0u) { + v = bias[co]; + } + for (var k = 0u; k < p.k; k++) { + v += wgt[co * p.k + k] * gather(g, k, pix); + } + dst[at] = v; +} + +// The sum of the K slices' partial results, plus the bias. `src` holds [splits][cout][n]. +@compute @workgroup_size(256) +fn reduce(@builtin(workgroup_id) wg: vec3<u32>, @builtin(local_invocation_index) tid: u32) { + let at = (wg.y * p.row + wg.x) * 256u + tid; + if (at >= p.total) { + return; + } + var v = 0.0; + if (p.bias != 0u) { + v = bias[at / p.n]; + } + for (var s = 0u; s < p.splits; s++) { + v += src[s * p.total + at]; + } + dst[at] = v; +} diff --git a/crates/gpu/src/wgsl/faces_ops.wgsl b/crates/gpu/src/wgsl/faces_ops.wgsl new file mode 100644 index 000000000..aa7eba242 --- /dev/null +++ b/crates/gpu/src/wgsl/faces_ops.wgsl @@ -0,0 +1,188 @@ +// The face networks' other operators (lightcraft-faces `net::program`), so activations stay on the device between layers. +// Same bindings as faces_conv.wgsl: the parameters, three read-only operands and the output. Element kernels run one +// thread per output value; `gavg` and `gemv` one workgroup per output value. +// +// Parameters are 24 numbers: q(0) = values (or workgroups) to make, q(1) = workgroups along x, then per kernel. + +struct P { + v: array<vec4<u32>, 6>, +} + +@group(0) @binding(0) var<uniform> p: P; +@group(0) @binding(1) var<storage, read> a: array<f32>; +@group(0) @binding(2) var<storage, read> b: array<f32>; +@group(0) @binding(3) var<storage, read> c: array<f32>; +@group(0) @binding(4) var<storage, read_write> dst: array<f32>; + +fn q(i: u32) -> u32 { + return p.v[i / 4u][i % 4u]; +} + +fn index(wid: vec3<u32>, lid: u32) -> u32 { + return (wid.y * q(1) + wid.x) * 256u + lid; +} + +// q(2): 0 relu, 1 sigmoid +@compute @workgroup_size(256) +fn unary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let x = a[i]; + if (q(2) == 0u) { + dst[i] = max(x, 0.0); + } else { + dst[i] = 1.0 / (1.0 + exp(-x)); + } +} + +// q(2) plane, q(3) channels: x * b[c] + c[c] +@compute @workgroup_size(256) +fn affine(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let ch = (i / q(2)) % q(3); + dst[i] = a[i] * b[ch] + c[ch]; +} + +// q(2) plane, q(3) channels: x < 0 ? x * b[c] : x +@compute @workgroup_size(256) +fn prelu(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let x = a[i]; + let ch = (i / q(2)) % q(3); + dst[i] = select(x, x * b[ch], x < 0.0); +} + +// q(2): 0 add, 1 sub, 2 mul; q(3..7) output dims; q(7..11) a's strides; q(11..15) b's strides +@compute @workgroup_size(256) +fn binary(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + var rest = i; + var ia = 0u; + var ib = 0u; + for (var d = 3; d >= 0; d = d - 1) { + let du = u32(d); + let size = q(3u + du); + let at = rest % size; + rest = rest / size; + ia = ia + at * q(7u + du); + ib = ib + at * q(11u + du); + } + let x = a[ia]; + let y = b[ib]; + if (q(2) == 0u) { + dst[i] = x + y; + } else if (q(2) == 1u) { + dst[i] = x - y; + } else { + dst[i] = x * y; + } +} + +// q(3..7) output dims; q(7..11) the input's strides for each output axis +@compute @workgroup_size(256) +fn transpose(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + var rest = i; + var at_in = 0u; + for (var d = 3; d >= 0; d = d - 1) { + let du = u32(d); + let size = q(3u + du); + at_in = at_in + (rest % size) * q(7u + du); + rest = rest / size; + } + dst[i] = a[at_in]; +} + +// q(2) h, q(3) w, q(4) kh, q(5) kw, q(6) sh, q(7) sw, q(8) oh, q(9) ow +@compute @workgroup_size(256) +fn maxpool(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let h = q(2); + let w = q(3); + let oh = q(8); + let ow = q(9); + let ox = i % ow; + let oy = (i / ow) % oh; + let ch = i / (ow * oh); + var m = -3.402823e38; + for (var ky = 0u; ky < q(4); ky = ky + 1u) { + for (var kx = 0u; kx < q(5); kx = kx + 1u) { + let y = oy * q(6) + ky; + let x = ox * q(7) + kx; + if (y < h && x < w) { + m = max(m, a[(ch * h + y) * w + x]); + } + } + } + dst[i] = m; +} + +// q(2) h, q(3) w, q(4) sh, q(5) sw: nearest upscale by whole numbers +@compute @workgroup_size(256) +fn resize(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let i = index(wid, lid); + if (i >= q(0)) { return; } + let h = q(2); + let w = q(3); + let ow = w * q(5); + let oh = h * q(4); + let ox = i % ow; + let oy = (i / ow) % oh; + let ch = i / (ow * oh); + dst[i] = a[(ch * h + oy / q(4)) * w + ox / q(5)]; +} + +var<workgroup> partial: array<f32, 256>; + +fn reduce(lid: u32) { + for (var s = 128u; s > 0u; s = s >> 1u) { + workgroupBarrier(); + if (lid < s) { + partial[lid] = partial[lid] + partial[lid + s]; + } + } + workgroupBarrier(); +} + +// one workgroup per channel; q(2) plane +@compute @workgroup_size(256) +fn gavg(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let ch = wid.y * q(1) + wid.x; + let plane = q(2); + var sum = 0.0; + if (ch < q(0)) { + for (var j = lid; j < plane; j = j + 256u) { + sum = sum + a[ch * plane + j]; + } + } + partial[lid] = sum; + reduce(lid); + if (lid == 0u && ch < q(0)) { + dst[ch] = partial[0] / f32(plane); + } +} + +// one workgroup per output; q(2) k, q(3) n, q(4) 1 when B is [n][k], q(5) alpha's bits +@compute @workgroup_size(256) +fn gemv(@builtin(workgroup_id) wid: vec3<u32>, @builtin(local_invocation_index) lid: u32) { + let o = wid.y * q(1) + wid.x; + let k = q(2); + let n = q(3); + var sum = 0.0; + if (o < q(0)) { + for (var j = lid; j < k; j = j + 256u) { + let wi = select(j * n + o, o * k + j, q(4) == 1u); + sum = sum + a[j] * b[wi]; + } + } + partial[lid] = sum; + reduce(lid); + if (lid == 0u && o < q(0)) { + dst[o] = bitcast<f32>(q(5)) * partial[0] + c[o]; + } +} diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index 51658147b..cc063bee3 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -19,7 +19,7 @@ const MAX_LSB: u8 = 3; fn gpu() -> bool { let ok = lightcraft_gpu::available(); if !ok { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); } ok } @@ -79,6 +79,19 @@ fn check(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, (mean, max) } +/// [`check`] for gamut-warning renders: a warning is a hard decision at the gamut boundary, so a +/// pixel sitting exactly on it may be painted by one renderer and not the other (float rounding). +/// Such flips are allowed for at most 0.01 % of the pixels; everything else is held to [`check`]'s bounds. +fn check_warned(name: &str, src: &Arc<Rgb32f>, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest) { + let cpu = render(src, info, s, req).image; + let gpu = lightcraft_gpu::render(src, info, s, req, None).expect("gpu render"); + let (mean, _, over) = diff(&cpu, &gpu.image); + let flips = cpu.data.iter().zip(&gpu.image.data).filter(|(p, q)| (0..3).any(|c| p[c].abs_diff(q[c]) > MAX_LSB)).count(); + let share = flips as f64 / cpu.data.len() as f64; + eprintln!("{name:<28} {}x{} mean {mean:.4} >1: {:.4}% boundary flips {flips}", cpu.width, cpu.height, over * 100.0); + assert!(mean < MEAN_LSB && share <= 1e-4, "{name}: mean {mean:.4} LSB, {flips} pixels differ by more than {MAX_LSB} LSB"); +} + type Edit = fn(&mut DevelopSettings); fn typical(s: &mut DevelopSettings) { @@ -93,6 +106,26 @@ fn typical(s: &mut DevelopSettings) { s.detail.sharpen_amount = 40.0; } +/// Issue #960: a preview's noise reduction works in the original's pixels (a smaller radius, a +/// smaller `eps`, faded below one pixel). The GPU takes the same parameters (`local::nr_params`). +#[test] +fn preview_noise_reduction_matches() { + if !gpu() { + return; + } + let src = scene(3, 640, 480); + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 70.0; + s.detail.nr_detail = 30.0; + s.detail.nr_color = 60.0; + s.detail.nr_color_smoothness = 50.0; + // a preview level of a 6000-px original: loupe-, grid- and tiny-thumbnail-sized outputs + let info = SourceInfo { raw: true, native_long: Some(6000), ..Default::default() }; + for (name, edge) in [("NR preview 640 of 6000", 640), ("NR preview 320 of 6000", 320), ("NR preview 128 of 6000", 128)] { + check(name, &src, &info, &s, &RenderRequest::fit(edge, edge)); + } +} + /// A render big enough for the per-pixel stage to run in several bands of rows (and the render /// in many submissions), with grain (position-dependent) and masks. #[test] @@ -876,7 +909,7 @@ fn soft_proofs_match() { saturated(&mut s); let proof = Some(Proof { space, dest_warning, display_warning, profile: 0 }); let req = RenderRequest { space: out, proof, ..RenderRequest::fit(640, 640) }; - check(&format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"), &src, &raw, &s, &req); + check_warned(&format!("proof {space:?} → {out:?} warn {dest_warning}/{display_warning}"), &src, &raw, &s, &req); } } } diff --git a/crates/gpu/tests/fallback.rs b/crates/gpu/tests/fallback.rs index fe2ecad5e..05347b446 100644 --- a/crates/gpu/tests/fallback.rs +++ b/crates/gpu/tests/fallback.rs @@ -38,7 +38,7 @@ fn masked(s: &mut DevelopSettings, k: usize) { #[test] fn failed_gpu_renders_fall_back_with_a_reason() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter ({:?})", lightcraft_gpu::unavailable_reason()); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({})", lightcraft_gpu::unavailable_reason().unwrap_or_default()); return; } assert_eq!(lightcraft_gpu::unavailable_reason(), None); diff --git a/crates/gpu/tests/gpu_probe.rs b/crates/gpu/tests/gpu_probe.rs new file mode 100644 index 000000000..e006fbc80 --- /dev/null +++ b/crates/gpu/tests/gpu_probe.rs @@ -0,0 +1,87 @@ +//! What GPU this machine offers the develop pipeline, as one JSON line: the capability probe of +//! `cargo xtask ci`, which is std + serde_json and has no wgpu of its own. Ignored in normal runs; +//! xtask runs it after the workspace tests, so it costs no extra build: +//! `cargo test --workspace --test gpu_probe -- --ignored --nocapture`. +//! +//! - `LIGHTCRAFT_GPU_PROBE` unset: the compute device the pipeline picks (`available()`, +//! `adapter_name()`, `unavailable_reason()`), the backends it may use and the backends this build +//! contains. +//! - `LIGHTCRAFT_GPU_PROBE=<backend>` (`vulkan`, `metal`, `dx12`, `gl`): every adapter that +//! backend lists, software ones (lavapipe, llvmpipe, WARP) included. One backend per process, so +//! a driver that crashes while it is enumerated (issue #136) costs only its own line. +//! +//! Only reports: which adapters the pipeline accepts is `ctx.rs`'s policy. +#![cfg(not(target_arch = "wasm32"))] + +use lightcraft_gpu::backend::{self, Backends}; + +const NAMES: [(&str, Backends); 4] = [("vulkan", Backends::VULKAN), ("metal", Backends::METAL), ("dx12", Backends::DX12), ("gl", Backends::GL)]; + +#[test] +#[ignore = "the capability probe of `cargo xtask ci`"] +fn gpu_probe() { + let line = match std::env::var("LIGHTCRAFT_GPU_PROBE").ok().filter(|b| !b.is_empty()) { + None => pipeline(), + Some(name) => adapters(&name), + }; + // its own line, prefixed: libtest may have printed `test gpu_probe ... ` on this one + println!("\ngpu_probe: {line}"); +} + +fn names(set: Backends) -> String { + let list: Vec<String> = NAMES.iter().filter(|(_, b)| set.contains(*b)).map(|(n, _)| json_str(n)).collect(); + format!("[{}]", list.join(",")) +} + +fn pipeline() -> String { + let available = lightcraft_gpu::available(); + let opt = |s: Option<String>| s.map_or_else(|| "null".to_owned(), |s| json_str(&s)); + let compiled = wgpu::Instance::enabled_backend_features(); + format!( + "{{\"pipeline\":{{\"available\":{available},\"adapter\":{},\"reason\":{},\"backends\":{},\"compiled\":{}}}}}", + opt(lightcraft_gpu::adapter_name()), + opt(lightcraft_gpu::unavailable_reason()), + names(backend::compute_backends().unwrap_or(Backends::empty()) & compiled), + names(compiled), + ) +} + +fn adapters(name: &str) -> String { + let (_, set) = NAMES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("unknown backend `{name}` (vulkan, metal, dx12, gl)")); + let mut desc = wgpu::InstanceDescriptor::new_without_display_handle(); + desc.backends = *set; + desc.backend_options = backend::backend_options(); + desc.flags = backend::instance_flags_from_env(desc.flags); + let instance = wgpu::Instance::new(desc); + let adapters: Vec<String> = pollster::block_on(instance.enumerate_adapters(*set)) + .iter() + .map(|a| { + let i = a.get_info(); + format!( + "{{\"name\":{},\"type\":{},\"vendor\":\"0x{:04x}\",\"device\":\"0x{:04x}\",\"driver\":{},\"driver_info\":{}}}", + json_str(&i.name), + json_str(&format!("{:?}", i.device_type)), + i.vendor, + i.device, + json_str(&i.driver), + json_str(&i.driver_info), + ) + }) + .collect(); + format!("{{\"backend\":{},\"adapters\":[{}]}}", json_str(name), adapters.join(",")) +} + +/// A JSON string literal. +fn json_str(s: &str) -> String { + let mut out = String::from("\""); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if u32::from(c) < 0x20 => out.push_str(&format!("\\u{:04x}", u32::from(c))), + c => out.push(c), + } + } + out.push('"'); + out +} diff --git a/crates/gpu/tests/memory.rs b/crates/gpu/tests/memory.rs index 2ef958cb1..25784f001 100644 --- a/crates/gpu/tests/memory.rs +++ b/crates/gpu/tests/memory.rs @@ -9,7 +9,7 @@ use lightcraft_pipeline::{RenderRequest, SourceInfo, StageCache}; #[test] fn stages_are_counted_and_the_pool_is_bounded_and_trimmed() { if !lightcraft_gpu::available() { - eprintln!("skipped: no GPU adapter"); + lightcraft_testkit::skip!(Gpu, "no GPU adapter"); return; } let limit = 64u64 << 20; diff --git a/crates/gpu/tests/shutdown.rs b/crates/gpu/tests/shutdown.rs index b827a0711..a8d5cbd23 100644 --- a/crates/gpu/tests/shutdown.rs +++ b/crates/gpu/tests/shutdown.rs @@ -21,7 +21,8 @@ fn nothing_renders_on_the_gpu_once_the_process_is_ending() { let req = RenderRequest::fit(w, h); let has_gpu = lightcraft_gpu::available(); if !has_gpu { - eprintln!("no GPU adapter ({:?}): only the CPU side is checked", lightcraft_gpu::unavailable_reason()); + let why = lightcraft_gpu::unavailable_reason().unwrap_or_default(); + lightcraft_testkit::skip!(Gpu, "no GPU adapter ({why}), so only the CPU side is checked"); } assert!(!lightcraft_gpu::shutting_down()); assert!(lightcraft_gpu::wait_idle(Duration::ZERO), "nothing is in flight yet"); diff --git a/crates/heif/src/lib.rs b/crates/heif/src/lib.rs index 8292024b1..21b9cf647 100644 --- a/crates/heif/src/lib.rs +++ b/crates/heif/src/lib.rs @@ -277,6 +277,10 @@ fn decode_item(ctx: &Context<'_>, id: u32, options: &Options, with_alpha: bool) }) } +/// How many times `max_pixels` the coded pictures of one item may hold together: a grid's tiles +/// cover its canvas rounded up to whole tiles, so legitimate grids sit near 1. +const GRID_BUDGET: u64 = 2; + /// The decoded pictures of an item: one, or a grid's tiles in `dimg` order. struct Coded<'a> { frames: Vec<Frame>, @@ -297,6 +301,24 @@ impl<'a> Coded<'a> { 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); + // #1518: the decoder allocates each picture at its SPS's uncropped size, which a + // conformance window (or a slice picking another SPS) can make far larger than the + // declared one, and a grid holds all its tiles at once. Both are refused before decoding. + let mut total = 0u64; + for t in &ids { + 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 sizes = vui::coded_sizes(&hvcc.parameter_sets()).ok_or_else(|| Error::Malformed("unreadable sequence parameter set".into()))?; + let mut largest = 0u64; + for (w, h) in sizes { + heic_rs::image::check_pixels(w, h, limit).map_err(err)?; + largest = largest.max(u64::from(w) * u64::from(h)); + } + total = total.saturating_add(largest); + } + if total > limit.saturating_mul(GRID_BUDGET) { + return Err(Error::Limit(format!("the coded pictures hold {total} pixels, over the {limit}-pixel limit"))); + } let decode_one = |t: &u32| -> Result<Frame, Error> { let tp = ctx.props(*t).map_err(err)?; let hvcc = tp.hvcc.as_ref().ok_or(heic_rs::Error::MissingBox("hvcC")).map_err(err)?; diff --git a/crates/heif/src/tests_synthetic.rs b/crates/heif/src/tests_synthetic.rs index e229af926..3259dc96d 100644 --- a/crates/heif/src/tests_synthetic.rs +++ b/crates/heif/src/tests_synthetic.rs @@ -146,6 +146,17 @@ fn limits_are_checked_before_decoding() { assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); } +/// #1518: a grid's tiles together are held to the pixel budget before any is decoded. A +/// 33 × 33 canvas fits 1089 pixels, but its four 32 × 32 tiles hold 4096: over twice the limit. +#[test] +fn grid_tiles_are_held_to_the_pixel_budget_together() { + let bytes = build(&Spec::new(33, 33, &grey)); + let r = decode(&bytes, &Options { max_pixels: 33 * 33, ..Default::default() }); + assert!(matches!(r, Err(Error::Limit(_))), "{r:?}"); + let d = decode(&bytes, &Options { max_pixels: 2 * 32 * 32, ..Default::default() }).unwrap(); + assert_eq!((d.width, d.height), (33, 33)); +} + #[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) }); diff --git a/crates/heif/src/vui.rs b/crates/heif/src/vui.rs index d732f2863..226604303 100644 --- a/crates/heif/src/vui.rs +++ b/crates/heif/src/vui.rs @@ -33,6 +33,41 @@ pub fn signal(parameter_sets: &[&[u8]]) -> Option<Signal> { parse_sps(&unescape(sps.get(2..)?)) } +/// The uncropped picture size (`pic_width/height_in_luma_samples`) of every SPS among +/// `parameter_sets`: what the decoder allocates before it applies the conformance window, so the +/// pixel budget is checked against it (heic-rs reports only the first SPS, cropped). `None`: an +/// SPS this reader can't follow. +pub fn coded_sizes(parameter_sets: &[&[u8]]) -> Option<Vec<(u32, u32)>> { + parameter_sets + .iter() + .filter(|n| n.first().is_some_and(|b| (b >> 1) & 0x3f == SPS_NUT)) + .map(|sps| { + let rbsp = unescape(sps.get(2..)?); + let r = &mut Bits { data: &rbsp, pos: 0 }; + sps_size(r).map(|(w, h, _)| (w, h)) + }) + .collect() +} + +/// Reads an SPS up to and including the picture size, leaving `r` after it: width, height and +/// `sps_max_sub_layers_minus1`. +fn sps_size(r: &mut Bits) -> Option<(u32, u32, u32)> { + 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 + } + let width = r.ue()?; // pic_width_in_luma_samples + let height = r.ue()?; // pic_height_in_luma_samples + Some((width, height, max_sub_layers_minus1)) +} + /// Removes emulation prevention bytes (`00 00 03` → `00 00`). fn unescape(nal: &[u8]) -> Vec<u8> { let mut out = Vec::with_capacity(nal.len()); @@ -165,19 +200,7 @@ fn st_ref_pic_set(r: &mut Bits, idx: usize, num_delta_pocs: &[u32]) -> Option<u3 fn parse_sps(rbsp: &[u8]) -> Option<Signal> { 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 + let (_, _, max_sub_layers_minus1) = sps_size(r)?; if r.flag()? { for _ in 0..4 { r.ue()?; // conf_win_*_offset diff --git a/crates/mcp/src/backend.rs b/crates/mcp/src/backend.rs index 73a02624e..0c59afa6a 100644 --- a/crates/mcp/src/backend.rs +++ b/crates/mcp/src/backend.rs @@ -1,11 +1,14 @@ //! Where MCP tool calls end up: a control-channel method call. -use std::io::{BufRead, BufReader, Write}; +use std::io::{BufRead, BufReader, Read, Write}; use std::net::{TcpStream, ToSocketAddrs}; use std::time::Duration; use serde_json::{Value, json}; +/// Reply payloads include base64-encoded previews and screenshots, unlike the small requests. +const MAX_REPLY: usize = 64 * 1024 * 1024; + /// Something that answers control-channel methods (`engine.execute`, `engine.commands`, /// `ui.render`, `ui.screenshot`, `ui.key`, …). See `docs/control-protocol.md`. pub trait Backend { @@ -84,11 +87,7 @@ impl Remote { data.push(b'\n'); writer.write_all(&data).map_err(|e| (e, false))?; writer.flush().map_err(|e| (e, true))?; - let mut reply = String::new(); - if reader.read_line(&mut reply).map_err(|e| (e, true))? == 0 { - return Err((std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed"), true)); - } - Ok(reply) + read_reply(reader, MAX_REPLY).map_err(|e| (e, true)) } fn unknown_outcome(&self, method: &str, e: &std::io::Error) -> String { @@ -97,6 +96,36 @@ impl Remote { } } +fn read_reply(reader: &mut impl BufRead, max: usize) -> std::io::Result<String> { + let mut reply = String::new(); + let limit = u64::try_from(max).unwrap_or(u64::MAX).saturating_add(1); + reader.take(limit).read_line(&mut reply)?; + if reply.ends_with('\n') { + Ok(reply) + } else if reply.len() > max { + Err(std::io::Error::new(std::io::ErrorKind::InvalidData, format!("control reply longer than {max} bytes"))) + } else { + Err(std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "control channel closed before a complete reply line")) + } +} + +fn parse_reply(reply: &str, id: u64) -> std::io::Result<Value> { + let invalid = |message| std::io::Error::new(std::io::ErrorKind::InvalidData, message); + let v: Value = serde_json::from_str(reply).map_err(|e| invalid(format!("bad reply from app: {e}")))?; + let ok = v.get("ok").and_then(Value::as_bool); + match v.get("id") { + Some(reply_id) if reply_id.as_u64() == Some(id) => {} + // The server refuses connections or malformed input before dispatch without an id. + None if ok == Some(false) => {} + _ => return Err(invalid(format!("control reply id does not match request {id}"))), + } + match ok { + Some(true) if v.get("result").is_some() => Ok(v), + Some(false) if v.get("error").and_then(Value::as_str).is_some() => Ok(v), + _ => Err(invalid("control reply must contain boolean `ok` and a result or string error".to_string())), + } +} + /// True when the app has closed `stream` (or it is unusable), checked without blocking or consuming data. fn peer_closed(stream: &TcpStream) -> bool { if stream.set_nonblocking(true).is_err() { @@ -133,7 +162,13 @@ impl Backend for Remote { return Err(self.unknown_outcome(method, &e)); } }; - let v: Value = serde_json::from_str(reply.trim()).map_err(|e| format!("bad reply from app: {e}"))?; + let v = parse_reply(&reply, id).map_err(|e| { + self.conn = None; + self.unknown_outcome(method, &e) + })?; + if v.get("id").is_none() { + self.conn = None; + } if v.get("ok").and_then(Value::as_bool) == Some(true) { Ok(v.get("result").cloned().unwrap_or(Value::Null)) } else { @@ -157,6 +192,143 @@ mod tests { use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; + fn with_reply(reply: &[u8], check: impl FnOnce(&mut Remote, Result<Value, String>)) { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let reply = reply.to_vec(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + let mut request = String::new(); + reader.read_line(&mut request).unwrap(); + let request: Value = serde_json::from_str(&request).unwrap(); + assert_eq!(request["id"], 1); + writer.write_all(&reply).unwrap(); + writer.shutdown(std::net::Shutdown::Write).unwrap(); + let mut extra = String::new(); + assert_eq!(reader.read_line(&mut extra).unwrap(), 0, "request was resent on the same connection"); + listener + }); + let mut remote = Remote::lazy(&addr); + let result = remote.call("engine.execute", json!({"command": "edit.undo"})); + check(&mut remote, result); + drop(remote); + let listener = worker.join().unwrap(); + listener.set_nonblocking(true).unwrap(); + assert_eq!(listener.accept().unwrap_err().kind(), std::io::ErrorKind::WouldBlock, "request was resent on a new connection"); + } + + #[test] + fn mismatched_reply_id_is_rejected_without_resending() { + with_reply(b"{\"id\":999,\"ok\":true,\"result\":\"unrelated\"}\n", |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{err}"); + assert!(remote.conn.is_none(), "discard the untrustworthy connection"); + }); + } + + #[test] + fn unterminated_reply_is_rejected_without_resending() { + with_reply(b"{\"id\":1,\"ok\":true,\"result\":7}", |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{err}"); + assert!(remote.conn.is_none(), "discard the incomplete response"); + }); + } + + #[test] + fn malformed_replies_are_rejected_without_resending() { + for reply in [ + "not JSON\n", + "[]\n", + "{\"id\":1,\"ok\":\"true\",\"result\":7}\n", + "{\"id\":1,\"result\":7}\n", + "{\"id\":1,\"ok\":true}\n", + "{\"ok\":true,\"result\":7}\n", + "{\"id\":null,\"ok\":true,\"result\":7}\n", + "{\"id\":\"1\",\"ok\":true,\"result\":7}\n", + "{\"id\":1.0,\"ok\":true,\"result\":7}\n", + "{\"id\":999,\"ok\":false,\"error\":\"unrelated\"}\n", + "{\"id\":1,\"ok\":false}\n", + "{\"id\":1,\"ok\":false,\"error\":7}\n", + "{\"ok\":false}\n", + ] { + with_reply(reply.as_bytes(), |remote, result| { + let err = result.unwrap_err(); + assert!(err.contains("outcome is unknown"), "{reply}: {err}"); + assert!(remote.conn.is_none()); + }); + } + with_reply(b"\xff\n", |remote, result| { + assert!(result.unwrap_err().contains("outcome is unknown")); + assert!(remote.conn.is_none()); + }); + } + + #[test] + fn valid_replies_and_errors_are_preserved() { + for (reply, expected) in [ + ("{\"id\":1,\"ok\":true,\"result\":7}\n", Ok(json!(7))), + ("{\"id\":1,\"ok\":true,\"result\":null}\r\n", Ok(Value::Null)), + ("{\"id\":1,\"ok\":false,\"error\":\"unknown command\"}\n", Err("unknown command".to_string())), + ("{\"ok\":false,\"error\":\"too many control connections\"}\n", Err("too many control connections".to_string())), + ] { + with_reply(reply.as_bytes(), |remote, result| { + assert_eq!(result, expected); + assert_eq!(remote.conn.is_none(), !reply.contains("\"id\"")); + }); + } + } + + #[test] + fn reply_line_is_bounded_and_requires_a_newline() { + let reply = b"{\"id\":1,\"ok\":true,\"result\":7}\n"; + let payload_len = reply.len() - 1; + assert_eq!(read_reply(&mut reply.as_slice(), payload_len).unwrap().as_bytes(), reply); + assert_eq!(read_reply(&mut reply.as_slice(), payload_len - 1).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(read_reply(&mut &reply[..payload_len], payload_len).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + assert_eq!(read_reply(&mut &b""[..], payload_len).unwrap_err().kind(), std::io::ErrorKind::UnexpectedEof); + let mut oversized = std::io::Cursor::new(vec![b'x'; 100]); + assert_eq!(read_reply(&mut oversized, 10).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + assert_eq!(oversized.position(), 11, "read only the bound plus one byte"); + } + + #[test] + fn preview_sized_replies_use_the_documented_limit() { + let input = std::io::repeat(b'x').take(MAX_REPLY as u64).chain(std::io::Cursor::new(b"\n")); + let reply = read_reply(&mut BufReader::new(input), MAX_REPLY).unwrap(); + assert_eq!(reply.len(), MAX_REPLY + 1); + assert!(reply.ends_with('\n')); + let mut oversized = BufReader::new(std::io::repeat(b'x')); + assert_eq!(read_reply(&mut oversized, MAX_REPLY).unwrap_err().kind(), std::io::ErrorKind::InvalidData); + } + + #[test] + fn consecutive_replies_match_their_requests_on_one_connection() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let addr = listener.local_addr().unwrap().to_string(); + let worker = std::thread::spawn(move || { + let (stream, _) = listener.accept().unwrap(); + stream.set_read_timeout(Some(Duration::from_secs(2))).unwrap(); + let mut writer = stream.try_clone().unwrap(); + let mut reader = BufReader::new(stream); + for id in 1..=2 { + let mut line = String::new(); + reader.read_line(&mut line).unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + assert_eq!(request["id"], id); + writeln!(writer, "{}", json!({"id": id, "ok": true, "result": id})).unwrap(); + } + }); + let mut remote = Remote::lazy(&addr); + for id in 1..=2 { + assert_eq!(remote.call("engine.commands", json!({})).unwrap(), json!(id)); + } + worker.join().unwrap(); + } + #[test] fn lost_reply_is_not_resent() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); @@ -192,7 +364,8 @@ mod tests { let mut line = String::new(); if BufReader::new(s).read_line(&mut line).unwrap_or(0) > 0 { seen2.fetch_add(1, Ordering::SeqCst); - writer.write_all(b"{\"ok\": true, \"result\": 7}\n").unwrap(); + let request: Value = serde_json::from_str(&line).unwrap(); + writeln!(writer, "{}", json!({"id": request["id"], "ok": true, "result": 7})).unwrap(); } } }); diff --git a/crates/mcp/src/headless.rs b/crates/mcp/src/headless.rs index 095cc4684..7c9af704d 100644 --- a/crates/mcp/src/headless.rs +++ b/crates/mcp/src/headless.rs @@ -30,7 +30,7 @@ impl Drop for Headless { impl Default for Headless { fn default() -> Self { let session = Session::new().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); - Self::new(session.with_default_sam3_model()) + Self::new(session.with_default_sam3_model().with_default_slide_templates()) } } @@ -42,7 +42,7 @@ impl Headless { /// A headless session with the procedurally generated demo library. pub fn demo() -> Self { let session = Session::with_demo().with_fs().with_default_denoise_models().with_system_clock().with_default_face_models(); - Self::new(session.with_default_sam3_model()) + Self::new(session.with_default_sam3_model().with_default_slide_templates()) } fn photo_or_active(&self, p: &Value) -> Result<PhotoId, String> { @@ -135,7 +135,7 @@ impl Backend for Headless { "engine.commands" => { let mut v: Vec<Value> = self.session.commands().into_iter().map(|c| serde_json::to_value(c).unwrap_or_default()).collect(); v.push(json!({"id": "app.export", "label": "Export Now", "menu": [], "shortcut": null, - "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", + "params": "{path?: output file (.jpg/.png/.tif/.webp/.avif/.jxl/.psd/.dng) | dir?, ids?, preset?, format?: jpeg|png|tiff|webp|avif|jxl|psd|dng|original, longEdge?|shortEdge?|width?|height?|megapixels?|percent? (default longEdge 3000; longEdge 0 = full size), dontEnlarge?, ppi?, quality?: 1..100, jxlLossless?, jxlEffort?: fast|normal, jxlProgressive?, limitKb?, colorSpace?: srgb|displayP3|adobeRgb|proPhoto|rec2020, bitDepth?: 8|10|16|32, hdr? (HDR output for HDR edits: gain map JPEG, PQ AVIF / PNG / JPEG XL, float TIFF), sharpen?: none|screen|matte|glossy, sharpenAmount?: low|standard|high, metadata?: all|allExceptCamera|copyright|none, removeLocation?, naming?, startNumber?, destinationMode?: specificFolder|sameAsOriginal (sameAsOriginal = each photo into its original's folder; `dir` unused), subfolder?, conflict?: unique|overwrite|skip, tiffCompression?: none|lzw|zip, dngCompression?: lossless|deflate|uncompressed, addToPhotos?: bool (macOS: then add the files written to Apple Photos; outcome in `applePhotos`), photosAlbum?: top-level album name (made when missing), watermark?: text | {text?, vertical? (upright columns, right to left), size? (text height, 0.005..0.5 of the short edge; default 0.035), opacity? (0..1; 0.7), anchor?: topLeft|top|topRight|left|center|right|bottomLeft|bottom|bottomRight, insetX? (margin from the left/right edge, 0..0.4 of the short edge; 0.025), insetY? (from the top/bottom edge; 0.025), inset? (sets both), rotation?: 0|90|180|270 (degrees clockwise; 0), color? [r,g,b] sRGB, shadow?, image? (graphic drawn instead of the text), imageWidth? (0.01..1 of the photo's width; 0.2)}} — an unknown parameter, an out-of-range watermark size or a value of the wrong kind is an error, not a default", "enabled": self.session.active().is_some()})); Ok(Value::Array(v)) } diff --git a/crates/mcp/src/lib.rs b/crates/mcp/src/lib.rs index f95dd7a53..f632f145b 100644 --- a/crates/mcp/src/lib.rs +++ b/crates/mcp/src/lib.rs @@ -11,7 +11,8 @@ //! control-channel method names itself (rendering with the engine's pipeline and encoding with //! `lightcraft-codecs`), so agents can develop photos and look at the result without a window. //! -//! Entry points: [`Server::serve`] (stdio loop) and [`Server::handle_line`] (one message). +//! Entry points: [`Server::serve_stdio`] (process stdin), [`Server::serve`] (caller-managed +//! streams), and [`Server::handle_line`] (one message). #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] diff --git a/crates/mcp/src/progress.rs b/crates/mcp/src/progress.rs index 2043dfd91..3f6a8deec 100644 --- a/crates/mcp/src/progress.rs +++ b/crates/mcp/src/progress.rs @@ -2,8 +2,8 @@ //! serving thread; only stdin and stdout move to transport threads. Other calls wait in order. use std::collections::VecDeque; -use std::io::{BufRead, Write}; -use std::sync::atomic::Ordering; +use std::io::{BufRead, BufReader, Write}; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{Receiver, Sender, TryRecvError}; use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; @@ -13,12 +13,17 @@ use serde_json::{Value, json}; use super::{Server, response}; use crate::backend::ProgressHook; -type Input = Option<std::io::Result<String>>; +enum Input { + Line(std::io::Result<String>), + Closed, + OutputFailed, +} #[derive(Clone)] pub(super) struct Wire { inbox: Arc<Mutex<Receiver<Input>>>, - out: Sender<String>, + out: Sender<Option<String>>, + failed: Arc<AtomicBool>, deferred: Arc<Mutex<VecDeque<Input>>>, } @@ -26,56 +31,114 @@ fn lock<T>(m: &Mutex<T>) -> std::sync::MutexGuard<'_, T> { m.lock().unwrap_or_else(std::sync::PoisonError::into_inner) } +fn read_input(input: impl BufRead, tx: Sender<Input>) { + let read = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + for line in input.lines() { + let failed = line.is_err(); + if tx.send(Input::Line(line)).is_err() || failed { + return; + } + } + let _ = tx.send(Input::Closed); + })); + if read.is_err() { + let _ = tx.send(Input::Line(Err(std::io::Error::other("MCP input thread panicked")))); + } +} + impl Server { + /// Serve process stdin once, writing replies to `output` (normally stdout). + /// + /// The caller must finish the process after this returns. On output failure, the single + /// stdin worker can remain blocked until process exit; it owns no backend or borrowed data. + /// Further calls are refused, including after EOF, so blocked readers cannot accumulate. + pub fn serve_stdio(&mut self, output: impl Write + Send) -> std::io::Result<()> { + static STARTED: AtomicBool = AtomicBool::new(false); + if STARTED.swap(true, Ordering::AcqRel) { + return Err(std::io::Error::new(std::io::ErrorKind::AlreadyExists, "MCP stdin already claimed")); + } + let (in_tx, in_rx) = std::sync::mpsc::channel(); + let reader_tx = in_tx.clone(); + let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn(move || read_input(BufReader::new(std::io::stdin()), reader_tx)) + { + Ok(reader) => reader, + Err(error) => { + STARTED.store(false, Ordering::Release); + return Err(error); + } + }; + let served = self.serve_messages(in_rx, in_tx, output); + let read = if reader.is_finished() { reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")) } else { Ok(()) }; + served.and(read) + } + pub(super) fn serve_wire(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { std::thread::scope(|scope| { - let (in_tx, in_rx) = std::sync::mpsc::channel::<Input>(); - let (out_tx, out_rx) = std::sync::mpsc::channel::<String>(); - let wire = Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, deferred: Arc::new(Mutex::new(VecDeque::new())) }; + let (in_tx, in_rx) = std::sync::mpsc::channel(); + let reader_tx = in_tx.clone(); + let reader = std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || read_input(input, reader_tx))?; + let served = self.serve_messages(in_rx, in_tx, output); + let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")); + served.and(read) + }) + } + + fn serve_messages(&mut self, in_rx: Receiver<Input>, in_tx: Sender<Input>, output: impl Write + Send) -> std::io::Result<()> { + std::thread::scope(|scope| { + let (out_tx, out_rx) = std::sync::mpsc::channel::<Option<String>>(); + let failed = Arc::new(AtomicBool::new(false)); + let wire = + Wire { inbox: Arc::new(Mutex::new(in_rx)), out: out_tx, failed: failed.clone(), deferred: Arc::new(Mutex::new(VecDeque::new())) }; let writer = std::thread::Builder::new().name("mcp-output".into()).spawn_scoped(scope, move || -> std::io::Result<()> { let mut output = output; - for line in out_rx { - output.write_all(line.as_bytes())?; - output.write_all(b"\n")?; - output.flush()?; - } - Ok(()) - })?; - let reader = match std::thread::Builder::new().name("mcp-input".into()).spawn_scoped(scope, move || { - for line in input.lines() { - let failed = line.is_err(); - if in_tx.send(Some(line)).is_err() || failed { - return; + let written = (|| -> std::io::Result<()> { + while let Ok(Some(line)) = out_rx.recv() { + output.write_all(line.as_bytes())?; + output.write_all(b"\n")?; + output.flush()?; } + Ok(()) + })(); + if written.is_err() { + failed.store(true, Ordering::Release); + let _ = in_tx.send(Input::OutputFailed); } - let _ = in_tx.send(None); - }) { - Ok(reader) => reader, - Err(error) => { - drop(wire); - let _ = writer.join(); - return Err(error); - } - }; + written + })?; self.wire = Some(wire.clone()); let served = (|| -> std::io::Result<()> { loop { + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } let queued = lock(&wire.deferred).pop_front(); let line = match queued { Some(line) => line, - None => lock(&wire.inbox).recv().ok().flatten(), + None => { + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } + lock(&wire.inbox).recv().map_err(|_| std::io::Error::other("MCP input disconnected without EOF"))? + } }; - let Some(line) = line else { return Ok(()) }; - if let Some(reply) = self.handle_line(&line?) { - wire.out.send(reply).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?; + let line = match line { + Input::Line(line) => line?, + Input::Closed | Input::OutputFailed => return Ok(()), + }; + if wire.failed.load(Ordering::Acquire) { + return Ok(()); + } + if let Some(reply) = self.handle_line(&line) { + wire.out.send(Some(reply)).map_err(|_| std::io::Error::new(std::io::ErrorKind::BrokenPipe, "MCP output closed"))?; } } })(); self.wire = None; + // A backend that panicked while clearing its progress hook may still hold a sender. + let _ = wire.out.send(None); drop(wire); let written = writer.join().map_err(|_| std::io::Error::other("MCP output thread panicked")).and_then(|r| r); - let read = reader.join().map_err(|_| std::io::Error::other("MCP input thread panicked")); - served.and(written).and(read) + written.and(served) }) } @@ -91,16 +154,29 @@ impl Server { let mut reported: Option<Instant> = None; Some(Box::new(move |done, total, name| { while !closed { + if wire.failed.load(Ordering::Acquire) { + suppress.store(true, Ordering::Relaxed); + return false; + } let next = lock(&wire.inbox).try_recv(); let line = match next { - Ok(Some(Ok(line))) => line, - Ok(Some(Err(error))) => { - lock(&wire.deferred).push_back(Some(Err(error))); + Ok(Input::Line(Ok(line))) => line, + Ok(Input::Line(Err(error))) => { + lock(&wire.deferred).push_back(Input::Line(Err(error))); closed = true; break; } - Ok(None) | Err(TryRecvError::Disconnected) => { - lock(&wire.deferred).push_back(None); + Ok(Input::OutputFailed) => { + suppress.store(true, Ordering::Relaxed); + return false; + } + Ok(Input::Closed) => { + lock(&wire.deferred).push_back(Input::Closed); + closed = true; + break; + } + Err(TryRecvError::Disconnected) => { + lock(&wire.deferred).push_back(Input::Line(Err(std::io::Error::other("MCP input disconnected without EOF")))); closed = true; break; } @@ -115,12 +191,13 @@ impl Server { if msg.get("id").is_some_and(|i| i.is_string() || i.is_number()) && msg.get("params").is_none_or(|p| p.is_null() || p.is_object()) => { - let _ = wire.out.send(response(msg["id"].clone(), json!({})).to_string()); + let _ = wire.out.send(Some(response(msg["id"].clone(), json!({})).to_string())); } - _ => lock(&wire.deferred).push_back(Some(Ok(line))), + _ => lock(&wire.deferred).push_back(Input::Line(Ok(line))), } } - if suppress.load(Ordering::Relaxed) { + if suppress.load(Ordering::Relaxed) || wire.failed.load(Ordering::Acquire) { + suppress.store(true, Ordering::Relaxed); return false; } if let Some(token) = &token @@ -137,13 +214,17 @@ impl Server { }; let notification = json!({"jsonrpc":"2.0","method":"notifications/progress", "params":{"progressToken":token,"progress":done,"total":total,"message":message}}); - let _ = wire.out.send(notification.to_string()); + let _ = wire.out.send(Some(notification.to_string())); } true })) } } +#[cfg(test)] +#[path = "progress_tests.rs"] +mod shutdown_tests; + #[cfg(test)] mod tests { use super::*; diff --git a/crates/mcp/src/progress_tests.rs b/crates/mcp/src/progress_tests.rs new file mode 100644 index 000000000..fb7859dc5 --- /dev/null +++ b/crates/mcp/src/progress_tests.rs @@ -0,0 +1,177 @@ +use super::*; +use crate::Backend; +use std::io::{BufReader, Cursor, Read}; +use std::process::{Command, Stdio}; + +struct ExportUntilCancelled { + hook: Option<ProgressHook>, + retain_hook: bool, +} + +impl Backend for ExportUntilCancelled { + fn call(&mut self, _: &str, _: Value) -> Result<Value, String> { + if self.retain_hook { + return Ok(json!({})); + } + let hook = self.hook.as_mut().expect("export installs a progress hook"); + while hook(0, 2, "photo.png") { + std::thread::sleep(Duration::from_millis(1)); + } + Ok(json!({"cancelled": true})) + } + + fn has_ui(&self) -> bool { + false + } + + fn describe(&self) -> String { + "test".into() + } + + fn set_progress(&mut self, hook: Option<ProgressHook>) { + assert!(!self.retain_hook || hook.is_some(), "synthetic clear-hook panic"); + self.hook = hook; + } +} + +struct FailedOutput { + flush: bool, + initialized: bool, +} + +impl Write for FailedOutput { + fn write(&mut self, bytes: &[u8]) -> std::io::Result<usize> { + if !self.initialized || self.flush { Ok(bytes.len()) } else { Err(std::io::Error::other("synthetic output failure")) } + } + + fn flush(&mut self) -> std::io::Result<()> { + if self.initialized { + Err(std::io::Error::other("synthetic output failure")) + } else { + self.initialized = true; + Ok(()) + } + } +} + +// The child owns the blocked read; a regression cannot strand the test runner in a thread join. +#[test] +#[ignore = "subprocess helper for the bounded shutdown regression"] +fn output_failure_child() { + let mode = std::env::var("LIGHTCRAFT_MCP_SHUTDOWN_CASE").unwrap(); + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: mode == "retained-hook" })); + if mode == "retained-hook" { + let request = json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"dir":"unused"}}}); + let mut output = Vec::new(); + server.serve(Cursor::new(format!("{request}\n")), &mut output).unwrap(); + let reply: Value = serde_json::from_slice(&output).unwrap(); + assert_eq!(reply["result"]["isError"], true); + assert!(reply.to_string().contains("synthetic clear-hook panic")); + } else { + let error = server.serve_stdio(FailedOutput { flush: mode.ends_with("flush"), initialized: false }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert_eq!(error.to_string(), "synthetic output failure"); + assert!(server.wire.is_none()); + let mut another = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = another.serve_stdio(std::io::sink()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::AlreadyExists); + } + eprintln!("MCP shutdown verified"); +} + +#[test] +fn idle_write_failure_does_not_wait_for_input_eof() { + run_shutdown_case("idle-write"); +} + +#[test] +fn idle_flush_failure_does_not_wait_for_input_eof() { + run_shutdown_case("idle-flush"); +} + +#[test] +fn progress_write_failure_does_not_wait_for_input_eof() { + run_shutdown_case("progress-write"); +} + +#[test] +fn progress_flush_failure_does_not_wait_for_input_eof() { + run_shutdown_case("progress-flush"); +} + +fn run_shutdown_case(mode: &str) { + let mut child = Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "server::progress::shutdown_tests::output_failure_child", "--ignored", "--nocapture"]) + .env("LIGHTCRAFT_MCP_SHUTDOWN_CASE", mode) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + let mut input = child.stdin.take().unwrap(); + if mode != "retained-hook" { + let request = if mode.starts_with("progress") { + json!({"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"export","arguments":{"dir":"unused"},"_meta":{"progressToken":"photos"}}}) + } else { + json!({"jsonrpc":"2.0","id":2,"method":"ping"}) + }; + let initialize = json!({"jsonrpc":"2.0","id":0,"method":"initialize"}); + let requests = format!("{initialize}\n{request}\n{{\"jsonrpc\":"); + input.write_all(requests.as_bytes()).unwrap(); + input.flush().unwrap(); + } + let deadline = Instant::now() + Duration::from_secs(5); + let status = loop { + if let Some(status) = child.try_wait().unwrap() { + break status; + } + if Instant::now() >= deadline { + child.kill().unwrap(); + child.wait().unwrap(); + panic!("{mode}: MCP shutdown waited for input EOF"); + } + std::thread::sleep(Duration::from_millis(10)); + }; + let mut stderr = String::new(); + child.stderr.take().unwrap().read_to_string(&mut stderr).unwrap(); + assert!(status.success(), "{mode}: {status}: {stderr}"); + assert!(stderr.contains("MCP shutdown verified"), "child did not exercise shutdown: {stderr}"); + drop(input); +} + +#[test] +fn retained_progress_hook_does_not_keep_output_alive() { + run_shutdown_case("retained-hook"); +} + +#[test] +fn input_errors_and_eof_keep_their_meaning() { + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = server.serve(Cursor::new(vec![0xff]), std::io::sink()).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::InvalidData); + assert!(server.wire.is_none()); + server.serve(Cursor::new(b""), std::io::sink()).unwrap(); +} + +#[test] +fn reader_panic_is_an_input_error() { + struct PanickingRead; + impl Read for PanickingRead { + fn read(&mut self, _: &mut [u8]) -> std::io::Result<usize> { + panic!("synthetic input panic"); + } + } + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let error = server.serve(BufReader::new(PanickingRead), std::io::sink()).unwrap_err(); + assert_eq!(error.to_string(), "MCP input thread panicked"); +} + +#[test] +fn output_error_takes_precedence_over_input_error() { + let mut server = Server::new(Box::new(ExportUntilCancelled { hook: None, retain_hook: false })); + let mut input = b"{\"jsonrpc\":\"2.0\",\"id\":1,\"method\":\"initialize\"}\n".to_vec(); + input.push(0xff); + let error = server.serve(Cursor::new(input), FailedOutput { flush: false, initialized: true }).unwrap_err(); + assert_eq!(error.kind(), std::io::ErrorKind::Other); + assert_eq!(error.to_string(), "synthetic output failure"); +} diff --git a/crates/mcp/src/server.rs b/crates/mcp/src/server.rs index 4565db76e..de086ca70 100644 --- a/crates/mcp/src/server.rs +++ b/crates/mcp/src/server.rs @@ -84,7 +84,9 @@ impl Server { } /// Serve newline-delimited JSON-RPC until `input` closes. Logs go to stderr only (stdout is - /// the protocol stream). + /// the protocol stream). The scoped input worker must reach EOF or an input error before + /// this returns, including on output failure: callers must unblock their own input. + /// For a process serving stdin, use [`Self::serve_stdio`] instead. pub fn serve(&mut self, input: impl BufRead + Send, output: impl Write + Send) -> std::io::Result<()> { self.serve_wire(input, output) } diff --git a/crates/mcp/src/tools.rs b/crates/mcp/src/tools.rs index b71e3d162..3265b36f9 100644 --- a/crates/mcp/src/tools.rs +++ b/crates/mcp/src/tools.rs @@ -235,10 +235,11 @@ pub fn helper_tools(has_ui: bool) -> Vec<Value> { "preset": {"type": "string", "description": "Export preset id or name"}, "background": {"type": "boolean", "description": "Desktop background export (headless exports remain blocking)"}, "resize": {"type": "object", "description": "Full Resize options; alternatively use the size helpers below"}, - "subfolder": {"type": "string"}, + "destinationMode": {"type": "string", "enum": ["specificFolder", "sameAsOriginal"], "description": "specificFolder (default) = everything into `dir`; sameAsOriginal = each photo into the folder of its original (+ `subfolder`); a photo with no folder on disk (demo, browser) or whose folder is gone gets an error"}, + "subfolder": {"type": "string", "description": "Created inside the destination folder"}, "conflict": {"type": "string", "enum": ["unique", "overwrite", "skip"]}, "tiffCompression": {"type": "string", "enum": ["none", "lzw", "zip"]}, - "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed"]}, + "dngCompression": {"type": "string", "enum": ["lossless", "deflate", "uncompressed", "lossy"]}, "format": {"type": "string", "enum": ["jpeg", "png", "tiff", "psd", "webp", "avif", "jxl", "original", "dng", "rendereddng"], "description": "psd = a flat PSD (no layers); rendereddng = Render to DNG, a linear 16-bit float DNG of the rendered photo with the edit baked in; original = the file as is + an XMP sidecar with the edits; dng = raw photos as DNG with the edits embedded"}, "longEdge": {"type": "integer", "description": "Output long edge in px. When no size param is given: 3000; 0 = full size (cropped, native resolution)"}, "shortEdge": {"type": "integer", "description": "Output short edge in px"}, @@ -748,6 +749,7 @@ pub fn call_tool(b: &mut dyn Backend, name: &str, args: &Value) -> ToolResult { "preset", "background", "resize", + "destinationMode", "subfolder", "conflict", "tiffCompression", diff --git a/crates/merge/src/frame.rs b/crates/merge/src/frame.rs index 9ce00a505..1316bfd13 100644 --- a/crates/merge/src/frame.rs +++ b/crates/merge/src/frame.rs @@ -6,7 +6,7 @@ use lightcraft_geom::Orientation; use lightcraft_meta::Metadata; use lightcraft_raster::Rgb32f; use lightcraft_raster::resample::{Filter, fit}; -use lightcraft_raw::{ColorData, Opcode}; +use lightcraft_raw::{ColorData, Opcode, RawImage}; use crate::{MergeError, Result}; @@ -19,6 +19,21 @@ pub enum FrameColor { Linear { to_xyz_d50: Mat3 }, } +impl FrameColor { + /// Make `multipliers` (green = 1) the as-shot white balance of camera colour. A DNG says it as `AsShotNeutral`, the + /// reciprocal of the multipliers. The source's `AsShotWhiteXY` goes: a reader takes it before the neutral and the + /// DNG spec has the two exclude each other, so a stale one would hide the white balance set here. Linear colour has + /// no white balance to set, and multipliers that aren't finite and above zero change nothing. + pub fn set_as_shot_multipliers(&mut self, multipliers: [f32; 3]) { + if let FrameColor::Camera(c) = self + && multipliers.iter().all(|v| v.is_finite() && *v > 0.0) + { + c.as_shot_neutral = Some(multipliers.map(|v| 1.0 / f64::from(v))); + c.as_shot_white_xy = None; + } + } +} + /// One input photo. #[derive(Clone, Debug)] pub struct Frame { @@ -40,6 +55,9 @@ pub struct Frame { /// in order), re-expressed for `image` (the default crop, not yet turned), for the merged DNG to carry. Empty for /// other sources and once [`Frame::orient`] has turned the pixels. pub lens: Vec<Opcode>, + /// Raw files: the as-shot white-balance multipliers from the vendor's maker notes (green = 1), for formats such + /// as CR2 and NEF that have no `AsShotNeutral` of their own. `None` when the file doesn't say. + pub wb_multipliers: Option<[f32; 3]>, } impl Frame { @@ -58,6 +76,7 @@ impl Frame { metadata: Metadata::default(), baseline_exposure: 0.0, lens: Vec::new(), + wb_multipliers: None, } } @@ -87,6 +106,29 @@ pub fn exif_exposure(m: &Metadata) -> Option<f64> { Some((t * iso / (n * n)).log2()) } +/// The as-shot white balance `frames` agree on, as multipliers with green = 1: the median R/G and the median B/G of +/// the frames that have usable multipliers (all three finite and above zero), so one frame the camera balanced +/// differently doesn't move it. With an even number of voters the median is the mean of the middle two. Frames without +/// usable multipliers don't vote; `None` when none does. +pub fn consensus_multipliers<'a>(frames: impl IntoIterator<Item = &'a Frame>) -> Option<[f32; 3]> { + let (mut r_over_g, mut b_over_g) = (Vec::new(), Vec::new()); + for [r, g, b] in frames.into_iter().filter_map(|f| f.wb_multipliers) { + if [r, g, b].iter().all(|v| v.is_finite() && *v > 0.0) { + r_over_g.push(f64::from(r) / f64::from(g)); + b_over_g.push(f64::from(b) / f64::from(g)); + } + } + Some([median(r_over_g)? as f32, 1.0, median(b_over_g)? as f32]) +} + +/// The median of `v` (the mean of the middle two when the count is even); `None` when empty. +fn median(mut v: Vec<f64>) -> Option<f64> { + v.sort_by(f64::total_cmp); + let upper = *v.get(v.len() / 2)?; + let lower = *v.get(v.len().checked_sub(1)? / 2)?; + Some((lower + upper) / 2.0) +} + /// The saturation level of a frame: 1.0, or the (lower) level at which values pile up when the /// file's white level is optimistic. Returned slightly below the level. pub fn detect_clip(img: &Rgb32f) -> f32 { @@ -125,56 +167,8 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo let metadata = lightcraft_meta::extract(bytes); let exposure = exif_exposure(&metadata); if lightcraft_raw::probe(bytes).is_some() { - let mut raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; - // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and - // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps. - // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional. - let (aw, ah) = (raw.active_area.width, raw.active_area.height); - let crop = raw.develop_crop(aw, ah); - let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3) - .filter(|op| op.is_lens_correction()) - .filter_map(|op| op.cropped(aw, ah, crop)) - .map(|op| match op { - Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => { - Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false } - } - op => op, - }) - .collect(); - raw.opcodes.list3.retain(|op| !op.is_lens_correction()); - if let Some(color) = colour(&mut raw, bytes) { - raw.color = color; - } - let small = max_edge.is_some_and(|m| m <= 1200); - let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; - let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; - for p in image.data.iter_mut() { - *p = p.map(|v| v.max(0.0)); - } - let clip = detect_clip(&image); - if let Some(m) = max_edge - && image.width.max(image.height) > m - { - image = fit(&image, m, m, Filter::Box); - } - let mut f = Frame { - image, - clip, - exposure, - raw: true, - color: FrameColor::Camera(Box::new(raw.color.clone())), - orientation: raw.orientation, - metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, - baseline_exposure: raw.color.baseline_exposure, - lens, - }; - if f.exposure.is_none() { - f.exposure = exif_exposure(&f.metadata); - } - if orient { - f.orient(); - } - return Ok(f); + let raw = lightcraft_raw::decode(bytes).map_err(|e| MergeError::Decode(e.to_string()))?; + return raw_frame(raw, bytes, metadata, exposure, max_edge, orient, colour); } let opts = match max_edge { Some(m) => lightcraft_codecs::DecodeOptions::fit(m as u32, m as u32), @@ -198,6 +192,7 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo metadata, baseline_exposure: 0.0, lens: Vec::new(), + wb_multipliers: None, }; if orient { f.orient(); @@ -205,6 +200,67 @@ pub fn load_frame_with(bytes: &[u8], max_edge: Option<usize>, orient: bool, colo Ok(f) } +/// A decoded raw (from `bytes`, which `colour` may read) as a frame: developed to camera RGB, with its colour tags, +/// its as-shot white-balance multipliers and its orientation. +fn raw_frame( + mut raw: RawImage, + bytes: &[u8], + metadata: Metadata, + exposure: Option<f64>, + max_edge: Option<usize>, + orient: bool, + colour: &RawColour, +) -> Result<Frame> { + // Lens corrections stay with the develop settings ("profile corrections"): frames are merged uncorrected, and + // the output DNG carries the reference frame's corrections, re-expressed for the default crop `develop` keeps. + // The DNG 1.6 skip rule is applied here, so no `WarpRectilinear2` is left optional. + let (aw, ah) = (raw.active_area.width, raw.active_area.height); + let crop = raw.develop_crop(aw, ah); + let lens = lightcraft_raw::opcodes::applied_ops(&raw.opcodes.list3) + .filter(|op| op.is_lens_correction()) + .filter_map(|op| op.cropped(aw, ah, crop)) + .map(|op| match op { + Opcode::WarpRectilinear2 { planes, center, reciprocal, .. } => Opcode::WarpRectilinear2 { planes, center, reciprocal, optional: false }, + op => op, + }) + .collect(); + raw.opcodes.list3.retain(|op| !op.is_lens_correction()); + if let Some(color) = colour(&mut raw, bytes) { + raw.color = color; + } + let small = max_edge.is_some_and(|m| m <= 1200); + let method = if small { lightcraft_raw::Method::Bilinear } else { lightcraft_raw::Method::Ahd }; + let mut image = raw.develop(method).map_err(|e| MergeError::Decode(e.to_string()))?; + for p in image.data.iter_mut() { + *p = p.map(|v| v.max(0.0)); + } + let clip = detect_clip(&image); + if let Some(m) = max_edge + && image.width.max(image.height) > m + { + image = fit(&image, m, m, Filter::Box); + } + let mut f = Frame { + image, + clip, + exposure, + raw: true, + color: FrameColor::Camera(Box::new(raw.color.clone())), + orientation: raw.orientation, + metadata: if raw.metadata == Metadata::default() { metadata } else { raw.metadata.clone() }, + baseline_exposure: raw.color.baseline_exposure, + lens, + wb_multipliers: raw.wb_multipliers, + }; + if f.exposure.is_none() { + f.exposure = exif_exposure(&f.metadata); + } + if orient { + f.orient(); + } + Ok(f) +} + #[cfg(test)] mod tests { use super::*; @@ -230,4 +286,126 @@ mod tests { } assert!((detect_clip(&img) - 0.9 * 0.995).abs() < 1e-6); } + + /// A 16×12 CR2-style mosaic (no colour tags, as the vendor decoders leave them) with as-shot multipliers. + fn mosaic(wb_multipliers: Option<[f32; 3]>) -> RawImage { + use lightcraft_raw::{BlackLevel, Cfa, OpcodeLists, RawData, RawFormat, Rect}; + let (w, h) = (16usize, 12usize); + RawImage { + format: RawFormat::Cr2, + width: w, + height: h, + cpp: 1, + data: RawData::U16((0..w * h).map(|i| (100 + (i * 37) % 900) as u16).collect()), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(0, 0, w, h), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers, + linearized: false, + opcodes: OpcodeLists::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + metadata: Metadata::default(), + } + } + + fn frame_of(raw: RawImage, colour: &RawColour) -> Frame { + raw_frame(raw, &[], Metadata::default(), None, None, true, colour).unwrap() + } + + /// Issue #1144: the as-shot multipliers of a CR2, NEF… (its only white balance) were dropped on loading, so + /// nothing downstream could tell a merged DNG what the camera had balanced to. + #[test] + fn a_raw_frame_carries_the_as_shot_multipliers() { + let wb = [2.05, 1.0, 1.62]; + let f = frame_of(mosaic(Some(wb)), &|_, _| None); + assert!(f.raw); + assert_eq!(f.wb_multipliers, Some(wb)); + // colour tags the caller works out leave them alone + let own = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.6]), ..Default::default() }; + let f = frame_of(mosaic(Some(wb)), &|_, _| Some(own.clone())); + assert_eq!(f.wb_multipliers, Some(wb)); + assert_eq!(f.color, FrameColor::Camera(Box::new(own))); + // a file that gives none gives none + assert_eq!(frame_of(mosaic(None), &|_, _| None).wb_multipliers, None); + assert_eq!(Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).wb_multipliers, None); + } + + fn voter(wb_multipliers: Option<[f32; 3]>) -> Frame { + Frame { wb_multipliers, ..Frame::from_linear_rec2020(Rgb32f::new(2, 2), None) } + } + + fn consensus(wb: &[Option<[f32; 3]>]) -> Option<[f32; 3]> { + let frames: Vec<Frame> = wb.iter().map(|m| voter(*m)).collect(); + consensus_multipliers(&frames) + } + + fn close(a: [f32; 3], b: [f32; 3]) -> bool { + a.iter().zip(b).all(|(x, y)| (x - y).abs() < 1e-6) + } + + #[test] + fn the_consensus_is_the_median_of_the_voters() { + // one voter is its own median + assert_eq!(consensus(&[Some([2.0, 1.0, 1.6])]), Some([2.0, 1.0, 1.6])); + // an odd count takes the middle one of each ratio, whichever frame it came from, whatever the order + let odd = [Some([2.4, 1.0, 1.4]), Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&odd), Some([2.0, 1.0, 1.6])); + // an outlier doesn't pull it + let outlier = [Some([2.0, 1.0, 1.6]), Some([2.1, 1.0, 1.5]), Some([1.9, 1.0, 1.7]), Some([5.0, 1.0, 0.7]), Some([2.0, 1.0, 1.6])]; + assert_eq!(consensus(&outlier), Some([2.0, 1.0, 1.6])); + // an even count takes the mean of the middle two + let even = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + assert!(close(consensus(&even).unwrap(), [2.1, 1.0, 1.55]), "{:?}", consensus(&even)); + let two = [Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])]; + assert!(close(consensus(&two).unwrap(), [2.5, 1.0, 1.5]), "{:?}", consensus(&two)); + // green is the unit even when a file's multipliers aren't normalised to it + assert!(close(consensus(&[Some([4.0, 2.0, 3.0])]).unwrap(), [2.0, 1.0, 1.5])); + } + + #[test] + fn frames_without_usable_multipliers_do_not_vote() { + assert_eq!(consensus(&[]), None); + assert_eq!(consensus(&[None, None]), None); + let bad = |m: [f32; 3]| consensus(&[Some(m)]); + assert_eq!(bad([0.0, 1.0, 1.5]), None); + assert_eq!(bad([2.0, 0.0, 1.5]), None); + assert_eq!(bad([2.0, 1.0, -1.5]), None); + assert_eq!(bad([f32::NAN, 1.0, 1.5]), None); + assert_eq!(bad([2.0, f32::INFINITY, 1.5]), None); + assert_eq!(bad([2.0, 1.0, f32::NEG_INFINITY]), None); + // mixed: the good ones decide, the missing and the unusable ones are not counted as anything + let mixed = [None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0]), Some([2.2, 1.0, 1.4]), Some([0.0, 0.0, 0.0])]; + assert!(close(consensus(&mixed).unwrap(), [2.1, 1.0, 1.5]), "{:?}", consensus(&mixed)); + assert_eq!(consensus(&[None, Some([2.0, 1.0, 1.6]), None]), Some([2.0, 1.0, 1.6])); + } + + #[test] + fn multipliers_become_the_reciprocal_as_shot_neutral() { + use lightcraft_color::{D50, Xy}; + let tagged = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.9]), as_shot_white_xy: Some(Xy::new(0.34, 0.35)), ..Default::default() }; + let mut color = FrameColor::Camera(Box::new(tagged.clone())); + color.set_as_shot_multipliers([2.0, 1.0, 1.6]); + let FrameColor::Camera(c) = &color else { panic!("still camera colour") }; + assert_eq!(c.as_shot_neutral, Some([0.5, 1.0, 1.0 / f64::from(1.6f32)])); + assert_eq!(c.as_shot_white_xy, None, "a stale AsShotWhiteXY would be read before the neutral"); + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..tagged.clone() }, "nothing else changes"); + // multipliers that aren't usable change nothing + for bad in [[0.0, 1.0, 1.0], [2.0, f32::NAN, 1.6], [2.0, 1.0, f32::INFINITY], [-2.0, 1.0, 1.6]] { + let mut color = FrameColor::Camera(Box::new(ColorData { as_shot_white_xy: Some(D50), ..Default::default() })); + let before = color.clone(); + color.set_as_shot_multipliers(bad); + assert_eq!(color, before, "{bad:?}"); + } + // linear colour has no white balance to set + let mut linear = Frame::from_linear_rec2020(Rgb32f::new(2, 2), None).color; + let before = linear.clone(); + linear.set_as_shot_multipliers([2.0, 1.0, 1.6]); + assert_eq!(linear, before); + } } diff --git a/crates/merge/src/hdr.rs b/crates/merge/src/hdr.rs index 59aa5286c..dd1a9ee53 100644 --- a/crates/merge/src/hdr.rs +++ b/crates/merge/src/hdr.rs @@ -1,20 +1,24 @@ //! HDR merge of exposure brackets. //! //! 1. **Order and reference.** Frames are ordered by brightness (EXIF exposure, or median -//! luminance without EXIF); the middle one is the reference (geometry, colour and the output's -//! default brightness come from it). +//! luminance without EXIF; ties by content); the middle one is the reference (geometry, colour +//! and the output's default brightness come from it). Everything below runs in that order, so +//! the result is the same whatever order the frames come in. //! 2. **Auto align** ([`crate::align`]): each frame is registered to the reference and resampled //! into its geometry. //! 3. **Exposure normalisation.** The exposure ratio between brightness-adjacent frames is //! measured robustly — the median of log ratios over pixels well exposed in both — and chained //! to the reference; EXIF is the fallback when too few pixels overlap. Radiance //! `Rᵢ = vᵢ / kᵢ` is expressed in reference units. -//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel))`: longer -//! exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels none -//! (all channels together, so a clipped channel never shifts colour). The darkest frame keeps a -//! tiny weight so fully saturated pixels still get a value. -//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 1024 px version, a per-pixel consistency test -//! against the reference: the reference radiance (or, where the reference is clipped/too dark, +//! 4. **Weights.** `wᵢ = kᵢ · (1 − smoothstep(0.80·clip, 0.96·clip, max channel within 1 px))`: +//! longer exposures get more weight (shot-noise optimal for a linear sensor), saturated pixels +//! none (all channels together, so a clipped channel never shifts colour), nor their neighbours, +//! which demosaicing interpolated from clipped photosites. The darkest frame keeps a tiny weight +//! so fully saturated pixels still get a value. Where the reference is at or near its clip, the scene is +//! at least that bright: a frame reading far darker there saw something that moved (a star, the moon, a light) and is +//! left out, so a clipped highlight of the reference is never replaced by content from another moment. +//! 5. **Deghost** (None/Low/Medium/High): on a ≤ 2048 px version, a per-pixel consistency test +//! against the reference: the reference radiance (or, where the reference is clipped or black, //! the nearest well-exposed frame's) predicts what each frame should have recorded; frames that //! disagree by more than a threshold (in log units, with a noise floor) are masked there. Masks //! are dilated and feathered (Gaussian), upsampled, and remove the frame's weight. Their union @@ -26,7 +30,7 @@ use rayon::prelude::*; use serde::{Deserialize, Serialize}; use crate::align::{self, Alignment}; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::{MergeError, Progress, Result, check}; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -85,8 +89,15 @@ pub struct HdrResult { pub baseline_exposure: f64, /// The reference frame's lens corrections, for `radiance` ([`Frame::lens`]). pub lens: Vec<lightcraft_raw::Opcode>, + /// The inputs' as-shot white-balance multipliers they agree on ([`crate::frame::consensus_multipliers`]), already in + /// `color`; kept so merged brackets stitched into a panorama can vote as frames of their own. + pub wb_multipliers: Option<[f32; 3]>, } +/// Long edge (at most) of the frames the deghost test compares: small moving things (a star at 200 mm) still span a few +/// pixels there. +const DEGHOST_EDGE: usize = 2048; + #[inline] fn smoothstep(a: f32, b: f32, x: f32) -> f32 { let t = ((x - a) / (b - a)).clamp(0.0, 1.0); @@ -127,9 +138,25 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) Some(e) if e.iter().any(|v| (v - e[0]).abs() > 0.1) => e.clone(), _ => medians.iter().map(|m| (*m as f64).log2()).collect(), }; - let mut order: Vec<usize> = (0..n).collect(); - order.sort_by(|&a, &b| brightness[a].total_cmp(&brightness[b]).then(a.cmp(&b))); - let reference = order[n / 2]; + // ties (equal brightness) by content, never by position + let fingerprint: Vec<f64> = + frames.par_iter().map(|f| f.image.data.iter().step_by(997).map(|p| p[0] as f64 + 2.0 * p[1] as f64 + 3.0 * p[2] as f64).sum()).collect(); + let mut input: Vec<usize> = (0..n).collect(); + input.sort_by(|&a, &b| { + brightness[a].total_cmp(&brightness[b]).then(medians[a].total_cmp(&medians[b])).then(fingerprint[a].total_cmp(&fingerprint[b])) + }); + // From here on frames are in brightness order (`input[p]` is the caller's index of position `p`): alignment seeds, + // sums and deghost masks no longer depend on the order the caller passed. Per-frame results go back at the end. + let mut slots: Vec<Option<Frame>> = frames.into_iter().map(Some).collect(); + let mut frames: Vec<Frame> = input.iter().filter_map(|&i| slots.get_mut(i).and_then(Option::take)).collect(); + if frames.len() != n { + return Err(MergeError::Mismatch("HDR merge lost a frame while ordering them".into())); + } + let exif: Option<Vec<f64>> = exif.map(|e| input.iter().map(|&i| e[i]).collect()); + let medians: Vec<f32> = input.iter().map(|&i| medians[i]).collect(); + let brightness: Vec<f64> = input.iter().map(|&i| brightness[i]).collect(); + let order: Vec<usize> = (0..n).collect(); + let reference = n / 2; // 2. alignment let mut alignments = vec![Alignment::identity(); n]; @@ -161,15 +188,9 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) alignments[i] = a; } check(progress, 0.35, "Resampling")?; - let warped: Vec<(usize, Rgb32f, Vec<bool>)> = (0..n) - .into_par_iter() - .filter(|&i| i != reference && alignments[i].model != "identity") - .map(|i| { - let (img, v) = align::warp(&frames[i].image, &alignments[i].h); - (i, img, v) - }) - .collect(); - for (i, img, v) in warped { + // one frame at a time (the warp is parallel inside): each warped copy replaces its frame before the next is made + for i in (0..n).filter(|&i| i != reference && alignments[i].model != "identity") { + let (img, v) = align::warp(&frames[i].image, &alignments[i].h); frames[i].image = img; valid[i] = Some(v); } @@ -206,6 +227,8 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) let darkest = order[0]; let clips: Vec<f32> = frames.iter().map(|f| f.clip).collect(); + // how close each pixel of each frame is to saturation: its brightest channel within 1 px (see `saturation`) + let sat: Vec<Vec<f32>> = frames.par_iter().map(|f| saturation(&f.image)).collect(); let mut out = Rgb32f::new(w, h); let fr = &frames; let vd = &valid; @@ -215,18 +238,24 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) let i_px = y * w + x; let mut acc = [0f64; 3]; let mut ws = 0f64; + // the reference at or near its clip: the radiance is at least this + let mr = maxc(fr[reference].image.data[i_px]); + let bound = if mr >= 0.80 * clips[reference] { mr / k[reference] } else { 0.0 }; for i in 0..n { if vd[i].as_ref().is_some_and(|v| !v[i_px]) { continue; } let v = fr[i].image.data[i_px]; - let m = maxc(v); + let m = sat[i][i_px]; let mut wt = k[i] * (1.0 - smoothstep(0.80 * clips[i], 0.96 * clips[i], m)); if i == darkest { wt += 1e-6; } if i == reference { wt += 1e-9; + } else if bound > 0.0 { + // a frame far darker than that saw something that moved there + wt *= smoothstep(0.35, 0.6, m / k[i] / bound); } if i != reference && let Some((maps, fac)) = gs @@ -266,20 +295,41 @@ pub fn merge_hdr(mut frames: Vec<Frame>, opts: &HdrOptions, progress: &Progress) } None => Plane::new(0, 0), }; + // per-frame results in the caller's order + let mut ev_in = vec![0.0; n]; + let mut alignments_in = vec![Alignment::identity(); n]; + for (p, &i) in input.iter().enumerate() { + ev_in[i] = ev[p]; + alignments_in[i] = alignments[p].clone(); + } + let ev_exif = ev_exif.map(|e: Vec<f64>| { + let mut v = vec![0.0; n]; + for (p, &i) in input.iter().enumerate() { + v[i] = e[p]; + } + v + }); let rf = &frames[reference]; + // the merge's white balance is the median of its inputs', as a panorama's: a CR2's or NEF's colour tags have none + let wb_multipliers = consensus_multipliers(&frames); + let mut color = rf.color.clone(); + if let Some(multipliers) = wb_multipliers { + color.set_as_shot_multipliers(multipliers); + } Ok(HdrResult { radiance: out, ghost, - reference, - ev, + reference: input[reference], + ev: ev_in, ev_exif, - alignments, - color: rf.color.clone(), + alignments: alignments_in, + color, raw: rf.raw, orientation: rf.orientation, metadata: rf.metadata.clone(), baseline_exposure: rf.baseline_exposure, lens: rf.lens.clone(), + wb_multipliers, }) } @@ -362,35 +412,40 @@ fn deghost_maps( ) -> (Vec<Plane>, usize) { let n = frames.len(); let (w, h) = (frames[0].width(), frames[0].height()); - let fac = w.max(h).div_ceil(1024).max(1); + let fac = w.max(h).div_ceil(DEGHOST_EDGE).max(1); + // the dilation/feather radius was chosen for a 1024 px map: keep it the same share of the picture + let r = (r as f32 * (w.max(h) as f32 / fac as f32 / 1024.0).max(1.0)).round() as usize; let small: Vec<(Rgb32f, Vec<f32>)> = (0..n).into_par_iter().map(|i| shrink(&frames[i].image, valid[i].as_deref(), fac)).collect(); let (sw, sh) = (small[0].0.width, small[0].0.height); let rpos = order.iter().position(|&i| i == reference).unwrap_or(0); - let good = |i: usize, p: usize| -> bool { + // `dark`: the share of the clip below which a frame's (averaged) pixel is too noisy to predict from + let good = |i: usize, p: usize, dark: f32| -> bool { let f = &frames[i]; let m = maxc(small[i].0.data[p]); - let dark = if f.raw { 0.01 } else { 0.04 }; small[i].1[p] > 0.99 && m < 0.8 * f.clip && m > dark * f.clip }; + let dark = |i: usize| if frames[i].raw { 0.01 } else { 0.04 }; // predicted reference radiance per pixel let pred: Vec<[f32; 3]> = (0..sw * sh) .into_par_iter() .map(|p| { let rad = |i: usize| small[i].0.data[p].map(|v| v / k[i]); - if good(reference, p) { + // the reference predicts down to a tenth of the other frames' floor (the map's averaging tames its noise): in a + // dark sky the brighter frames must not predict themselves, or their ghosts would look consistent + if good(reference, p, dark(reference) * 0.1) { return rad(reference); } let bright = maxc(small[reference].0.data[p]) >= 0.8 * frames[reference].clip; if bright { for q in (0..rpos).rev() { - if good(order[q], p) { + if good(order[q], p, dark(order[q])) { return rad(order[q]); } } rad(order[0]) } else { for &i in &order[rpos + 1..] { - if good(i, p) { + if good(i, p, dark(i)) { return rad(i); } } @@ -450,3 +505,29 @@ fn dilate(p: &Plane, r: usize) -> Plane { } out } + +/// A frame's saturation per pixel: the brightest channel within 1 px (3 × 3). A demosaiced pixel next to clipped +/// photosites is interpolated from them and reads low in the channel that clipped (green, in camera RGB), so it loses +/// its weight with them. On real ARW brackets the brightest frame's green was 3–5 % low within 2 px of clipped pixels +/// (and linear to 1 % elsewhere), and as that frame carries the most weight, bright windows got magenta fringes. +fn saturation(img: &Rgb32f) -> Vec<f32> { + let (w, h) = (img.width, img.height); + let m: Vec<f32> = img.data.iter().map(|p| maxc(*p)).collect(); + if w == 0 || h == 0 { + return m; + } + let mut rows = vec![0f32; w * h]; + rows.par_chunks_mut(w).zip(m.par_chunks(w)).for_each(|(out, src)| { + for (x, o) in out.iter_mut().enumerate() { + *o = src[x.saturating_sub(1)..=(x + 1).min(w - 1)].iter().copied().fold(0.0, f32::max); + } + }); + let mut out = vec![0f32; w * h]; + out.par_chunks_mut(w).enumerate().for_each(|(y, row)| { + let (a, b) = (y.saturating_sub(1), (y + 1).min(h - 1)); + for (x, v) in row.iter_mut().enumerate() { + *v = (a..=b).map(|yy| rows[yy * w + x]).fold(0.0, f32::max); + } + }); + out +} diff --git a/crates/merge/src/output.rs b/crates/merge/src/output.rs index f8b7e7173..2d0a523f4 100644 --- a/crates/merge/src/output.rs +++ b/crates/merge/src/output.rs @@ -4,8 +4,9 @@ //! - Pixel values are scaled so the brightest pixel sits just below 1.0 (the white level); the //! scale is carried in `BaselineExposure`, so a default rendering looks like the reference frame. //! - Raw sources: the reference frame's DNG colour tags (`ColorMatrix*`, `ForwardMatrix*`, -//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over unchanged — the data is -//! still camera RGB. +//! `CameraCalibration*`, `AsShotNeutral`, illuminants) are carried over — the data is still camera +//! RGB. A raw with its white balance only in vendor multipliers (CR2, NEF…) has none of its own: the +//! result's `AsShotNeutral` comes from the frames' multipliers ([`FrameColor::set_as_shot_multipliers`]). //! - Other sources: the "camera" is the file's linear RGB space; `ColorMatrix1` = XYZ(D65) → RGB, //! `ForwardMatrix1` = RGB → XYZ(D50), `AsShotNeutral` = (1, 1, 1). //! - Lens corrections (an HDR merge's: the reference frame's, [`crate::Frame::lens`]) go in `OpcodeList3`, where the @@ -111,8 +112,10 @@ pub fn write_linear_dng( color: cd, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: lens.to_vec(), ..Default::default() }, metadata: meta, + lens_metadata: Default::default(), }; let compression = DngCompression::Deflate { tile: 256, half: samples == DngSamples::Half }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression, ..Default::default() }).map_err(|e| MergeError::Output(e.to_string())) diff --git a/crates/merge/src/pano/mod.rs b/crates/merge/src/pano/mod.rs index 309505614..4211f6503 100644 --- a/crates/merge/src/pano/mod.rs +++ b/crates/merge/src/pano/mod.rs @@ -25,7 +25,7 @@ use serde::{Deserialize, Serialize}; pub use project::Projection; -use crate::frame::{Frame, FrameColor}; +use crate::frame::{Frame, FrameColor, consensus_multipliers}; use crate::linalg; use crate::{MergeError, Progress, Result, check}; use blend::{Blender, Buf, push_pull}; @@ -39,16 +39,23 @@ pub struct PanoOptions { pub boundary_warp: f64, pub auto_crop: bool, pub fill_edges: bool, - /// Output size limit (megapixels); the panorama is scaled down to fit. + /// Output size limit (megapixels); the panorama is scaled down to fit. At most + /// [`MAX_MEGAPIXELS`] (the default: the photos' own resolution). pub max_megapixels: f64, } impl Default for PanoOptions { fn default() -> Self { - PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: 40.0 } + PanoOptions { projection: Projection::Auto, boundary_warp: 0.0, auto_crop: false, fill_edges: false, max_megapixels: MAX_MEGAPIXELS } } } +/// The largest panorama, in megapixels (Lightroom's limit too); a bigger one is scaled down. +pub const MAX_MEGAPIXELS: f64 = 512.0; + +/// The longest edge of a panorama, in pixels (Lightroom's limit too); a longer one is scaled down. +pub const MAX_EDGE: f64 = 65_000.0; + /// The stitched panorama. #[derive(Clone, Debug)] pub struct PanoResult { @@ -71,6 +78,8 @@ pub struct PanoResult { pub rms: f64, /// Horizontal and vertical field of view (degrees). pub fov: (f64, f64), + /// The panorama's size at the photos' own resolution (megapixels), before any limit. + pub native_megapixels: f64, /// Output focal length and the surface coordinates of the canvas's top-left corner: canvas /// pixel (x, y) (before boundary warp) shows surface point (x + 0.5 + origin.0, y + 0.5 + origin.1). pub focal: f64, @@ -210,7 +219,7 @@ pub fn register(frames: &[Frame], progress: &Progress) -> Result<(Vec<Option<Cam } /// Stitch `frames` (oriented, linear) into a panorama. -pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> { +pub fn stitch(mut frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Result<PanoResult> { let n = frames.len(); if n < 2 { return Err(MergeError::TooFew(n, 2)); @@ -231,9 +240,9 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re if projection == Projection::Perspective && (bx1 - bx0) * (by1 - by0) > 12.0 * area { return Err(MergeError::NoOverlap(format!("the field of view ({:.0}°) is too wide for a perspective projection", fov.0))); } - // output scale: native resolution, limited to max_megapixels - let max_px = (opts.max_megapixels.max(0.1) * 1e6).min(400e6); - let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(1.0); + // output scale: native resolution, limited to max_megapixels and the longest edge + let max_px = if opts.max_megapixels.is_nan() { MAX_MEGAPIXELS } else { opts.max_megapixels.clamp(0.1, MAX_MEGAPIXELS) } * 1e6; + let s = (max_px / ((bx1 - bx0) * (by1 - by0))).sqrt().min(MAX_EDGE / (bx1 - bx0).max(by1 - by0)).min(1.0); let f_out = fmed * s; let (ox, oy) = (bx0 * s, by0 * s); let (cw, ch) = (((bx1 - bx0) * s).ceil() as usize + 1, ((by1 - by0) * s).ceil() as usize + 1); @@ -298,6 +307,11 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re .collect(); check(progress, 0.6, "Exposure compensation")?; compensate_gains(&tiles, &mut gains, &frames); + // the tiles hold every pixel the blend needs: the frames' own go now, before the blend + // takes its memory (12 bytes a pixel of every photo: 3 GB for five 51 MP frames) + for f in &mut frames { + f.image = Rgb32f::new(0, 0); + } // seams: label = max feather weight let mut best = vec![0f32; pw * ph]; @@ -362,6 +376,11 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re image = finish::fill_edges(&image, &alpha); } let r = &frames[used[0]]; + // the panorama's white balance is the median of the frames that are in it, not whichever frame came first + let mut color = r.color.clone(); + if let Some(multipliers) = consensus_multipliers(used.iter().filter_map(|&i| frames.get(i))) { + color.set_as_shot_multipliers(multipliers); + } Ok(PanoResult { image, alpha, @@ -373,9 +392,10 @@ pub fn stitch(frames: Vec<Frame>, opts: &PanoOptions, progress: &Progress) -> Re rms_before, rms, fov, + native_megapixels: (bx1 - bx0) * (by1 - by0) / 1e6, focal: f_out, origin: (ox, oy), - color: r.color.clone(), + color, metadata: r.metadata.clone(), raw: r.raw, baseline_exposure: r.baseline_exposure, diff --git a/crates/merge/tests/hdr.rs b/crates/merge/tests/hdr.rs index 68b9ba4b9..4f2ccfafb 100644 --- a/crates/merge/tests/hdr.rs +++ b/crates/merge/tests/hdr.rs @@ -125,6 +125,69 @@ fn bracket_merge_recovers_radiance_and_alignment() { assert!(rmed < 0.05, "recovered highlights: median log error {rmed}"); } +/// The result doesn't depend on the order the frames come in: every permutation of a bracket gives the same radiance and +/// deghost overlay, bit for bit, and the same per-frame results for each frame. (Real brackets merged in the app's +/// selection order and in the CLI's file order differed on 0.6 % of their pixels: alignment seeds followed the input +/// positions.) +#[test] +fn merge_does_not_depend_on_the_frame_order() { + let t = truth(); + let shifts = [(3.25f32, -2.0f32), (0.0, 0.0), (-4.0, 1.5)]; + let ks = [0.25f32, 1.0, 4.0]; + let shot: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], shifts[i].0, shifts[i].1, i as u64 + 1)).collect(); + let opts = HdrOptions { align: true, deghost: Deghost::Medium }; + let base = merge_hdr(shot.clone(), &opts, &no_progress).unwrap(); + assert_eq!(base.reference, 1); + for perm in [[2usize, 0, 1], [1, 2, 0], [0, 2, 1]] { + let r = merge_hdr(perm.iter().map(|&i| shot[i].clone()).collect(), &opts, &no_progress).unwrap(); + assert!(r.radiance.data == base.radiance.data, "{perm:?}: the radiance differs"); + assert!(r.ghost.data == base.ghost.data, "{perm:?}: the deghost overlay differs"); + assert_eq!(perm[r.reference], base.reference, "{perm:?}"); + for (p, &i) in perm.iter().enumerate() { + assert_eq!(r.ev[p], base.ev[i], "{perm:?}"); + assert_eq!(r.alignments[p].h, base.alignments[i].h, "{perm:?}"); + } + } +} + +/// Pixels demosaiced next to clipped photosites read low in the clipped channel (real ARW brackets: the brightest +/// frame's green 3–5 % low within 2 px of clipped pixels, linear to 1 % elsewhere); the merge must not take them from +/// that frame, or bright windows get magenta fringes. Here the +2 EV frame's green is 5 % low around its clipped areas. +#[test] +fn pixels_next_to_clipped_ones_do_not_tint_the_merge() { + let t = truth(); + let ks = [0.25f32, 1.0, 4.0]; + let mut frames: Vec<Frame> = (0..3).map(|i| shoot(&t, ks[i], 0.0, 0.0, i as u64 + 1)).collect(); + let (w, h) = (frames[2].image.width, frames[2].image.height); + let clipped = |img: &Rgb32f, x: usize, y: usize| img.data[y * w + x].iter().any(|&v| v >= 1.0); + let mut ring = vec![false; w * h]; + for y in 1..h - 1 { + for x in 1..w - 1 { + let img = &frames[2].image; + ring[y * w + x] = !clipped(img, x, y) && (y - 1..=y + 1).any(|yy| (x - 1..=x + 1).any(|xx| clipped(img, xx, yy))); + } + } + for (p, &r) in frames[2].image.data.iter_mut().zip(&ring) { + if r { + p[1] *= 0.95; + } + } + let r = merge_hdr(frames, &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + let ratio = |p: [f32; 3]| p[1] / (p[0] + p[2]); + let mut devs = Vec::new(); + for y in 1..h - 1 { + for x in 1..w - 1 { + let want = t.get(x + M, y + M); + if ring[y * w + x] && want[0] > 0.01 && want[2] > 0.01 { + devs.push(ratio(r.radiance.get(x, y)) / ratio(want) - 1.0); + } + } + } + assert!(devs.len() > 500, "ring pixels: {}", devs.len()); + let med = pct(devs, 0.5); + assert!(med.abs() < 0.01, "green against red and blue on the ring: {:+.2} %", 100.0 * med); +} + #[test] fn deghost_removes_a_moving_object() { let t = truth(); @@ -161,3 +224,48 @@ fn deghost_removes_a_moving_object() { let mean = high.ghost.data.iter().sum::<f32>() / high.ghost.data.len() as f32; assert!(mean < 0.08, "the overlay is local (mean {mean})"); } + +/// A dark night sky (≈ 0.3 % of the reference's white) with a soft star of peak `peak` (Gaussian, σ = 3 px) at (cx, cy) +/// in truth coordinates. +fn night_with_star(cx: f32, cy: f32, peak: f32) -> Rgb32f { + Rgb32f::from_fn(W + 2 * M, H + 2 * M, |x, y| { + let sky = 0.003 * (1.0 + 0.3 * (x as f32 / (W + 2 * M) as f32)); + let d2 = (x as f32 + 0.5 - cx).powi(2) + (y as f32 + 0.5 - cy).powi(2); + [sky + peak * (-d2 / 18.0).exp(); 3] + }) +} + +/// Night brackets of a star that moves between the frames (the sky turns during a tripod bracket): its core clips in the +/// reference and in the brighter frame, not in the darkest one. +fn moving_star_frames(xs: [f32; 3]) -> Vec<Frame> { + let ks = [0.0625f32, 1.0, 8.0]; + (0..3).map(|i| shoot(&night_with_star(xs[i], 210.0 + M as f32, 10.0), ks[i], 0.0, 0.0, 30 + i as u64)).collect() +} + +#[test] +fn a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::None }, &no_progress).unwrap(); + assert_eq!(r.reference, 1); + // the reference saw the star clipped here, so the scene is at least that bright; the frames where the star had moved + // read the dark sky there and must not replace it (which left a black hole in real night brackets) + let core = r.radiance.get(320, 210); + let m = core[0].max(core[1]).max(core[2]); + assert!(m > 0.9, "the reference's clipped star core was erased: {core:?}"); +} + +#[test] +fn deghost_removes_a_bright_frames_ghost_in_a_dark_sky() { + let xs = [280.0 + M as f32, 320.0 + M as f32, 360.0 + M as f32]; + let r = merge_hdr(moving_star_frames(xs), &HdrOptions { align: false, deghost: Deghost::High }, &no_progress).unwrap(); + // where only the brightest frame had the star, the sky is what the reference saw: no ring of its unclipped halo + let mut worst = 0f32; + for y in 196..224 { + for x in 346..374 { + let p = r.radiance.get(x, y); + worst = worst.max(p[1]); + } + } + assert!(worst < 0.02, "the brightest frame's star is left as a ghost (peak {worst})"); + assert!(r.ghost.get(360, 210) > 0.5, "the overlay marks the ghost"); +} diff --git a/crates/merge/tests/pano.rs b/crates/merge/tests/pano.rs index 402037ca0..fdb736524 100644 --- a/crates/merge/tests/pano.rs +++ b/crates/merge/tests/pano.rs @@ -245,3 +245,26 @@ fn unrelated_photos_do_not_stitch() { let frames: Vec<Frame> = a.iter().take(2).map(|s| Frame::from_linear_rec2020(s.render(400, 300), None)).collect(); assert!(stitch(frames, &PanoOptions::default(), &no_progress).is_err()); } + +/// Issue #1455: a panorama came out at 40 MP whatever its photos' resolution. It now keeps their +/// own resolution unless a size is asked for (up to Lightroom's 512 MP); a size scales it down, +/// and a NaN one (from an agent) is no size at all, not a 0.1 MP thumbnail. +#[test] +fn panoramas_keep_the_photos_resolution_unless_asked() { + assert_eq!(PanoOptions::default().max_megapixels, lightcraft_merge::pano::MAX_MEGAPIXELS); + let shots = [ + Shot { yaw: -22.0, pitch: 0.5, roll: 0.3, gain: 1.0 }, + Shot { yaw: 0.0, pitch: -0.4, roll: 0.0, gain: 1.0 }, + Shot { yaw: 21.0, pitch: 0.8, roll: -0.4, gain: 1.0 }, + ]; + let opts = |max_megapixels: f64| PanoOptions { projection: Projection::Perspective, max_megapixels, ..Default::default() }; + let mp = |r: &lightcraft_merge::pano::PanoResult| (r.image.width * r.image.height) as f64 / 1e6; + let full = run(&shots, 800.0, &PanoOptions { projection: Projection::Perspective, ..Default::default() }).0; + assert!((mp(&full) / full.native_megapixels - 1.0).abs() < 0.02, "{} MP for {} MP", mp(&full), full.native_megapixels); + let quarter = run(&shots, 800.0, &opts(full.native_megapixels / 4.0)).0; + assert!((quarter.native_megapixels / full.native_megapixels - 1.0).abs() < 1e-6); + let k = mp(&quarter) / (full.native_megapixels / 4.0); + assert!(k > 0.9 && k <= 1.02, "a quarter of the size asked: {} MP", mp(&quarter)); + let nan = run(&shots, 800.0, &opts(f64::NAN)).0; + assert_eq!((nan.image.width, nan.image.height), (full.image.width, full.image.height)); +} diff --git a/crates/merge/tests/white_balance.rs b/crates/merge/tests/white_balance.rs new file mode 100644 index 000000000..48acff6af --- /dev/null +++ b/crates/merge/tests/white_balance.rs @@ -0,0 +1,248 @@ +//! A panorama or an HDR merge of raw files keeps their as-shot white balance (issue #1144). +//! +//! CR2, NEF and the like carry their white balance as vendor multipliers, not as DNG tags. The merged DNG has +//! to say it as `AsShotNeutral`, or a viewer has no white to start from and shows a colour cast. + +use lightcraft_color::{D50, Xy}; +use lightcraft_geom::Orientation; +use lightcraft_merge::frame::{Frame, FrameColor, consensus_multipliers, detect_clip}; +use lightcraft_merge::hdr::{HdrOptions, HdrResult, merge_hdr}; +use lightcraft_merge::output::color_data; +use lightcraft_merge::pano::{PanoOptions, PanoResult, Projection, stitch}; +use lightcraft_merge::{DngSamples, no_progress, write_linear_dng}; +use lightcraft_meta::Metadata; +use lightcraft_raster::Rgb32f; +use lightcraft_raw::ColorData; + +type Multipliers = Option<[f32; 3]>; + +/// A camera that sees linear sRGB, with the colour matrices a DNG gives but no white balance of its own, as a raw +/// file whose white balance is only in the vendor multipliers would be after the app has worked its colour out. +fn camera_without_white_balance() -> ColorData { + let srgb = FrameColor::Linear { + to_xyz_d50: lightcraft_color::bradford(lightcraft_color::D65, lightcraft_color::D50).mul(&lightcraft_color::SRGB.to_xyz()), + }; + ColorData { as_shot_neutral: None, ..color_data(&srgb) } +} + +fn raw_frame(image: Rgb32f, color: &ColorData, wb_multipliers: Multipliers) -> Frame { + let clip = detect_clip(&image); + Frame { + image, + clip, + exposure: None, + raw: true, + color: FrameColor::Camera(Box::new(color.clone())), + orientation: Orientation::Normal, + metadata: Metadata::default(), + baseline_exposure: 0.0, + lens: Vec::new(), + wb_multipliers, + } +} + +/// Overlapping views of one scene, one frame per entry of `wb`, in order. +fn shoot(color: &ColorData, wb: &[Multipliers]) -> Vec<Frame> { + let n = wb.len(); + let yaws: Vec<f64> = (0..n).map(|i| (i as f64 - (n as f64 - 1.0) / 2.0) * 25.0).collect(); + let views = lightcraft_merge::synth::pano_views(400, 300, 350.0, &yaws).unwrap(); + views.into_iter().zip(wb).map(|(image, m)| raw_frame(image, color, *m)).collect() +} + +fn pano(frames: Vec<Frame>) -> PanoResult { + stitch(frames, &PanoOptions { projection: Projection::Cylindrical, ..Default::default() }, &no_progress).unwrap() +} + +fn neutral_of(res: &PanoResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +fn reciprocal(m: [f64; 3]) -> [f64; 3] { + m.map(|v| 1.0 / v) +} + +fn assert_close(got: [f64; 3], want: [f64; 3], tolerance: f64) { + assert!(got.iter().zip(want).all(|(a, b)| (a - b).abs() <= tolerance), "{got:?} vs {want:?}"); +} + +/// The panorama as a DNG and read back with the repo's own raw reader. +fn dng_of(res: &PanoResult) -> lightcraft_raw::RawImage { + let bytes = write_linear_dng(&res.image, &res.color, Orientation::Normal, &res.metadata, res.baseline_exposure, DngSamples::U16, &[]).unwrap(); + lightcraft_raw::decode(&bytes).unwrap() +} + +const DAYLIGHT: [f32; 3] = [2.0, 1.0, 1.6]; + +#[test] +fn a_panorama_of_an_odd_number_of_raw_frames_has_their_median_white_balance() { + // three frames the camera balanced slightly differently: the middle R/G and the middle B/G, from different frames + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used, [0, 1, 2]); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); +} + +#[test] +fn a_panorama_of_an_even_number_of_frames_takes_the_mean_of_the_middle_two() { + let wb = [Some([1.8, 1.0, 1.9]), Some([2.0, 1.0, 1.6]), Some([2.2, 1.0, 1.5]), Some([2.4, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 4); + let (r, b) = ((f64::from(2.0f32) + f64::from(2.2f32)) / 2.0, (f64::from(1.6f32) + f64::from(1.5f32)) / 2.0); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([r, 1.0, b]), 1e-6); + // two frames: the mean of both + let res = pano(shoot(&camera_without_white_balance(), &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); +} + +#[test] +fn frames_without_multipliers_do_not_vote() { + // one frame of three has none (a DNG, say): the other two decide + let wb = [Some([2.0, 1.0, 1.6]), None, Some([2.2, 1.0, 1.4])]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_eq!(res.used.len(), 3); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.1, 1.0, 1.5]), 1e-6); + // a first frame without them doesn't hide the others' + let wb = [None, Some(DAYLIGHT), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // unusable values don't count either + let wb = [Some([f32::NAN, 1.0, 1.0]), Some(DAYLIGHT), Some([0.0, 1.0, 1.4]), Some([2.0, f32::INFINITY, 1.6]), Some(DAYLIGHT)]; + let res = pano(shoot(&camera_without_white_balance(), &wb)); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn a_frame_outside_the_panorama_does_not_vote() { + let color = camera_without_white_balance(); + let mut frames = shoot(&color, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)]); + // four photos of other scenes, balanced very differently: counted, they would be the majority + let others = lightcraft_scenes::demo_library().into_iter().filter(|s| s.kind != lightcraft_scenes::Kind::Canyon).take(4); + frames.extend(others.map(|scene| raw_frame(scene.render(400, 300), &color, Some([4.0, 1.0, 0.8])))); + assert_eq!(frames.len(), 7); + let res = pano(frames); + assert_eq!(res.used, [0, 1, 2], "the others don't connect"); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn without_any_multipliers_the_colour_is_the_first_frames_as_before() { + for source in [ + camera_without_white_balance(), + ColorData::default(), + ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }, + ] { + let frames = shoot(&source, &[None, None, None]); + let first = frames[0].color.clone(); + let res = pano(frames); + assert_eq!(res.color, first); + assert_eq!(neutral_of(&res), source.as_shot_neutral); + } + // a DNG's own AsShotWhiteXY survives when no frame brings multipliers + let xy = ColorData { as_shot_white_xy: Some(D50), ..camera_without_white_balance() }; + assert_eq!(pano(shoot(&xy, &[None, None, None])).color, FrameColor::Camera(Box::new(xy))); +} + +#[test] +fn the_consensus_replaces_a_neutral_or_white_point_the_first_frame_had() { + // a neutral the app worked out for the first frame, and a white point of the source: both give way + let had = ColorData { as_shot_neutral: Some([0.9, 1.0, 0.8]), as_shot_white_xy: Some(Xy::new(0.31, 0.33)), ..camera_without_white_balance() }; + let res = pano(shoot(&had, &[Some(DAYLIGHT), Some(DAYLIGHT), Some(DAYLIGHT)])); + assert_close(neutral_of(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + let FrameColor::Camera(c) = &res.color else { panic!("camera colour") }; + assert_eq!(c.as_shot_white_xy, None); + // everything else of the first frame's colour is as it was + assert_eq!(**c, ColorData { as_shot_neutral: c.as_shot_neutral, as_shot_white_xy: None, ..had }); +} + +#[test] +fn the_dng_says_the_median_white_balance_as_as_shot_neutral() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some([1.9, 1.0, 1.7]), Some(DAYLIGHT)]; + let frames = shoot(&source, &wb); + let median = consensus_multipliers(&frames).unwrap(); + let res = pano(frames); + let raw = dng_of(&res); + // AsShotNeutral is the reciprocal of the median multipliers, to the precision of a TIFF rational + let got = raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"); + assert_close(got, reciprocal(median.map(f64::from)), 2e-6); + assert_close(got, [0.5, 1.0, 0.625], 2e-6); + assert_eq!(raw.color.as_shot_white_xy, None, "the DNG spec has the two tags exclude each other"); + // what a viewer starts from: the white and the camera transform the source's multipliers give + let want = lightcraft_raw::color::as_shot_white_of(&source, Some(median)); + let xy = lightcraft_raw::color::as_shot_white_xy(&raw); + assert!((xy.x - want.x).abs() < 1e-4 && (xy.y - want.y).abs() < 1e-4, "{xy:?} vs {want:?}"); + let (got, want) = (lightcraft_raw::color::camera_transform(&raw, xy), lightcraft_raw::color::camera_transform_of(&source, want)); + assert!(got.wb.iter().zip(want.wb).all(|(a, b)| (a - b).abs() < 1e-3), "{:?} vs {:?}", got.wb, want.wb); + // the colour model and the picture are the source's, and the same without the colour matrices + assert_eq!(raw.color.forward_matrix[0].is_some(), source.forward_matrix[0].is_some()); + assert_eq!((raw.width, raw.height, raw.cpp), (res.image.width, res.image.height, 3)); + let bare = dng_of(&pano(shoot(&ColorData::default(), &wb))); + assert_close(bare.color.as_shot_neutral.expect("AsShotNeutral without colour matrices"), [0.5, 1.0, 0.625], 2e-6); +} + +#[test] +fn without_multipliers_the_dng_has_no_as_shot_neutral_as_before() { + let res = pano(shoot(&ColorData::default(), &[None, None, None])); + let raw = dng_of(&res); + assert_eq!((raw.color.as_shot_neutral, raw.color.as_shot_white_xy), (None, None)); +} + +/// Exposures `evs` (EV, relative to the view's) of one view, one frame each, with `wb` as their multipliers. +fn bracket(color: &ColorData, evs: &[f64], wb: &[Multipliers]) -> Vec<Frame> { + let view = lightcraft_merge::synth::pano_views(320, 240, 280.0, &[0.0]).unwrap().remove(0); + evs.iter() + .zip(wb) + .map(|(ev, m)| { + let k = 2f32.powf(*ev as f32); + let mut frame = raw_frame(view.map(|p| p.map(|v| (v * k).min(1.0))), color, *m); + frame.exposure = Some(*ev); + frame + }) + .collect() +} + +fn hdr(frames: Vec<Frame>) -> HdrResult { + merge_hdr(frames, &HdrOptions::default(), &no_progress).unwrap() +} + +fn hdr_neutral(res: &HdrResult) -> Option<[f64; 3]> { + match &res.color { + FrameColor::Camera(c) => c.as_shot_neutral, + FrameColor::Linear { .. } => None, + } +} + +#[test] +fn an_hdr_merge_of_raw_frames_has_their_median_white_balance() { + let source = camera_without_white_balance(); + let wb = [Some([2.1, 1.0, 1.55]), Some(DAYLIGHT), Some([1.9, 1.0, 1.7])]; + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &wb)); + assert_eq!(res.wb_multipliers, Some(DAYLIGHT)); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); + // the DNG says it, to the precision of a TIFF rational + let bytes = + write_linear_dng(&res.radiance, &res.color, res.orientation, &res.metadata, res.baseline_exposure, DngSamples::Half, &res.lens).unwrap(); + let raw = lightcraft_raw::decode(&bytes).unwrap(); + assert_close(raw.color.as_shot_neutral.expect("AsShotNeutral in the DNG"), [0.5, 1.0, 0.625], 2e-6); + // an even count takes the mean of the middle two; frames without multipliers do not vote + let res = hdr(bracket(&source, &[-1.0, 1.0], &[Some([2.0, 1.0, 1.0]), Some([3.0, 1.0, 2.0])])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.5, 1.0, 1.5]), 1e-6); + let res = hdr(bracket(&source, &[-2.0, 0.0, 2.0], &[None, Some([2.0, 1.0, 1.6]), Some([f32::NAN, 1.0, 1.0])])); + assert_eq!(res.wb_multipliers, Some([2.0, 1.0, 1.6])); + assert_close(hdr_neutral(&res).expect("an as-shot neutral"), reciprocal([2.0, 1.0, 1.6f32.into()]), 1e-6); +} + +#[test] +fn an_hdr_merge_without_multipliers_keeps_the_reference_colour_as_before() { + let source = ColorData { as_shot_neutral: Some([0.5, 1.0, 0.62]), ..camera_without_white_balance() }; + let frames = bracket(&source, &[-2.0, 0.0, 2.0], &[None, None, None]); + let reference = frames[1].color.clone(); + let res = hdr(frames); + assert_eq!((res.reference, res.wb_multipliers), (1, None)); + assert_eq!(res.color, reference); +} diff --git a/crates/meta/src/exif.rs b/crates/meta/src/exif.rs index ca8d7b7aa..2b2ea1d13 100644 --- a/crates/meta/src/exif.rs +++ b/crates/meta/src/exif.rs @@ -16,8 +16,16 @@ pub fn read_exif(bytes: &[u8]) -> Metadata { /// Like [`read_exif`] but reports a structurally invalid TIFF header / IFD0. pub fn try_read_exif(bytes: &[u8]) -> Result<Metadata, TiffError> { - let tiff = Tiff::parse(strip_exif_header(bytes))?; - Ok(from_tiff(&tiff)) + let data = strip_exif_header(bytes); + let tiff = Tiff::parse(data)?; + let mut m = from_tiff(&tiff); + // Panasonic writes no LensModel, only the name in its maker note (and in a RW2 that note is not in the raw's own Exif) + if m.lens_model.is_none() { + m.lens_model = panasonic_maker_note_lens(data, &tiff); + } + // the maker note's values are offsets into the stream, which `from_tiff` doesn't have + m.focus = crate::focus::read(data, &tiff, m.make.as_deref(), m.model.as_deref(), m.orientation); + Ok(m) } fn text(ifd: Option<&Ifd>, tag: u16) -> Option<String> { @@ -54,6 +62,10 @@ pub fn from_tiff(tiff: &Tiff) -> Metadata { if let Some(s) = spec.filter(|s| s.len() == 4 && s[0] > 0.0) { m.lens_spec = Some([s[0], s[1], s[2], s[3]]); } + // Nikon DSLRs write neither LensModel nor LensSpecification, only the same four values in their maker note + if m.lens_spec.is_none() { + m.lens_spec = nikon_lens_spec(exif, m.make.as_deref(), tiff.order); + } m.orientation = ifd0.and_then(|i| i.u16(t::ORIENTATION)).filter(|v| (1..=8).contains(v)).map(Orientation::from_exif); m.rating = ifd0.and_then(|i| i.i64(t::RATING)).map(|r| r.clamp(-1, 5) as i8); @@ -138,11 +150,167 @@ fn read_gps(g: &Ifd) -> Option<Gps> { Some(Gps { latitude: lat, longitude: lon, altitude }) } +/// Panasonic maker note tag `0x0051` (LensType in the ExifTool Panasonic tag names): the lens name, ASCII. +const PANASONIC_LENS_TYPE: u16 = 0x0051; +/// RW2/RWL IFD0 tag `0x002E` (JpgFromRaw): a JPEG whose APP1 holds the full Exif, maker note included. +const PANASONIC_JPG_FROM_RAW: u16 = 0x002E; + +/// The lens name in the Panasonic maker note (tag `0x0051`) of this Exif or RW2 stream (`data` starts at its +/// TIFF header): the note of its own Exif IFD, else, for a raw, the note in its JpgFromRaw's Exif. +pub(crate) fn panasonic_maker_note_lens(data: &[u8], tiff: &Tiff) -> Option<String> { + panasonic_lens(data, tiff).or_else(|| panasonic_jpg_from_raw_lens(tiff)) +} + +/// The lens name in a `Panasonic\0` maker note of this Exif stream (`data` starts at its TIFF header). +fn panasonic_lens(data: &[u8], tiff: &Tiff) -> Option<String> { + let exif = tiff.exif()?; + if !exif.bytes(t::MAKER_NOTE)?.starts_with(b"Panasonic\0") { + return None; + } + let e = exif.get(t::MAKER_NOTE)?; + let make = tiff.ifds.first().and_then(|i| i.string(t::MAKE)).unwrap_or_default(); + let mn = lightcraft_tiff::makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, &make)?; + text(Some(&mn.ifd), PANASONIC_LENS_TYPE) +} + +/// The lens name from the Exif of a RW2's (or Leica RWL's) embedded JpgFromRaw. +fn panasonic_jpg_from_raw_lens(tiff: &Tiff) -> Option<String> { + let jpg = tiff.ifds.first()?.bytes(PANASONIC_JPG_FROM_RAW)?; + let exif = crate::jpeg_segments(jpg).exif?; + let data = strip_exif_header(&exif); + panasonic_lens(data, &Tiff::parse(data).ok()?) +} + +/// Nikon maker note tag `0x0084` (Lens in the ExifTool Nikon tag names): shortest and longest focal length, then the +/// widest aperture at each, as rationals, like Exif LensSpecification. Zero for a lens that doesn't report itself. +const NIKON_LENS: u16 = 0x0084; + +/// The lens a Nikon camera records in its maker note, when it's plausible. +fn nikon_lens_spec(exif: Option<&Ifd>, make: Option<&str>, order: lightcraft_tiff::ByteOrder) -> Option<[f64; 4]> { + let make = make?; + if !make.trim().to_ascii_uppercase().starts_with("NIKON") { + return None; + } + let note = exif?.bytes(t::MAKER_NOTE)?; + let mn = lightcraft_tiff::makernote::parse_makernote(note, 0, note.len() as u64, order, make)?; + let s = mn.ifd.f64s(NIKON_LENS)?; + let [short, long, wide, narrow] = s.as_slice() else { return None }; + let focal = |f: f64| f.is_finite() && f > 0.0 && f < 10_000.0; + let aperture = |a: f64| a.is_finite() && a > 0.0 && a < 100.0; + (focal(*short) && focal(*long) && long >= short && aperture(*wide) && aperture(*narrow)).then_some([*short, *long, *wide, *narrow]) +} + #[cfg(test)] pub(crate) mod tests { use super::*; use lightcraft_tiff::{ByteOrder, IfdBuilder, TiffWriter, Value}; + /// An Exif block from a Panasonic body: no LensModel, the lens name in maker note tag 0x0051, whose value + /// lies outside the note's IFD at an offset relative to the Exif TIFF header (as Panasonic writes it). + pub(crate) fn panasonic_exif(order: ByteOrder, lens: Option<&str>, lens_model: Option<&str>) -> Vec<u8> { + let note = |at: u32| { + let mut v = b"Panasonic\0\0\0".to_vec(); + order.put_u16(&mut v, 1); + order.put_u16(&mut v, if lens.is_some() { PANASONIC_LENS_TYPE } else { 0x0001 }); + order.put_u16(&mut v, 2); + let name = format!("{:<34}\0", lens.unwrap_or("0210")); + order.put_u32(&mut v, name.len() as u32); + order.put_u32(&mut v, at + 30); + order.put_u32(&mut v, 0); + v.extend_from_slice(name.as_bytes()); + v + }; + let build = |at: u32| { + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(note(at))); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(25, 1)])); + if let Some(l) = lens_model { + exif.set(t::LENS_MODEL, Value::Ascii(l.into())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(order, false).write(&[ifd0]).unwrap() + }; + // the note's own offset is only known once it is laid out; its length doesn't change + let first = build(0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as u32; + build(at) + } + + #[test] + fn panasonic_maker_note_names_the_lens() { + for order in [ByteOrder::Little, ByteOrder::Big] { + let m = read_exif(&panasonic_exif(order, Some("LUMIX G 25/F1.7"), None)); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX G 25/F1.7")); + assert_eq!(m.focal_length, Some(25.0)); + } + // Exif's own LensModel wins, and a note without 0x0051 leaves the lens empty + let m = read_exif(&panasonic_exif(ByteOrder::Little, Some("LUMIX G 25/F1.7"), Some("Other 25mm"))); + assert_eq!(m.lens_model.as_deref(), Some("Other 25mm")); + assert_eq!(read_exif(&panasonic_exif(ByteOrder::Little, None, None)).lens_model, None); + } + + /// A RW2 has the full Exif only in the APP1 of its JpgFromRaw (IFD0 0x002E), not in its own Exif IFD. + pub(crate) fn panasonic_rw2(lens: &str) -> Vec<u8> { + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + let mut app1 = b"Exif\0\0".to_vec(); + app1.extend(panasonic_exif(ByteOrder::Little, Some(lens), None)); + jpg.extend_from_slice(&((app1.len() + 2) as u16).to_be_bytes()); + jpg.extend(app1); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + let mut exif = IfdBuilder::new(); + exif.set(t::FOCAL_LENGTH, Value::Rational(vec![(85, 1)])); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Panasonic".into())); + ifd0.set(PANASONIC_JPG_FROM_RAW, Value::Undefined(jpg)); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + #[test] + fn rw2_reads_the_lens_from_its_jpg_from_raw() { + let rw2 = panasonic_rw2("LUMIX S 85/F1.8"); + let m = read_exif(&rw2); + assert_eq!(m.lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + assert_eq!(m.focal_length, Some(85.0)); + assert_eq!(crate::extract(&rw2).lens_model.as_deref(), Some("LUMIX S 85/F1.8")); + } + + /// An Exif block from a Nikon body whose maker note records the lens (tag 0x0084) and no LensModel or + /// LensSpecification, as the D700, D750, D80 and D5300 write it. + fn nikon_exif(make: &str, lens: Option<[(u32, u32); 4]>, spec: Option<[(u32, u32); 4]>) -> Vec<u8> { + let mut note = IfdBuilder::new(); + note.set(0x0001, Value::Undefined(b"0210".to_vec())); + if let Some(l) = lens { + note.set(NIKON_LENS, Value::Rational(l.to_vec())); + } + let mut blob = b"Nikon\0\x02\x10\0\0".to_vec(); + blob.extend(TiffWriter::new(ByteOrder::Little, false).write(&[note]).unwrap()); + let mut exif = IfdBuilder::new(); + exif.set(t::MAKER_NOTE, Value::Undefined(blob)); + if let Some(sp) = spec { + exif.set(t::LENS_SPECIFICATION, Value::Rational(sp.to_vec())); + } + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii(make.into())); + ifd0.set_child(t::EXIF_IFD, exif); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn nikon_maker_note_lens_fills_the_lens_spec() { + let zoom = [(18, 1), (135, 1), (35, 10), (56, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), None)).lens_spec, Some([18.0, 135.0, 3.5, 5.6])); + // Exif's own LensSpecification wins, another make's note is not read, and a lens that reports nothing is left out + let prime = [(50, 1), (50, 1), (18, 10), (18, 10)]; + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some(zoom), Some(prime))).lens_spec, Some([50.0, 50.0, 1.8, 1.8])); + assert_eq!(read_exif(&nikon_exif("Canon", Some(zoom), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(0, 1), (0, 1), (0, 1), (0, 1)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", Some([(200, 1), (80, 1), (28, 10), (28, 10)]), None)).lens_spec, None); + assert_eq!(read_exif(&nikon_exif("NIKON CORPORATION", None, None)).lens_spec, None); + } + pub(crate) fn sample_exif(order: ByteOrder) -> Vec<u8> { let mut exif = IfdBuilder::new(); exif.set(t::EXPOSURE_TIME, Value::Rational(vec![(1, 250)])); diff --git a/crates/meta/src/focus.rs b/crates/meta/src/focus.rs new file mode 100644 index 000000000..e1c84f296 --- /dev/null +++ b/crates/meta/src/focus.rs @@ -0,0 +1,749 @@ +//! Where the camera focused: the autofocus point a camera records in its maker notes, as a box on the +//! upright photo ([`FocusPoint`]). Sony only for now. +//! +//! **Sony** (the MakerNote IFD: header-less in ARWs, after a `SONY DSC` header in JPEGs). Tag identities and value names come from the public ExifTool +//! tag-name documentation (Sony table); every layout below was confirmed on real ILCE-1 (α1) and +//! ILCE-7SM3 (A7S III) ARWs, compared with ExifTool's output as a black box. No third-party code was read. +//! +//! | Tag | Layout (confirmed) | Use | +//! |---|---|---| +//! | `0x2027` FocusLocation | 4 × int16u: the width and height of the frame it is measured in, then the focus centre x, y in pixels of that frame (the sensor's landscape frame: a portrait shot still says 8640 × 5760) | the point | +//! | `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height of the frame the camera showed, then 2 bytes that are zero when there is no frame (manual focus) | the box size | +//! | `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | manual = no point | +//! | `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | shown | +//! | `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | shown | +//! | `0x9402` (enciphered block) byte 0x16, 0x17 | FocusMode (low 7 bits) and the AF area mode actually used: 0 Multi, 1 Center, 2 Spot, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual | shown; read only when its focus mode agrees with `0x201b` | +//! +//! The `0x94xx` blocks are enciphered byte by byte (the documentation calls it "a simple substitution +//! cipher"). On the sample files each plain byte `p < 249` is stored as `p³ mod 249` and bytes 249–255 +//! as themselves (e.g. a stored `0x20` reads as AFAreaMode 20, stored `0x77` as `0x83`, FocusMode AF-C +//! with its top bit set); the map is a bijection on 0..249 (cubing is invertible mod 3 and mod 83), so +//! it is undone by a table of its inverse. +//! +//! **No real point** (nothing is stored, so nothing is drawn and Z zooms as before): +//! - a body the layout hasn't been confirmed on ([`SONY_MODELS`]), a missing or malformed tag, a zero +//! frame size, or a centre outside its frame; +//! - manual focus (`0x201b` = 0, or the block's area mode says Manual): the camera still records where +//! the AF frame was parked, not where anything was in focus (on the samples these are off-centre); +//! - the exact frame centre while the area used was Multi (Wide with nothing tracked): on the samples +//! the wide area reports the centre when it picked no particular spot. A centred Spot, Zone or +//! Flexible Spot frame is a real choice and is kept. + +pub use crate::focus_read::{HEADER_PREFIX, HeaderRead, MAX_HEADER_BYTES, read_header}; +use crate::{Orientation, Rect}; +use lightcraft_tiff::{ByteOrder, Ifd, Tiff, makernote, tags as t}; +use serde::{Deserialize, Serialize}; + +/// Where the camera focused, on the upright photo. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct FocusPoint { + /// The focus frame, normalized to the photo's full, EXIF-oriented frame (y down, 0..1 on both + /// axes, clipped to it) — the frame face regions use, so it follows Rotate, flips and crops. + pub rect: Rect, + /// Focus mode as the camera names it (`AF-C`, `AF-S`, `DMF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mode: Option<String>, + /// The AF area chosen on the camera (`Wide`, `Zone`, `Flexible Spot`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub area: Option<String>, + /// What the camera followed, when it says (`Animal Eye Tracking`, `Face tracking`, `Lock On AF`…). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tracking: Option<String>, +} + +impl FocusPoint { + /// The box's centre (normalized, upright). + pub fn center(&self) -> (f64, f64) { + ((self.rect.x0 + self.rect.x1) / 2.0, (self.rect.y0 + self.rect.y1) / 2.0) + } + /// The words that describe it, in order (mode, area, tracking), for "AF-C · Wide · Animal Eye Tracking". + pub fn words(&self) -> Vec<&str> { + [&self.mode, &self.area, &self.tracking].into_iter().filter_map(|s| s.as_deref()).filter(|s| !s.is_empty()).collect() + } + /// The box clipped to the frame and checked: `None` when it is not a usable box (non-finite, + /// empty, or entirely outside the photo). + pub fn sanitized(mut self) -> Option<FocusPoint> { + let r = self.rect; + if ![r.x0, r.y0, r.x1, r.y1].iter().all(|v| v.is_finite()) { + return None; + } + let (x0, x1) = (r.x0.min(r.x1).clamp(0.0, 1.0), r.x0.max(r.x1).clamp(0.0, 1.0)); + let (y0, y1) = (r.y0.min(r.y1).clamp(0.0, 1.0), r.y0.max(r.y1).clamp(0.0, 1.0)); + if x1 - x0 <= 0.0 || y1 - y0 <= 0.0 { + return None; + } + self.rect = Rect { x0, y0, x1, y1 }; + Some(self) + } +} + +/// Sony bodies whose focus tags have been checked against real files. Another body may store the same +/// tags differently (or not mean the same by them): it shows no focus point until its files are checked. +pub const SONY_MODELS: &[&str] = &["ILCE-1", "ILCE-7SM3"]; + +/// Whether a camera as the catalog names it (`"SONY ILCE-1"`: the Exif Make, a space, the Model) is +/// one whose files can carry a focus point, so a photo from any other camera is never opened to look. +pub fn camera_supported(camera: &str) -> bool { + let camera = camera.trim(); + camera.get(..4).is_some_and(|m| m.eq_ignore_ascii_case("SONY")) && camera.rsplit(' ').next().is_some_and(|model| SONY_MODELS.contains(&model)) +} + +/// Names what [`read`] decodes (its bodies), so a record of "looked, found nothing" made by this +/// decoder is not trusted by one that knows more bodies. +pub fn decoder_tag() -> String { + format!("sony-v1:{}", SONY_MODELS.join(",")) +} + +const FOCUS_MODE: u16 = 0x201b; +const AF_AREA_SETTING: u16 = 0x201c; +const AF_TRACKING: u16 = 0x2021; +const FOCUS_LOCATION: u16 = 0x2027; +const FOCUS_FRAME_SIZE: u16 = 0x2037; +const TAG_9402: u16 = 0x9402; + +/// Side of the box drawn when the camera gave a point but no frame size, as a fraction of the frame's +/// short side (about the size of the α1's smallest frames). +const DEFAULT_SIDE: f64 = 0.03; + +/// The focus point a file's maker note records, or `None` (see the module notes for when). +/// `data` is the TIFF stream the maker note's offsets refer to (a whole raw, or an Exif block). +pub fn read(data: &[u8], tiff: &Tiff, make: Option<&str>, model: Option<&str>, orientation: Option<Orientation>) -> Option<FocusPoint> { + let make = make?; + if !make.to_ascii_uppercase().starts_with("SONY") || !SONY_MODELS.contains(&model?.trim()) { + return None; + } + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let mn = makernote::parse_makernote(data, e.offset, e.count() as u64, tiff.order, make)?; + // ARWs from these bodies carry a header-less IFD; their JPEGs the `SONY DSC` header + if !matches!(mn.kind, makernote::MakerNoteKind::Sony | makernote::MakerNoteKind::Plain) { + return None; + } + sony(&mn.ifd, mn.order, orientation.unwrap_or_default()) +} + +fn byte(ifd: &Ifd, tag: u16) -> Option<u8> { + ifd.value(tag)?.get_u64(0).and_then(|v| u8::try_from(v).ok()) +} + +/// Decode the Sony tags of a parsed maker-note IFD. +pub(crate) fn sony(ifd: &Ifd, order: ByteOrder, orientation: Orientation) -> Option<FocusPoint> { + let loc = ifd.value(FOCUS_LOCATION)?; + let [w, h, x, y] = [0, 1, 2, 3].map(|i| loc.get_u64(i)); + let (w, h, x, y) = (w? as f64, h? as f64, x? as f64, y? as f64); + if w < 1.0 || h < 1.0 || x > w || y > h { + return None; + } + let mode = byte(ifd, FOCUS_MODE); + if mode == Some(0) { + return None; + } + // the enciphered block: its focus mode must agree with the plain one, else its layout is not the one we know + let block = ifd.bytes(TAG_9402).and_then(|b| { + let (m, area) = (decipher(*b.get(0x16)?) & 0x7f, decipher(*b.get(0x17)?)); + (Some(m) == mode).then_some(area) + }); + if block == Some(255) { + return None; + } + let setting = byte(ifd, AF_AREA_SETTING); + let tracking = byte(ifd, AF_TRACKING); + let wide_untracked = match block { + Some(a) => a == 0, + None => setting == Some(0) && tracking.is_none_or(|t| t == 0), + }; + if wide_untracked && x * 2.0 == w && y * 2.0 == h { + return None; + } + // frame size: two int16u in the note's byte order, then a non-zero marker when there is a frame + let size = ifd.bytes(FOCUS_FRAME_SIZE).and_then(|b| { + let (fw, fh) = (order.read_u16(b, 0)? as f64, order.read_u16(b, 2)? as f64); + let shown = b.get(4..6).is_none_or(|m| m.iter().any(|&v| v != 0)); + (shown && fw >= 1.0 && fh >= 1.0).then_some((fw, fh)) + }); + let (fw, fh) = size.unwrap_or_else(|| { + let side = w.min(h) * DEFAULT_SIDE; + (side, side) + }); + let sensor = Rect { x0: (x - fw / 2.0) / w, y0: (y - fh / 2.0) / h, x1: (x + fw / 2.0) / w, y1: (y + fh / 2.0) / h }; + let name = |v: Option<u8>, f: fn(u8) -> Option<&'static str>| v.and_then(f).map(str::to_string); + let area = name(setting, area_setting_name); + let used = name(block, area_used_name); + // what it followed: the area mode used when it says more than the setting (Animal Eye Tracking on + // Wide), else the tracking setting when it is on + let tracking = used.filter(|u| Some(u) != area.as_ref() && u != "Multi").or_else(|| name(tracking, tracking_name).filter(|t| t != "Off")); + FocusPoint { rect: orientation.map_norm_rect(sensor), mode: name(mode, focus_mode_name), area, tracking }.sanitized() +} + +/// A minimal little-endian TIFF laid out as an α1 ARW's metadata is (IFD0 with Make `SONY` and Model `ILCE-1` → +/// Exif IFD → a header-less maker note with the focus tags: AF-C or the given FocusMode byte, Wide, Face tracking, +/// Animal Eye Tracking at (3226, 1980) of 8640 × 5760), followed by `samples` bytes standing in for sensor data. For +/// tests of the readers and the backfill in other crates; not a real raw (it has no image). +#[doc(hidden)] +pub fn synthetic_arw(mode: u8, samples: usize) -> Vec<u8> { + fn entry(f: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: [u8; 4]) { + f.extend_from_slice(&tag.to_le_bytes()); + f.extend_from_slice(&typ.to_le_bytes()); + f.extend_from_slice(&count.to_le_bytes()); + f.extend_from_slice(&value); + } + let encipher = |p: u8| if p >= 249 { p } else { (u32::from(p).pow(3) % 249) as u8 }; + let mut f = b"II*\0".to_vec(); + f.extend_from_slice(&8u32.to_le_bytes()); + // IFD0 at 8: Make (at 62), Model (at 68), Orientation, the Exif IFD (at 76) + f.extend_from_slice(&4u16.to_le_bytes()); + entry(&mut f, t::MAKE, 2, 5, 62u32.to_le_bytes()); + entry(&mut f, t::MODEL, 2, 7, 68u32.to_le_bytes()); + entry(&mut f, t::ORIENTATION, 3, 1, [1, 0, 0, 0]); + entry(&mut f, t::EXIF_IFD, 4, 1, 76u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + f.extend_from_slice(b"SONY\0\0ILCE-1\0\0"); + // the Exif IFD: the maker note at 94, 156 bytes + f.extend_from_slice(&1u16.to_le_bytes()); + entry(&mut f, t::MAKER_NOTE, 7, 156, 94u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + // the maker note's IFD, its values from 172 + f.extend_from_slice(&6u16.to_le_bytes()); + entry(&mut f, FOCUS_MODE, 1, 1, [mode, 0, 0, 0]); + entry(&mut f, AF_AREA_SETTING, 1, 1, [0, 0, 0, 0]); + entry(&mut f, AF_TRACKING, 1, 1, [1, 0, 0, 0]); + entry(&mut f, FOCUS_LOCATION, 3, 4, 172u32.to_le_bytes()); + entry(&mut f, FOCUS_FRAME_SIZE, 7, 6, 180u32.to_le_bytes()); + entry(&mut f, TAG_9402, 7, 64, 186u32.to_le_bytes()); + f.extend_from_slice(&[0; 4]); + for v in [8640u16, 5760, 3226, 1980] { + f.extend_from_slice(&v.to_le_bytes()); + } + f.extend_from_slice(&[0xaf, 0, 0xb4, 0, 1, 1]); + let mut block = [0u8; 64]; + block[0x16] = encipher(0x80 | mode); + block[0x17] = encipher(20); + f.extend_from_slice(&block); + f.extend((0..samples).map(|i| (i % 251) as u8)); + f +} + +/// Undo the `0x94xx` byte substitution: the stored byte is `p³ mod 249` for `p < 249`. +fn decipher(b: u8) -> u8 { + if b >= 249 { + return b; + } + (0..249u32).find(|p| (p * p * p) % 249 == u32::from(b)).and_then(|p| u8::try_from(p).ok()).unwrap_or(b) +} + +fn focus_mode_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Manual", + 2 => "AF-S", + 3 => "AF-C", + 4 => "AF-A", + 6 => "DMF", + 7 => "AF-D", + _ => return None, + }) +} + +fn area_setting_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Wide", + 1 | 9 => "Center", + 3 | 4 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + _ => return None, + }) +} + +fn tracking_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Off", + 1 => "Face tracking", + 2 => "Lock On AF", + _ => return None, + }) +} + +fn area_used_name(v: u8) -> Option<&'static str> { + Some(match v { + 0 => "Multi", + 1 => "Center", + 2 => "Spot", + 3 => "Flexible Spot", + 11 => "Zone", + 12 => "Expanded Flexible Spot", + 13 => "Custom AF Area", + 14 => "Tracking", + 15 => "Face Tracking", + 20 => "Animal Eye Tracking", + 21 => "Human Eye Tracking", + _ => return None, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, IfdBuilder, TiffWriter, Value}; + + fn encipher(p: u8) -> u8 { + if p >= 249 { p } else { ((u32::from(p).pow(3)) % 249) as u8 } + } + + fn entry(tag: u16, value: Value) -> Entry { + Entry { tag, value, offset: 0 } + } + + /// The α1 sample `_DSC6954.ARW`: AF-C, Wide, Face tracking, Animal Eye Tracking, 8640×5760 at (3226, 1980), 175×180. + fn alpha1() -> Ifd { + let mut block = vec![0u8; 0x40]; + block[0x16] = encipher(0x83); + block[0x17] = encipher(20); + Ifd { + entries: vec![ + entry(FOCUS_MODE, Value::Byte(vec![3])), + entry(AF_AREA_SETTING, Value::Byte(vec![0])), + entry(AF_TRACKING, Value::Byte(vec![1])), + entry(FOCUS_LOCATION, Value::Short(vec![8640, 5760, 3226, 1980])), + entry(FOCUS_FRAME_SIZE, Value::Undefined(vec![0xaf, 0, 0xb4, 0, 1, 1])), + entry(TAG_9402, Value::Undefined(block)), + ], + ..Default::default() + } + } + + fn set(ifd: &mut Ifd, tag: u16, value: Value) { + ifd.entries.retain(|e| e.tag != tag); + ifd.entries.push(entry(tag, value)); + // IFD entries are kept sorted by tag (lookups rely on it) + ifd.entries.sort_by_key(|e| e.tag); + } + + #[test] + fn the_cipher_matches_the_bytes_seen_in_files() { + // stored → plain, as read from an α1 file's 0x9402 block next to ExifTool's decoded values + assert_eq!(decipher(0x20), 20); + assert_eq!(decipher(0x77), 0x83); + assert_eq!(decipher(0xff), 0xff); + for p in 0..=255u8 { + assert_eq!(decipher(encipher(p)), p); + } + } + + #[test] + fn reads_the_alpha1_sample() { + let f = sony(&alpha1(), ByteOrder::Little, Orientation::Normal).unwrap(); + assert!((f.rect.x0 - (3226.0 - 87.5) / 8640.0).abs() < 1e-12); + assert!((f.rect.y1 - (1980.0 + 90.0) / 5760.0).abs() < 1e-12); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + let (cx, cy) = f.center(); + assert!((cx - 3226.0 / 8640.0).abs() < 1e-12 && (cy - 1980.0 / 5760.0).abs() < 1e-12); + } + + #[test] + fn big_endian_frame_size() { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0, 0xaf, 0, 0xb4, 1, 1])); + let f = sony(&ifd, ByteOrder::Big, Orientation::Normal).unwrap(); + assert!(((f.rect.x1 - f.rect.x0) * 8640.0 - 175.0).abs() < 1e-9); + } + + #[test] + fn follows_the_exif_orientation() { + // a portrait shot (Rotate 270 CW, EXIF 8) still records landscape sensor coordinates + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 864, 576])); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(8)).unwrap(); + let (cx, cy) = f.center(); + // left of the sensor (x 0.1) is the bottom of the upright photo; its top (y 0.1) the left + assert!((cx - 0.1).abs() < 1e-9 && (cy - 0.9).abs() < 1e-9, "{cx} {cy}"); + let f = sony(&ifd, ByteOrder::Little, Orientation::from_exif(6)).unwrap(); + let (cx, cy) = f.center(); + assert!((cx - 0.9).abs() < 1e-9 && (cy - 0.1).abs() < 1e-9, "{cx} {cy}"); + } + + #[test] + fn no_real_point() { + // manual focus + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // the block says Manual (its focus mode agreeing) + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // Multi at the exact centre + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(3); + block[0x17] = encipher(0); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // …and without the block: Wide, tracking off + ifd.entries.retain(|e| e.tag != TAG_9402); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + // missing location, zero frame, centre outside it + let mut ifd = alpha1(); + ifd.entries.retain(|e| e.tag != FOCUS_LOCATION); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none()); + for loc in [vec![0, 5760, 0, 0], vec![8640, 0, 1, 0], vec![8640, 5760, 9000, 10], vec![8640, 5760, 10, 6000], vec![8640, 5760, 10]] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(loc.clone())); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_none(), "{loc:?}"); + } + } + + #[test] + fn a_centred_spot_is_a_real_point() { + let mut ifd = alpha1(); + set(&mut ifd, AF_AREA_SETTING, Value::Byte(vec![3])); + set(&mut ifd, AF_TRACKING, Value::Byte(vec![0])); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(2); + block[0x17] = encipher(3); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + set(&mut ifd, FOCUS_MODE, Value::Byte(vec![2])); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 4320, 2880])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-S", "Flexible Spot"]); + let (cx, cy) = f.center(); + assert!((cx - 0.5).abs() < 1e-12 && (cy - 0.5).abs() < 1e-12); + } + + #[test] + fn a_block_that_disagrees_is_ignored() { + // another body's block layout: its "focus mode" is not the plain tag's, so its area isn't trusted + let mut ifd = alpha1(); + let mut block = vec![0u8; 0x18]; + block[0x16] = encipher(7); + block[0x17] = encipher(255); + set(&mut ifd, TAG_9402, Value::Undefined(block)); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Face tracking"]); + // a block too short to hold the bytes is ignored the same way + set(&mut ifd, TAG_9402, Value::Undefined(vec![1, 2, 3])); + assert!(sony(&ifd, ByteOrder::Little, Orientation::Normal).is_some()); + } + + #[test] + fn missing_or_garbage_frame_size_gets_a_small_box_and_edges_are_clipped() { + for fs in [ + Value::Undefined(vec![0, 0, 0, 0, 0, 0]), + Value::Undefined(vec![1]), + Value::Short(vec![5]), + Value::Undefined(vec![0xaf, 0, 0xb4, 0, 0, 0]), + ] { + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_FRAME_SIZE, fs); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(((f.rect.y1 - f.rect.y0) - DEFAULT_SIDE).abs() < 1e-9); + } + // a frame reaching past the corner is clipped to the photo + let mut ifd = alpha1(); + set(&mut ifd, FOCUS_LOCATION, Value::Short(vec![8640, 5760, 0, 5760])); + set(&mut ifd, FOCUS_FRAME_SIZE, Value::Undefined(vec![0xff, 0xff, 0xff, 0xff, 1, 1])); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert_eq!(f.rect, Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }); + let f = sony(&ifd, ByteOrder::Little, Orientation::Normal).unwrap(); + assert!(f.rect.x0 >= 0.0 && f.rect.y1 <= 1.0); + } + + #[test] + fn wrong_types_and_extreme_values_never_panic() { + let values = [ + Value::Ascii("x".into()), + Value::Byte(vec![]), + Value::SShort(vec![-1, -1, -1, -1]), + Value::Long(vec![u32::MAX; 4]), + Value::Double(vec![f64::NAN; 4]), + Value::Undefined(vec![0xff; 300]), + ]; + for v in &values { + for tag in [FOCUS_MODE, AF_AREA_SETTING, AF_TRACKING, FOCUS_LOCATION, FOCUS_FRAME_SIZE, TAG_9402] { + let mut ifd = alpha1(); + set(&mut ifd, tag, v.clone()); + if let Some(f) = sony(&ifd, ByteOrder::Little, Orientation::Normal) { + assert!(f.rect.x0 >= 0.0 && f.rect.x1 <= 1.0 && f.rect.x0 < f.rect.x1); + } + } + } + assert!(FocusPoint { rect: Rect { x0: f64::NAN, y0: 0.0, x1: 1.0, y1: 1.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + assert!(FocusPoint { rect: Rect { x0: 2.0, y0: 2.0, x1: 3.0, y1: 3.0 }, mode: None, area: None, tracking: None }.sanitized().is_none()); + } + + /// A whole TIFF with a `SONY DSC` maker note holding `ifd`'s entries, written for real. + fn sony_file(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)]) -> Vec<u8> { + sony_file_with(make, model, orientation, note_entries, None) + } + + /// [`sony_file`], with the `0x9402` block's value placed `far` bytes past the end of the TIFF + /// (the gap filled with "sample data") when `far` is given. + fn sony_file_with(make: &str, model: &str, orientation: u16, note_entries: &[(u16, Value)], far: Option<usize>) -> Vec<u8> { + let order = ByteOrder::Little; + // the note: 12-byte header, then an IFD whose out-of-line values are offset from the TIFF header, + // which we only know after writing; write once to learn where the note lands, then rebuild + let build = |note_at: u64, end: u64| -> (Vec<u8>, Vec<u8>) { + let mut note = b"SONY DSC \0\0\0".to_vec(); + let ifd_len = 2 + note_entries.len() * 12 + 4; + let mut tail: Vec<u8> = Vec::new(); + let mut outside: Vec<u8> = Vec::new(); + order.put_u16(&mut note, note_entries.len() as u16); + for (tag, v) in note_entries { + let raw = lightcraft_tiff::writer::encode(order, v); + order.put_u16(&mut note, *tag); + order.put_u16(&mut note, v.field_type() as u16); + order.put_u32(&mut note, v.count() as u32); + if raw.len() <= 4 { + let mut r = raw.clone(); + r.resize(4, 0); + note.extend(r); + } else if let (Some(gap), TAG_9402) = (far, *tag) { + order.put_u32(&mut note, (end + gap as u64) as u32); + outside = raw; + } else { + order.put_u32(&mut note, (note_at + 12 + ifd_len as u64 + tail.len() as u64) as u32); + tail.extend(raw); + if tail.len() % 2 == 1 { + tail.push(0); + } + } + } + order.put_u32(&mut note, 0); + note.extend(tail); + 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(model.into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![orientation])); + ifd0.set_child(t::EXIF_IFD, exif); + (TiffWriter { order, bigtiff: false }.write(&[ifd0]).unwrap(), outside) + }; + let (first, _) = build(0, 0); + let at = Tiff::parse(&first).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset; + let (mut file, outside) = build(at, first.len() as u64); + assert_eq!(file.len(), first.len()); + assert_eq!(Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset, at); + if let Some(gap) = far { + file.extend((0..gap).map(|i| (i * 13 % 241) as u8)); + file.extend(outside); + } + file + } + + fn alpha1_entries() -> Vec<(u16, Value)> { + alpha1().entries.into_iter().map(|e| (e.tag, e.value)).collect() + } + + #[test] + fn read_exif_finds_it_through_the_maker_note_and_orientation() { + let file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let m = crate::read_exif(&file); + let f = m.focus.expect("focus point"); + assert_eq!(f.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + // EXIF 8: sensor x becomes 1 - upright y + let (cx, cy) = f.center(); + assert!((cy - (1.0 - 3226.0 / 8640.0)).abs() < 1e-9 && (cx - 1980.0 / 5760.0).abs() < 1e-9, "{cx} {cy}"); + // an unconfirmed body or another maker: nothing + assert!(crate::read_exif(&sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries())).focus.is_none()); + assert!(crate::read_exif(&sony_file("Canon", "ILCE-1", 1, &alpha1_entries())).focus.is_none()); + } + + #[test] + fn truncated_and_garbage_maker_notes_never_panic() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + let note_at = Tiff::parse(&file).unwrap().exif().unwrap().get(t::MAKER_NOTE).unwrap().offset as usize; + for cut in (0..file.len()).step_by(7) { + let _ = crate::read_exif(&file[..cut]); + } + let mut seed = 0x2545_f491_u32; + for _ in 0..300 { + let mut f = file.clone(); + for _ in 0..8 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = note_at + (seed as usize % (file.len() - note_at)); + f[i] = (seed >> 8) as u8; + } + if let Some(fp) = crate::read_exif(&f).focus { + assert!(fp.rect.x0 >= 0.0 && fp.rect.x1 <= 1.0 && fp.rect.y0 >= 0.0 && fp.rect.y1 <= 1.0); + } + } + } + + /// A whole-file read and a header-only read of the same file agree, and the header read reads a + /// small part of it (the sample data after the metadata is never touched). + #[test] + fn header_read_matches_the_whole_file_and_reads_little() { + let read = |file: &[u8]| { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let s = off as usize; + buf.copy_from_slice(&file[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.bytes_read, asked); + r + }; + // a raw: the metadata, then 8 MB of "samples" + let mut file = sony_file("SONY", "ILCE-1", 8, &alpha1_entries()); + let meta_len = file.len(); + file.extend((0..8_000_000u32).map(|i| (i * 31 % 251) as u8)); + let whole = crate::extract(&file).focus; + assert!(whole.is_some()); + let r = read(&file); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX && r.bytes_read < file.len() as u64 / 100, "{} of {}", r.bytes_read, file.len()); + assert!(meta_len < HEADER_PREFIX as usize); + // the area-mode block placed 3 MB further on: found in a further round, still a small part + let mut far = sony_file_with("SONY", "ILCE-1", 1, &alpha1_entries(), Some(3_000_000)); + far.extend((0..4_000_000u32).map(|i| (i * 7 % 253) as u8)); + let whole = crate::extract(&far).focus; + assert!(whole.is_some()); + let r = read(&far); + assert_eq!(r.focus, whole); + assert!(r.bytes_read < 64 * 1024, "{}", r.bytes_read); + // a JPEG: the Exif segment after another segment + let exif = sony_file("SONY", "ILCE-1", 6, &alpha1_entries()); + let mut jpeg = vec![0xff, 0xd8, 0xff, 0xe0, 0, 16]; + jpeg.extend_from_slice(b"JFIF\0\x01\x01\0\0\x01\0\x01\0\0"); + jpeg.extend_from_slice(&[0xff, 0xe1]); + jpeg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpeg.extend_from_slice(b"Exif\0\0"); + jpeg.extend_from_slice(&exif); + jpeg.extend_from_slice(&[0xff, 0xda, 0, 2]); + jpeg.extend(std::iter::repeat_n(0x55u8, 3_000_000)); + let whole = crate::extract(&jpeg).focus; + assert!(whole.is_some()); + let r = read(&jpeg); + assert_eq!(r.focus, whole); + assert!(r.bytes_read <= HEADER_PREFIX); + // unsupported bodies agree too (no point either way) + let other = sony_file("SONY", "ILCE-7M4", 1, &alpha1_entries()); + assert_eq!(read(&other).focus, None); + } + + /// Every truncation and corruption of a file gives the header read the whole-file read's answer + /// (or an error when the file is shorter than it said), never a panic. + #[test] + fn header_read_of_damaged_files_agrees_or_fails_cleanly() { + let file = sony_file("SONY", "ILCE-1", 1, &alpha1_entries()); + for cut in (0..file.len()).step_by(5) { + let part = &file[..cut]; + let r = read_header(part.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(part.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(part).focus, "cut at {cut}"); + } + let mut seed = 0x9e37_79b9_u32; + for _ in 0..400 { + let mut f = file.clone(); + for _ in 0..6 { + seed ^= seed << 13; + seed ^= seed >> 17; + seed ^= seed << 5; + let i = seed as usize % f.len(); + f[i] = (seed >> 8) as u8; + } + let r = read_header(f.len() as u64, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(&f[s..s + buf.len()]); + Ok(()) + }) + .unwrap(); + assert_eq!(r.focus, crate::extract(&f).focus); + } + // a reader told the file is longer than it is (replaced since it was measured): an error + let r = read_header(file.len() as u64 + 100_000, &mut |off, buf: &mut [u8]| { + let s = off as usize; + buf.copy_from_slice(file.get(s..s + buf.len()).ok_or_else(|| std::io::Error::from(std::io::ErrorKind::UnexpectedEof))?); + Ok(()) + }); + assert!(r.is_err()); + } + + #[test] + fn the_synthetic_arw_is_laid_out_as_described() { + let f = synthetic_arw(3, 10); + assert_eq!(f.len(), 260); + let p = crate::extract(&f).focus.expect("a point"); + assert_eq!(p.words(), vec!["AF-C", "Wide", "Animal Eye Tracking"]); + assert!(crate::extract(&synthetic_arw(0, 0)).focus.is_none(), "manual focus"); + } + + #[test] + fn cameras_that_can_have_a_point() { + assert!(camera_supported("SONY ILCE-1") && camera_supported("Sony ILCE-7SM3 ")); + for c in ["SONY ILCE-7M4", "Canon EOS R5", "", "SONY", "ILCE-1", "SON"] { + assert!(!camera_supported(c), "{c}"); + } + } + + /// Opt-in: decode real α1 / A7S III files and compare with the values ExifTool (run as a black box) + /// reports for them. `LIGHTCRAFT_FOCUS_SAMPLES=<dir>` is the photo folder the paths below are relative + /// to; skipped without it. No user file is part of the repository. + #[test] + fn real_sony_files_match_exiftool() { + let Ok(dir) = std::env::var("LIGHTCRAFT_FOCUS_SAMPLES") else { return }; + let dir = std::path::Path::new(&dir); + type Want = Option<([f64; 4], [f64; 2], &'static [&'static str])>; + // (file, EXIF orientation, ExifTool's FocusLocation and FocusFrameSize, the words we show; None = no point) + let cases: [(&str, u16, Want); 8] = [ + // α1, AF-C, Wide, Face tracking, AFAreaMode Animal Eye Tracking + ( + "2023/11-november/17th/_DSC6954.ARW", + 1, + Some(([8640.0, 5760.0, 3226.0, 1980.0], [175.0, 180.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + ( + "2023/11-november/17th/_DSC6894.ARW", + 1, + Some(([8640.0, 5760.0, 4725.0, 1860.0], [135.0, 132.0], &["AF-C", "Wide", "Animal Eye Tracking"])), + ), + // α1, manual focus (the location 4441 3060 is where the frame was parked) + ("2023/11-november/15th/_DSC6613.ARW", 1, None), + // α1 portrait, AF-S, Wide, Multi at the exact centre 4320 2880 + ("2022/01-january/9th/_DSC6079.ARW", 8, None), + // α1 APS-C crop mode portrait, AF-C, Wide, Lock On AF, AFAreaMode Tracking + ("2022/12-december/16th/_DSC1575.ARW", 8, Some(([5616.0, 3744.0, 4071.0, 1848.0], [280.0, 280.0], &["AF-C", "Wide", "Tracking"]))), + // A7S III portrait, AF-A, Wide, Face tracking, Animal Eye Tracking + ( + "2020/10-october/31st/DSC00065.ARW", + 8, + Some(([4240.0, 2832.0, 1192.0, 1675.0], [278.0, 277.0], &["AF-A", "Wide", "Animal Eye Tracking"])), + ), + // A7S III upside down, AF-C, a centred Flexible Spot: a real point + ("2021/11-november/7th/DSC05975.ARW", 3, Some(([4240.0, 2832.0, 2120.0, 1416.0], [251.0, 224.0], &["AF-C", "Flexible Spot"]))), + // A7S III, manual focus + ("2021/06-june/12th/DSC06806.ARW", 1, None), + ]; + for (name, exif_orientation, want) in cases { + let bytes = std::fs::read(dir.join(name)).unwrap_or_else(|e| panic!("{name}: {e}")); + let got = crate::extract(&bytes).focus; + match (want, got) { + (None, None) => {} + (Some((loc, size, words)), Some(f)) => { + let [w, h, x, y] = loc; + let sensor = + Rect { x0: (x - size[0] / 2.0) / w, y0: (y - size[1] / 2.0) / h, x1: (x + size[0] / 2.0) / w, y1: (y + size[1] / 2.0) / h }; + let r = Orientation::from_exif(exif_orientation).map_norm_rect(sensor); + for (a, b) in [(f.rect.x0, r.x0), (f.rect.y0, r.y0), (f.rect.x1, r.x1), (f.rect.y1, r.y1)] { + assert!((a - b).abs() < 1e-9, "{name}: {:?} vs {r:?}", f.rect); + } + assert_eq!(f.words(), words, "{name}"); + } + (w, g) => panic!("{name}: want {w:?}, got {g:?}"), + } + } + } +} diff --git a/crates/meta/src/focus_read.rs b/crates/meta/src/focus_read.rs new file mode 100644 index 000000000..5a5d349d1 --- /dev/null +++ b/crates/meta/src/focus_read.rs @@ -0,0 +1,465 @@ +//! Reading a file's focus point without reading the file: [`read_header`] asks for the few byte +//! ranges [`crate::focus::read`] looks at (a TIFF raw's IFD0, its Exif IFD, the maker-note IFD and +//! the values of the focus tags; a JPEG's Exif segment) and decodes them exactly as a whole-file +//! read would. On the α1 and the A7S III all of it sits in the first 16 KB of a 50–120 MB ARW, so a +//! library of them can be backfilled from a slow disk by reading one small block per photo. +//! +//! The ranges are found by walking the structure as the readers do (the IFD tables and the value +//! offsets they hold), round by round: each round asks only for what the bytes loaded so far point +//! at. A TIFF is decoded from a buffer of the file's length in which only the loaded ranges hold the +//! file's bytes (the rest stays zero and its pages are never touched), so every offset and bound the +//! reader checks is the one it checks on the whole file. Rounds, bytes and the buffer are bounded: +//! a file that wants more is reported as having no focus point. + +use std::io; + +use crate::focus::FocusPoint; +use lightcraft_tiff::{ByteOrder, FieldType, Tiff, tags as t}; + +/// Bytes read first: the α1's and A7S III's focus data ends before 16 KB, so one read covers it. +pub const HEADER_PREFIX: u64 = 32 * 1024; +/// Later reads are widened to this granularity (fewer, larger reads on a spinning disk). +const ALIGN: u64 = 4 * 1024; +/// The most rounds of reads one file gets (a real file needs one; the walk takes at most nine). +const MAX_ROUNDS: usize = 12; +/// The most bytes read from one file: past this the layout is not one the decoder knows. +pub const MAX_HEADER_BYTES: u64 = 2 << 20; +/// The largest TIFF given a buffer of its length (its untouched pages are never written, but the +/// allocation must succeed: the engine's planned preview reads use the same bound). Raws are far below it. +const MAX_SPARSE_FILE: u64 = 1 << 30; +/// JPEG segments walked before giving up. +const MAX_SEGMENTS: usize = 1024; +/// Entries read from one IFD (the TIFF reader's limit). +const MAX_ENTRIES: u64 = 8192; + +/// The focus tags whose values [`crate::focus`] reads from a Sony maker note. +const FOCUS_TAGS: [u16; 6] = [0x201b, 0x201c, 0x2021, 0x2027, 0x2037, 0x9402]; + +/// What [`read_header`] found, and what it cost. +#[derive(Clone, Debug, PartialEq)] +pub struct HeaderRead { + /// The focus point a whole-file read gives ([`crate::extract`]`(file).focus`). + pub focus: Option<FocusPoint>, + /// Bytes read from the file. + pub bytes_read: u64, +} + +/// The focus point of a file of `len` bytes, reading only what it needs through `read_at(offset, +/// buf)`, which fills `buf` with the file's bytes at `offset` (an error — a file that shrank or +/// went away — is returned). A malformed or unknown file is `Ok` with no point. +pub fn read_header(len: u64, read_at: &mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>) -> io::Result<HeaderRead> { + let mut reader = Reader { len, read_at, bytes: 0 }; + let head = reader.fetch(0, HEADER_PREFIX.min(len))?; + let focus = if head.starts_with(&[0xff, 0xd8]) { + jpeg(&mut reader, &head)? + } else if matches!(Tiff::sniff(&head), Some((_, false))) { + tiff(&mut reader, head)? + } else { + None + }; + Ok(HeaderRead { focus, bytes_read: reader.bytes }) +} + +struct Reader<'a> { + len: u64, + read_at: &'a mut dyn FnMut(u64, &mut [u8]) -> io::Result<()>, + bytes: u64, +} + +impl Reader<'_> { + /// `[start, end)` of the file (clipped to it), counted against [`MAX_HEADER_BYTES`]. + fn fetch(&mut self, start: u64, end: u64) -> io::Result<Vec<u8>> { + let end = end.min(self.len); + let n = end.saturating_sub(start); + if self.bytes.saturating_add(n) > MAX_HEADER_BYTES { + return Err(io::Error::other(OverBudget)); + } + let mut buf = vec![0u8; usize::try_from(n).map_err(|_| io::Error::other("range too large"))?]; + if n > 0 { + (self.read_at)(start, &mut buf)?; + } + self.bytes = self.bytes.saturating_add(n); + Ok(buf) + } +} + +/// A file whose focus data would take more than [`MAX_HEADER_BYTES`] to read. +#[derive(Debug)] +struct OverBudget; + +impl std::fmt::Display for OverBudget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("focus data larger than a header") + } +} + +impl std::error::Error for OverBudget {} + +fn over_budget(e: &io::Error) -> bool { + e.get_ref().is_some_and(|i| i.is::<OverBudget>()) +} + +/// An over-budget read is "no focus point here", not a failure to report. +fn budget_is_none<T>(r: io::Result<Option<T>>) -> io::Result<Option<T>> { + match r { + Err(e) if over_budget(&e) => Ok(None), + r => r, + } +} + +/// A JPEG's first `Exif` APP1 segment, found as [`crate::jpeg_segments`] finds it. +fn jpeg(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> { + budget_is_none(jpeg_inner(r, head)) +} + +fn jpeg_inner(r: &mut Reader, head: &[u8]) -> io::Result<Option<FocusPoint>> { + let len = r.len; + let at = |r: &mut Reader, start: u64, n: u64| -> io::Result<Vec<u8>> { + let local = usize::try_from(start).ok().zip(usize::try_from(start.saturating_add(n)).ok()).and_then(|(a, b)| head.get(a..b)); + match local { + Some(s) => Ok(s.to_vec()), + None => r.fetch(start, start.saturating_add(n)), + } + }; + let mut i = 2u64; + for _ in 0..MAX_SEGMENTS { + if i.saturating_add(4) > len { + break; + } + let h = at(r, i, 4)?; + let (Some(&ff), Some(&marker), Some(&a), Some(&b)) = (h.first(), h.get(1), h.get(2), h.get(3)) else { break }; + if ff != 0xff { + break; + } + if marker == 0xff { + i += 1; + continue; + } + if marker == 0xd8 || (0xd0..=0xd7).contains(&marker) || marker == 0x01 { + i += 2; + continue; + } + if marker == 0xd9 || marker == 0xda { + break; + } + let seg = u64::from(u16::from_be_bytes([a, b])); + if seg < 2 { + break; + } + let end = i.saturating_add(2).saturating_add(seg).min(len); + if marker == 0xe1 { + let payload = at(r, i + 4, end.saturating_sub(i + 4))?; + if payload.starts_with(b"Exif\0") { + return Ok(crate::read_exif(payload.get(6..).unwrap_or(&[])).focus); + } + } + i = end; + } + Ok(None) +} + +/// A TIFF raw, decoded from a buffer of its length holding only the ranges [`plan`] asked for. +fn tiff(r: &mut Reader, head: Vec<u8>) -> io::Result<Option<FocusPoint>> { + if r.len > MAX_SPARSE_FILE { + return Ok(None); + } + let Ok(len) = usize::try_from(r.len) else { return Ok(None) }; + let mut buf = vec![0u8; len]; + let Some(dst) = buf.get_mut(..head.len()) else { return Ok(None) }; + dst.copy_from_slice(&head); + let mut loaded = Loaded::default(); + loaded.insert(0, head.len() as u64); + for _ in 0..MAX_ROUNDS { + match plan(&buf, &loaded) { + Step::Ready => return Ok(crate::try_read_exif(&buf).ok().and_then(|m| m.focus)), + Step::Nothing => return Ok(None), + Step::Need(ranges) => { + for (a, b) in ranges { + // widen to the read granularity, but never re-read what is loaded + let (wa, wb) = ((a / ALIGN) * ALIGN, b.div_ceil(ALIGN).saturating_mul(ALIGN).min(r.len)); + for (s, e) in loaded.gaps(wa, wb) { + let bytes = match r.fetch(s, e) { + Err(e) if over_budget(&e) => return Ok(None), + other => other?, + }; + let (Ok(su), Ok(eu)) = (usize::try_from(s), usize::try_from(e)) else { return Ok(None) }; + let Some(dst) = buf.get_mut(su..eu) else { return Ok(None) }; + dst.copy_from_slice(&bytes); + loaded.insert(s, e); + } + } + } + } + } + Ok(None) +} + +/// Sorted, disjoint, non-touching byte ranges. +#[derive(Default)] +struct Loaded(Vec<(u64, u64)>); + +impl Loaded { + fn insert(&mut self, start: u64, end: u64) { + if start >= end { + return; + } + let (mut s, mut e) = (start, end); + let mut out = Vec::with_capacity(self.0.len() + 1); + for &(a, b) in &self.0 { + if b < s || a > e { + out.push((a, b)); + } else { + s = s.min(a); + e = e.max(b); + } + } + out.push((s, e)); + out.sort_unstable(); + self.0 = out; + } + + fn contains(&self, start: u64, end: u64) -> bool { + start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) + } + + /// The parts of `[start, end)` not loaded. + fn gaps(&self, start: u64, end: u64) -> Vec<(u64, u64)> { + let mut out = Vec::new(); + let mut at = start; + for &(a, b) in &self.0 { + if b <= at || a >= end { + continue; + } + if a > at { + out.push((at, a.min(end))); + } + at = at.max(b); + if at >= end { + break; + } + } + if at < end { + out.push((at, end)); + } + out + } +} + +enum Step { + /// Load these and ask again. + Need(Vec<(u64, u64)>), + /// Everything the decoder reads is loaded. + Ready, + /// The file has nothing the decoder could read (no Exif, no maker note, a structure it rejects). + Nothing, +} + +/// One IFD entry as the TIFF reader sees it: where its value is (absolute), if it fits the file. +struct RawEntry { + tag: u16, + typ: FieldType, + start: u64, + end: u64, +} + +struct Want<'a> { + buf: &'a [u8], + order: ByteOrder, + loaded: &'a Loaded, + need: Vec<(u64, u64)>, +} + +impl Want<'_> { + fn len(&self) -> u64 { + self.buf.len() as u64 + } + + /// Whether `[start, end)` (clipped to the file) is loaded; asks for it if not. + fn want(&mut self, start: u64, end: u64) -> bool { + let end = end.min(self.len()); + if self.loaded.contains(start, end) { + return true; + } + self.need.push((start, end)); + false + } + + /// The entries of the IFD at `off` (values relative to 0), once its table is loaded; `Some(empty)` + /// for one the reader would reject. + fn table(&mut self, off: u64) -> Option<Vec<RawEntry>> { + let len = self.len(); + if off == 0 || off >= len { + return Some(Vec::new()); + } + if !self.want(off, off.saturating_add(2)) { + return None; + } + let pos = usize::try_from(off).ok()?; + let n = u64::from(self.order.read_u16(self.buf, pos)?); + let table = off + 2; + let n_read = n.min(len.saturating_sub(table) / 12).min(MAX_ENTRIES); + let end = table + n_read * 12 + if n_read == n { 4 } else { 0 }; + if !self.want(off, end) { + return None; + } + let mut out = Vec::new(); + for i in 0..n_read { + let Ok(p) = usize::try_from(table + i * 12) else { continue }; + let (Some(tag), Some(typ), Some(count)) = ( + self.order.read_u16(self.buf, p), + self.order.read_u16(self.buf, p + 2).and_then(FieldType::from_u16), + self.order.read_u32(self.buf, p + 4), + ) else { + continue; + }; + let Some(total) = u64::from(count).checked_mul(typ.size() as u64) else { continue }; + let field = p as u64 + 8; + let start = if total <= 4 { + field + } else { + match self.order.read_u32(self.buf, p + 8) { + Some(v) => u64::from(v), + None => continue, + } + }; + let Some(end) = start.checked_add(total).filter(|&e| e <= len) else { continue }; + out.push(RawEntry { tag, typ, start, end }); + } + Some(out) + } + + /// The first unsigned value of an entry (loaded). + fn first_value(&self, e: &RawEntry) -> Option<u64> { + let pos = usize::try_from(e.start).ok()?; + match e.typ.size() { + 1 => self.buf.get(pos).map(|&b| u64::from(b)), + 2 => self.order.read_u16(self.buf, pos).map(u64::from), + 4 => self.order.read_u32(self.buf, pos).map(u64::from), + _ => self.order.read_u64(self.buf, pos), + } + } + + fn step(self) -> Step { + if self.need.is_empty() { Step::Ready } else { Step::Need(self.need) } + } +} + +/// What of a classic TIFF the focus decoder still needs, given what is `loaded`: IFD0 (Make, Model, +/// the Exif pointer), the Exif IFD (the maker-note entry), the maker note's header and IFD as +/// `lightcraft_tiff::makernote` locates it, and the focus tags' values. +fn plan(buf: &[u8], loaded: &Loaded) -> Step { + let Some((order, false)) = Tiff::sniff(buf) else { return Step::Nothing }; + let mut w = Want { buf, order, loaded, need: Vec::new() }; + let Some(ifd0) = order.read_u32(buf, 4).map(u64::from) else { return Step::Nothing }; + let Some(e0) = w.table(ifd0) else { return w.step() }; + let mut exif = None; + for e in &e0 { + if matches!(e.tag, t::MAKE | t::MODEL | t::UNIQUE_CAMERA_MODEL | t::ORIENTATION) { + w.want(e.start, e.end); + } + if e.tag == t::EXIF_IFD && exif.is_none() && w.want(e.start, e.end) { + exif = w.first_value(e); + } + } + if !w.need.is_empty() { + return w.step(); + } + let Some(exif) = exif.filter(|&o| o != 0) else { return Step::Nothing }; + let Some(ee) = w.table(exif) else { return w.step() }; + let Some(note) = ee.iter().find(|e| e.tag == t::MAKER_NOTE) else { return Step::Nothing }; + let head_end = note.start.saturating_add(12).min(note.end); + if !w.want(note.start, head_end) { + return w.step(); + } + let head = usize::try_from(note.start).ok().zip(usize::try_from(head_end).ok()).and_then(|(a, b)| buf.get(a..b)).unwrap_or(&[]); + let sony = head.starts_with(b"SONY") || head.starts_with(b"VHAB \0"); + // another maker's header: `focus::read` only decodes Sony and header-less notes + let foreign: [&[u8]; 13] = [ + b"Nikon\0", + b"FUJIFILM", + b"GENERALE", + b"OLYMPUS\0", + b"OM SYSTEM\0", + b"OLYMP\0", + b"EPSON\0", + b"MINOL\0", + b"CAMER\0", + b"SANYO\0", + b"Panasonic\0", + b"AOC\0", + b"PENTAX \0", + ]; + if !sony && foreign.iter().any(|p| head.starts_with(p)) { + return Step::Nothing; + } + let mut entries = None; + if sony { + match w.table(note.start.saturating_add(12)) { + None => return w.step(), + Some(e) if !e.is_empty() => entries = Some(e), + Some(_) => {} + } + } + let entries = match entries { + Some(e) => e, + None => match w.table(note.start) { + Some(e) => e, + None => return w.step(), + }, + }; + for e in entries.iter().filter(|e| FOCUS_TAGS.contains(&e.tag)) { + w.want(e.start, e.end); + } + w.step() +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A reader over `file` that counts what it is asked for. + fn read(file: &[u8]) -> (io::Result<HeaderRead>, u64) { + let mut asked = 0u64; + let r = read_header(file.len() as u64, &mut |off, buf: &mut [u8]| { + asked += buf.len() as u64; + let start = usize::try_from(off).map_err(io::Error::other)?; + let src = file.get(start..start + buf.len()).ok_or_else(|| io::Error::from(io::ErrorKind::UnexpectedEof))?; + buf.copy_from_slice(src); + Ok(()) + }); + (r, asked) + } + + #[test] + fn loaded_ranges() { + let mut l = Loaded::default(); + l.insert(10, 20); + l.insert(30, 40); + assert_eq!(l.gaps(0, 50), vec![(0, 10), (20, 30), (40, 50)]); + l.insert(20, 30); + assert_eq!(l.0, vec![(10, 40)]); + assert!(l.contains(12, 38) && !l.contains(5, 12)); + assert!(l.gaps(15, 35).is_empty()); + } + + #[test] + fn garbage_and_empty_files_are_no_point() { + for file in [vec![], vec![0xff, 0xd8], b"II*\0\x08\0\0\0".to_vec(), b"MM\0*\xff\xff\xff\xff".to_vec(), vec![0x42; 100_000]] { + let (r, _) = read(&file); + assert_eq!(r.unwrap().focus, None); + } + // a JPEG of nothing but fill bytes and tiny segments stops at its bounds + let mut j = vec![0xff, 0xd8]; + for _ in 0..5000 { + j.extend_from_slice(&[0xff, 0xe2, 0, 2]); + } + assert_eq!(read(&j).0.unwrap().focus, None); + } + + #[test] + fn a_file_that_shrinks_mid_read_is_an_error() { + // the reader is told the file is longer than it is (replaced between the stat and the read) + let r = read_header(1 << 20, &mut |_, _| Err(io::Error::from(io::ErrorKind::UnexpectedEof))); + assert!(r.is_err()); + } +} diff --git a/crates/meta/src/gpx.rs b/crates/meta/src/gpx.rs index e63499033..abfc9ebd4 100644 --- a/crates/meta/src/gpx.rs +++ b/crates/meta/src/gpx.rs @@ -114,11 +114,7 @@ pub fn parse_gpx(s: &str) -> Result<Tracklog, GpxError> { match name.as_slice() { b"gpx" if path.is_empty() => seen_gpx = true, b"trk" if parent == Some(b"gpx") => log.tracks += 1, - b"trkseg" if parent == Some(b"trk") => { - if log.points.last().is_some_and(|p| p.segment == segment) { - segment += 1; - } - } + b"trkseg" if parent == Some(b"trk") && log.points.last().is_some_and(|p| p.segment == segment) => segment += 1, b"trkpt" if parent == Some(b"trkseg") => { let mut p = Pending::default(); for a in e.attributes().with_checks(false).flatten() { diff --git a/crates/meta/src/lib.rs b/crates/meta/src/lib.rs index 31a35cc65..a8c043153 100644 --- a/crates/meta/src/lib.rs +++ b/crates/meta/src/lib.rs @@ -15,6 +15,8 @@ mod container; pub mod cr3; mod datetime; mod exif; +pub mod focus; +mod focus_read; mod gpx; mod iptc; pub mod tags; @@ -24,6 +26,7 @@ mod xmp_merge; pub use container::{Embedded, embedded, jpeg_segments, png_chunks, webp_chunks}; pub use datetime::DateTime; pub use exif::{from_tiff, read_exif, strip_exif_header, try_read_exif, write_exif}; +pub use focus::FocusPoint; pub use gpx::{GpxError, Match, TrackPoint, Tracklog, parse_gpx}; pub use iptc::parse_iptc; pub use lightcraft_geom::{Orientation, Rect}; @@ -142,6 +145,9 @@ pub struct Metadata { pub label: Option<String>, /// Face/pet/focus/barcode regions (`mwg-rs:Regions`), read from XMP only. pub regions: Vec<Region>, + /// Where the camera focused, from its maker notes ([`focus`]; Sony only for now), on the upright photo. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub focus: Option<FocusPoint>, } macro_rules! fill { @@ -205,6 +211,9 @@ impl Metadata { if self.regions.is_empty() { self.regions = other.regions.clone(); } + if self.focus.is_none() { + self.focus = other.focus.clone(); + } } /// Replace the user-editable fields (rating, label, title, caption, artist, copyright, keywords, GPS) with @@ -283,6 +292,18 @@ pub fn extract(bytes: &[u8]) -> Metadata { m } +/// A lens name from its focal and aperture range (Exif LensSpecification, or a Nikon maker note's Lens), written the +/// way Lightroom shows a lens it has no name for: `50.0 mm f/1.8`, `18.0-135.0 mm f/3.5-5.6`. +pub fn lens_spec_name(spec: [f64; 4]) -> Option<String> { + let [short, long, wide, narrow] = spec; + if ![short, long, wide, narrow].iter().all(|v| v.is_finite() && *v > 0.0) || long < short { + return None; + } + let focal = if (long - short).abs() < 0.05 { format!("{short:.1}") } else { format!("{short:.1}-{long:.1}") }; + let aperture = if (narrow - wide).abs() < 0.05 { format!("{wide:.1}") } else { format!("{wide:.1}-{narrow:.1}") }; + Some(format!("{focal} mm f/{aperture}")) +} + /// Lenient number parsing used by XMP/IPTC: `"28/10"`, `"2.8"`, `"+0.7"`. pub(crate) fn parse_number(s: &str) -> Option<f64> { let s = s.trim(); @@ -297,6 +318,17 @@ pub(crate) fn parse_number(s: &str) -> Option<f64> { mod tests { use super::*; + #[test] + fn lens_names_from_a_spec_read_like_lightroom() { + assert_eq!(lens_spec_name([50.0, 50.0, 1.8, 1.8]).as_deref(), Some("50.0 mm f/1.8")); + assert_eq!(lens_spec_name([18.0, 135.0, 3.5, 5.6]).as_deref(), Some("18.0-135.0 mm f/3.5-5.6")); + assert_eq!(lens_spec_name([24.0, 70.0, 2.8, 2.8]).as_deref(), Some("24.0-70.0 mm f/2.8")); + assert_eq!(lens_spec_name([70.0, 210.0, 4.0, 5.6]).as_deref(), Some("70.0-210.0 mm f/4.0-5.6")); + assert_eq!(lens_spec_name([0.0, 0.0, 0.0, 0.0]), None); + assert_eq!(lens_spec_name([200.0, 80.0, 2.8, 2.8]), None); + assert_eq!(lens_spec_name([f64::NAN, 50.0, 1.8, 1.8]), None); + } + #[test] fn numbers() { assert_eq!(parse_number("28/10"), Some(2.8)); diff --git a/crates/meta/src/tags.rs b/crates/meta/src/tags.rs index 39e26097d..3cbab5cc2 100644 --- a/crates/meta/src/tags.rs +++ b/crates/meta/src/tags.rs @@ -1,14 +1,15 @@ //! Every EXIF / TIFF / GPS tag of a file as readable `(group, name, value)` rows (the Info //! panel's "All Metadata"). Names follow the public TIFF 6.0 / EXIF 2.3 / DNG specifications; //! common enumerations are spelled out, everything else is shown as stored. Maker notes and -//! binary blobs are summarised by size. +//! binary blobs are summarised by size; of a maker note only the lens name is listed (the +//! Panasonic one, as Panasonic writes no Exif LensModel). use lightcraft_tiff::{Ifd, Tiff, Value}; /// One metadata row. #[derive(Clone, Debug, PartialEq)] pub struct TagRow { - /// `TIFF`, `EXIF`, `GPS`, `Interop`. + /// `TIFF`, `EXIF`, `GPS`, `Interop`, or a maker note's (`Panasonic`). pub group: &'static str, pub tag: u16, pub name: String, @@ -309,6 +310,13 @@ fn nikon_nef_compression(tiff: &Tiff, bytes: &[u8]) -> Option<&'static str> { nef_compression(mn.ifd.u32(0x0093)?) } +/// The Panasonic maker note's lens name (LensType, `0x0051`) as a row, the name `photo.inspect` shows as the lens. +fn panasonic_lens_row(data: &[u8], tiff: &Tiff, out: &mut Vec<TagRow>) { + if let Some(lens) = crate::exif::panasonic_maker_note_lens(data, tiff) { + out.push(TagRow { group: "Panasonic", tag: 0x0051, name: "Lens Type".into(), value: lens.trim().to_string() }); + } +} + /// Every tag of a parsed TIFF structure (a TIFF-based raw, a TIFF, or an EXIF block): IFD0, /// then the EXIF, GPS and interoperability IFDs. A raw file's Compression row is the sensor /// image's, not IFD0's thumbnail's. @@ -347,10 +355,15 @@ pub fn file_tag_rows(bytes: &[u8]) -> Vec<TagRow> { { row.value = words.to_string(); } + panasonic_lens_row(bytes, &t, &mut rows); return rows; } let Some(exif) = crate::embedded(bytes).exif else { return Vec::new() }; - Tiff::parse(crate::strip_exif_header(&exif)).map(|t| tag_rows(&t)).unwrap_or_default() + let data = crate::strip_exif_header(&exif); + let Ok(t) = Tiff::parse(data) else { return Vec::new() }; + let mut rows = tag_rows(&t); + panasonic_lens_row(data, &t, &mut rows); + rows } #[cfg(test)] @@ -461,4 +474,22 @@ mod tests { let b = [b"II*����".to_vec(), ifd(&[entry(0x0103, 3, 1, 8)])].concat(); assert_eq!(compression(&file_tag_rows(&b)), ["Deflate"]); } + + #[test] + fn panasonic_lens_is_listed_from_the_maker_note() { + let lens = |rows: &[TagRow]| rows.iter().filter(|r| r.group == "Panasonic" && r.tag == 0x0051).map(|r| r.value.clone()).collect::<Vec<_>>(); + // a RW2: the note is only in the JpgFromRaw's Exif + let rw2 = crate::exif::tests::panasonic_rw2("LUMIX S 85/F1.8"); + assert_eq!(lens(&file_tag_rows(&rw2)), ["LUMIX S 85/F1.8"]); + // a JPEG: the note is in its own Exif + let exif = crate::exif::tests::panasonic_exif(lightcraft_tiff::ByteOrder::Big, Some("LUMIX G 25/F1.7"), None); + let mut jpg = vec![0xff, 0xd8, 0xff, 0xe1]; + jpg.extend_from_slice(&((exif.len() + 8) as u16).to_be_bytes()); + jpg.extend(b"Exif\0\0"); + jpg.extend(exif); + jpg.extend_from_slice(&[0xff, 0xda, 0, 2, 0xff, 0xd9]); + assert_eq!(lens(&file_tag_rows(&jpg)), ["LUMIX G 25/F1.7"]); + // no Panasonic note, no row + assert!(lens(&file_tag_rows(&synthetic_raw(Some(3)))).is_empty()); + } } diff --git a/crates/meta/src/xmp.rs b/crates/meta/src/xmp.rs index 2a4d753dc..dbfdd5458 100644 --- a/crates/meta/src/xmp.rs +++ b/crates/meta/src/xmp.rs @@ -185,11 +185,22 @@ fn entity(name: &str) -> Option<char> { Some(h) => u32::from_str_radix(h, 16).ok()?, None => n.parse().ok()?, }; - char::from_u32(v)? + char::from_u32(v).filter(|&c| is_xml_char(c))? } }) } +/// The XML 1.0 `Char` production (the surrogate range can't occur in a `char`). +fn is_xml_char(c: char) -> bool { + matches!(c, '\t' | '\n' | '\r') || (c >= '\u{20}' && c != '\u{FFFE}' && c != '\u{FFFF}') +} + +/// Drop the characters XML 1.0 forbids, which strict parsers (Adobe XMP Toolkit, expat, libxml2) reject the +/// whole document for. +fn xml_safe(s: &str) -> String { + s.chars().filter(|&c| is_xml_char(c)).collect() +} + /// Unescape attribute values (predefined + numeric references; unknown references kept verbatim). fn unescape_attr(s: &str) -> String { let mut out = String::with_capacity(s.len()); @@ -252,13 +263,11 @@ fn parse_tree(s: &str) -> Result<Node, XmpError> { stack.push(node); } } - Event::End(_) => { - if stack.len() > 1 { - let n = stack.pop().expect("len > 1"); - scopes.pop(); - if let Some(parent) = stack.last_mut() { - parent.children.push(n); - } + Event::End(_) if stack.len() > 1 => { + let n = stack.pop().expect("len > 1"); + scopes.pop(); + if let Some(parent) = stack.last_mut() { + parent.children.push(n); } } Event::Text(t) => { @@ -508,10 +517,7 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { label: first("xmp:Label"), ..Default::default() }; - m.iso = num("exif:ISOSpeedRatings").or_else(|| num("exifEX:PhotographicSensitivity")).filter(|v| *v > 0.0).map(|v| v as u32); - if let Some(v) = props.get("exif:ISOSpeedRatings").and_then(|v| v.first()).and_then(|s| parse_number(s)) { - m.iso = Some(v as u32); - } + m.iso = [num("exif:ISOSpeedRatings"), num("exifEX:PhotographicSensitivity")].into_iter().flatten().find(|v| *v > 0.0).map(|v| v as u32); if let Some(spec) = props.get("exifEX:LensSpecification").filter(|v| v.len() == 4) { let v: Vec<f64> = spec.iter().filter_map(|s| parse_number(s)).collect(); if v.len() == 4 { @@ -538,6 +544,8 @@ pub fn parse_xmp(s: &str) -> Result<XmpData, XmpError> { } if let (Some(lat), Some(lon)) = (first("exif:GPSLatitude").and_then(|s| parse_gps_coord(&s)), first("exif:GPSLongitude").and_then(|s| parse_gps_coord(&s))) + && (-90.0..=90.0).contains(&lat) + && (-180.0..=180.0).contains(&lon) { let altitude = num("exif:GPSAltitude").map(|a| if first("exif:GPSAltitudeRef").as_deref() == Some("1") { -a } else { a }); m.gps = Some(Gps { latitude: lat, longitude: lon, altitude }); @@ -625,12 +633,12 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { } x.push_str(">\n"); for (k, v) in &simple { - x.push_str(&format!(" <{k}>{}</{k}>\n", escape(v.as_str()))); + x.push_str(&format!(" <{k}>{}</{k}>\n", escape(xml_safe(v)))); } for (k, v) in lc { if !k.is_empty() && k.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') { // element content: quotes needn't be escaped, which keeps the JSON readable - x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(*v))); + x.push_str(&format!(" <lc:{k}>{}</lc:{k}>\n", partial_escape(xml_safe(v)))); } } let array = |x: &mut String, k: &str, kind: &str, items: &[String], lang: bool| { @@ -640,9 +648,9 @@ pub fn write_xmp_lc(meta: &Metadata, lc: &[(&str, &str)]) -> String { x.push_str(&format!(" <{k}>\n <rdf:{kind}>\n")); for it in items { if lang { - x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li xml:lang=\"x-default\">{}</rdf:li>\n", escape(xml_safe(it)))); } else { - x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(it.as_str()))); + x.push_str(&format!(" <rdf:li>{}</rdf:li>\n", escape(xml_safe(it)))); } } x.push_str(&format!(" </rdf:{kind}>\n </{k}>\n")); @@ -686,6 +694,7 @@ mod tests { // write_xmp doesn't emit regions yet (read-only interchange; see docs/xmp-interop.md), so the // roundtrip fixture must leave this empty or `roundtrip_all_fields` can't round-trip it. regions: Vec::new(), + focus: None, make: Some("Maker & Sons".into()), model: Some("X <1>".into()), serial_number: Some("SN1".into()), @@ -803,6 +812,24 @@ mod tests { assert_eq!(read("True").copyright_url.as_deref(), Some("https://example.org/c")); } + /// A zero or negative `exif:ISOSpeedRatings` is "no ISO" (as in the EXIF reader), not `Some(0)`, and + /// must not hide a valid `exifEX:PhotographicSensitivity`. + #[test] + fn non_positive_iso_is_ignored() { + let read = |attrs: &str| { + let x = format!( + r#"<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"> + <rdf:Description rdf:about="" xmlns:exif="http://ns.adobe.com/exif/1.0/" xmlns:exifEX="http://cipa.jp/exif/1.0/" {attrs}/> + </rdf:RDF></x:xmpmeta>"# + ); + parse_xmp(&x).unwrap().metadata.iso + }; + assert_eq!(read(r#"exif:ISOSpeedRatings="0" exifEX:PhotographicSensitivity="400""#), Some(400)); + assert_eq!(read(r#"exif:ISOSpeedRatings="-100""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="0""#), None); + assert_eq!(read(r#"exif:ISOSpeedRatings="200""#), Some(200)); + } + #[test] fn malformed_inputs() { assert!(parse_xmp("<a><b></a>").is_ok() || parse_xmp("<a><b></a>").is_err()); @@ -841,6 +868,24 @@ mod tests { assert_eq!(fmt_gps_coord(-45.51, 'N', 'S'), "45,30.600000S"); } + /// Like the EXIF and GPX readers, XMP drops a position outside latitude ±90° / longitude ±180°. + #[test] + fn xmp_gps_out_of_range_rejected() { + let gps = |lat: &str, lon: &str| { + let x = format!( + r#"<rdf:Description xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#" + xmlns:e="http://ns.adobe.com/exif/1.0/" e:GPSLatitude="{lat}" e:GPSLongitude="{lon}"/>"# + ); + parse_xmp(&x).unwrap().metadata.gps + }; + assert_eq!(gps("95,0N", "200,30E"), None); + assert_eq!(gps("95,0N", "10,0E"), None); + assert_eq!(gps("10,0S", "200,30W"), None); + assert_eq!(gps("90,0,1N", "0,0E"), None); + assert_eq!(gps("90,0N", "180,0E"), Some(Gps { latitude: 90.0, longitude: 180.0, altitude: None })); + assert_eq!(gps("90,0S", "180,0W"), Some(Gps { latitude: -90.0, longitude: -180.0, altitude: None })); + } + /// MWG Region Guidelines v2.0, element form (`rdf:parseType="Resource"`, self-closing `Area`) — how /// Lightroom typically writes it. One named face, one untyped/unnamed region (still usable), one /// region with a description. `AppliedToDimensions` is present but irrelevant: normalized areas @@ -1040,4 +1085,24 @@ mod tests { a.overlay_user_fields(&b); assert_eq!(a.regions, vec![with_region]); } + + #[test] + fn writer_drops_characters_xml_1_0_forbids() { + let bad = "a\u{1}b\u{0}c\u{FFFE}d\u{FFFF}e\u{1F}\tf\ng\rh"; + let m = Metadata { make: Some(bad.into()), caption: Some(bad.into()), keywords: vec![bad.into()], ..Default::default() }; + let x = write_xmp_lc(&m, &[("settings", bad)]); + let illegal: Vec<char> = + x.chars().filter(|&c| (c < '\u{20}' && !matches!(c, '\t' | '\n' | '\r')) || c == '\u{FFFE}' || c == '\u{FFFF}').collect(); + assert!(illegal.is_empty(), "illegal XML chars in output: {illegal:?}"); + assert!(x.contains(">abcde\tf\ng\rh<"), "allowed characters must survive: {x}"); + } + + #[test] + fn numeric_references_to_forbidden_characters_are_not_decoded() { + assert_eq!(entity("#1"), None); + assert_eq!(entity("#0"), None); + assert_eq!(entity("#xFFFE"), None); + assert_eq!(entity("#x41"), Some('A')); + assert_eq!(entity("#9"), Some('\t')); + } } diff --git a/crates/pipeline/src/lib.rs b/crates/pipeline/src/lib.rs index d94d3a2fc..d3049bc7d 100644 --- a/crates/pipeline/src/lib.rs +++ b/crates/pipeline/src/lib.rs @@ -92,6 +92,11 @@ pub struct SourceInfo { /// The raw's own local tone mapping (DNG `ProfileGainTableMap`), rendered only when the /// photo's Profile option asks for it ([`local_tone`]). pub local_tone: Option<Arc<local_tone::LocalTone>>, + /// Long edge (px) of the original photo, when the source handed to the renderer is a smaller + /// copy of it (the app's thumbnail and preview levels); `None`: the source is the original. + /// Noise reduction sizes its radii in the original's pixels ([`Plan::detail_scale`]), so a + /// preview looks like the full-size render scaled down (issue #960). + pub native_long: Option<usize>, } impl Default for SourceInfo { @@ -107,6 +112,7 @@ impl Default for SourceInfo { camera_tone: None, mattes: None, local_tone: None, + native_long: None, } } } @@ -133,7 +139,7 @@ pub struct RenderRequest { pub space: OutputSpace, /// Sample format: 8-bit only, or also a 16-bit / linear float [`Rendered::deep`] (exports). pub depth: OutputDepth, - /// Soft proofing (CPU only; see [`Proof`]). + /// Soft proofing (a proof against an output-device profile renders on the CPU; see [`Proof`]). pub proof: Option<Proof>, /// 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`]. @@ -360,6 +366,9 @@ pub struct Plan<'a> { pub px_per_long: f64, /// Long edge of the source buffer (px). pub src_long: usize, + /// Output pixels per pixel of the original photo ([`SourceInfo::native_long`], else the source + /// buffer), at most 1: the scale of noise reduction, whose radii are in the original's pixels. + pub detail_scale: f32, /// Key of the resampled source (together with the source buffer's identity). pub geo: u64, /// Key of the white-balanced, retouched, denoised image (and of the planes computed from it). @@ -423,6 +432,9 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R None => (frame, full_w, full_h), }; let src_long = src.width.max(src.height); + // (an original reported smaller than the source is the source) + let native_long = info.native_long.unwrap_or(0).max(src_long).max(1); + let detail_scale = local::detail_scale(px_per_long, native_long); let geo = hash_of((format!("{frame:?}"), w, h)); let (wb_t, wb_tint) = local::effective_wb(info, s); let eyes = redeye::resolve(src, &s.red_eye, s.orientation, &frame, w, h, px_per_long); @@ -438,8 +450,12 @@ pub fn plan<'a>(src: &Rgb32f, info: &SourceInfo, s: &'a DevelopSettings, req: &R local_tone::enabled(info, s), src_long, )); - let lin_key = hash_of((pre_nr_key, [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits))); - Plan { settings, frame, w, h, px_per_long, src_long, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } + let lin_key = hash_of(( + pre_nr_key, + [d.nr_luminance, d.nr_detail, d.nr_color, d.nr_color_detail, d.nr_color_smoothness].map(f64::to_bits), + detail_scale.to_bits(), + )); + Plan { settings, frame, w, h, px_per_long, src_long, detail_scale, geo, lin_key, pre_nr_key, eyes, fixed_air, keep, mattes: info.mattes.clone() } } /// Whether the scene-linear stage needs work only the CPU does (defringe, spot removal). @@ -538,7 +554,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende Src::Shared(a) => a, }; let plan = plan(src_img, info, s, req); - let Plan { ref frame, w, h, px_per_long, src_long, geo, lin_key, .. } = plan; + let Plan { ref frame, w, h, px_per_long, detail_scale, geo, lin_key, .. } = plan; let s = &*plan.settings; let shared = match (&src, cache) { @@ -558,7 +574,7 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende // Without a cache the resampled buffer is ours: work on it in place. let mut img = if shared.is_some() { (*sampled).clone() } else { Arc::unwrap_or_clone(sampled.clone()) }; lin_cpu(&mut img, info, &plan); - local::denoise(&mut img, s, src_long, frame.output_long(w, h)); + local::denoise(&mut img, s, detail_scale); Arc::new(img) } }; @@ -618,7 +634,11 @@ fn render_impl(src: Src<'_>, info: &SourceInfo, s: &DevelopSettings, req: &Rende /// exposed scene colours, as [`MaskShape::ColorRange`](lightcraft_develop::MaskShape) compares /// them), averaged over 3 × 3 pixels of a render fitting `req`. `None` outside the image. pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, req: &RenderRequest, p: lightcraft_geom::Point) -> Option<[f64; 3]> { - let plan = plan(src, info, s, req); + // Pick from the same settings as the render: disabled panels contribute nothing, and an + // SDR preview of an HDR edit includes the SDR rendition's exposure offset. + let effective = s.effective(); + let rendition = settings_for(&effective, req); + let plan = plan(src, info, &rendition, req); let mut img = plan.frame.sample(src, plan.w, plan.h); lin_cpu(&mut img, info, &plan); let o = plan.frame.norm_to_out(plan.w, plan.h).apply(p); @@ -626,7 +646,7 @@ pub fn color_range_sample(src: &Rgb32f, info: &SourceInfo, s: &DevelopSettings, if cx < 0 || cy < 0 || cx >= plan.w as i64 || cy >= plan.h as i64 { return None; } - let gain = (s.light.exposure as f32).exp2(); + let gain = (plan.settings.light.exposure as f32).exp2(); let mut acc = [0f64; 3]; let mut n = 0.0; for y in (cy - 1).max(0)..=(cy + 1).min(plan.h as i64 - 1) { @@ -713,6 +733,8 @@ mod tests_hdr; #[cfg(test)] mod tests_local; #[cfg(test)] +mod tests_nr_scale; +#[cfg(test)] mod tests_tone; #[cfg(test)] mod tests_window; diff --git a/crates/pipeline/src/local.rs b/crates/pipeline/src/local.rs index a99530777..679acabfe 100644 --- a/crates/pipeline/src/local.rs +++ b/crates/pipeline/src/local.rs @@ -147,7 +147,7 @@ pub fn legacy_to_kelvin(temp: f64, tint: f64, as_shot: (f64, f64)) -> (f64, f64) /// edited on a rendered photo or a raw without matrices never pass for Kelvin when pasted onto one that has them. pub fn normalize_wb(d: &mut DevelopSettings, info: &SourceInfo) { use lightcraft_develop::{WbMode, WbScale}; - if !(info.raw && !info.relative_wb) { + if !info.raw || info.relative_wb { d.wb.scale = WbScale::Legacy; return; } @@ -306,28 +306,64 @@ pub struct NrColor { pub t: f32, } -/// Noise-reduction parameters at an output long edge of `out_long` px (see [`denoise`]). -pub fn nr_params(s: &DevelopSettings, src_long: usize, out_long: usize) -> (Option<NrLum>, Option<NrColor>) { +/// Output pixels per pixel of the original photo for an output of `px_per_long` pixels per unit +/// of the long edge of an original `native_long` pixels long, at most 1 ([`crate::Plan::detail_scale`]). +pub fn detail_scale(px_per_long: f64, native_long: usize) -> f32 { + let k = (px_per_long / native_long.max(1) as f64) as f32; + if k.is_finite() { k.clamp(0.0, 1.0) } else { 1.0 } +} + +/// Smallest radius (px) noise reduction filters a preview with: [`gaussian`] makes no blur below +/// about 0.58 px and one 3-px box from there ([`lightcraft_raster::blur::box_sigma`]). +const NR_MIN_SIGMA: f32 = 1.0; + +/// Below this fade a preview's noise reduction is not drawn at all (less than 1 % of a 1-px blur). +const NR_MIN_BLEND: f32 = 0.01; + +/// Radius and blend weight that stand in for a blur of `sigma` px: below [`NR_MIN_SIGMA`] the +/// smallest blur the filter makes, blended in by the ratio of their variances, which keeps the +/// smoothing's second moment (how much it softens the finest detail) that of `sigma`. +fn nr_radius(sigma: f32) -> (f32, f32) { + if sigma >= NR_MIN_SIGMA { + return (sigma, 1.0); + } + let made = lightcraft_raster::blur::box_sigma(NR_MIN_SIGMA); + (NR_MIN_SIGMA, (sigma / made).powi(2).min(1.0)) +} + +/// Noise-reduction parameters at `scale` output pixels per pixel of the original +/// ([`crate::Plan::detail_scale`]; see [`denoise`]). Noise lives at the original's pixels, so the +/// radii are the full-size radii times `scale`, and the guided filter's `eps` (the variance it +/// treats as noise) follows the noise variance, which averaging `1/scale × 1/scale` pixels into one +/// multiplies by `scale²`. A radius below [`NR_MIN_SIGMA`] fades instead ([`nr_radius`]): previews look like +/// the full-size render scaled down (issue #960). At `scale` 1 these are the full-size parameters. +pub fn nr_params(s: &DevelopSettings, scale: f32) -> (Option<NrLum>, Option<NrColor>) { let lum = (s.detail.nr_luminance / 100.0) as f32; let col = (s.detail.nr_color / 100.0) as f32; - let scale = (out_long as f32 / src_long.max(1) as f32).clamp(0.05, 1.0); - let l = (lum > 0.0).then(|| { + let scale = if scale.is_finite() { scale.clamp(0.0, 1.0) } else { 1.0 }; + let l = (lum > 0.0).then(|| nr_radius((1.0 + 2.5 * lum) * scale)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| { let detail = (s.detail.nr_detail / 100.0) as f32; - NrLum { sigma: (1.0 + 2.5 * lum) * scale.max(0.4), eps: 0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail), k: lum.sqrt() } + let eps = ((0.002 + lum * lum * 0.25 * (1.0 - 0.7 * detail)) * scale * scale).max(1e-7); + NrLum { sigma, eps, k: lum.sqrt() * w } }); - let c = (col > 0.0).then(|| { - let sigma = (1.5 + 6.0 * col) * scale.max(0.35) * (1.0 + (s.detail.nr_color_smoothness / 100.0) as f32); + let smooth = 1.0 + (s.detail.nr_color_smoothness / 100.0) as f32; + let c = (col > 0.0).then(|| nr_radius((1.5 + 6.0 * col) * scale * smooth)).filter(|&(_, w)| w >= NR_MIN_BLEND).map(|(sigma, w)| { let keep = (s.detail.nr_color_detail / 100.0) as f32 * 0.5; - NrColor { sigma, t: col * (1.0 - keep) } + NrColor { sigma, t: col * (1.0 - keep) * w } }); (l, c) } /// Noise reduction at output resolution: luminance via an edge-aware self-guided filter on /// log-luminance, colour by blurring chromaticity (rgb / Y) and re-applying the original luminance. -/// Radii scale with how much the source was downsampled (preview noise is already averaged out). -pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, src_long: usize, out_long: usize) { - let (lum, col) = nr_params(s, src_long, out_long); +/// `scale`: output pixels per pixel of the original ([`nr_params`]). +pub fn denoise(img: &mut Rgb32f, s: &DevelopSettings, scale: f32) { + let (lum, col) = nr_params(s, scale); + apply_nr(img, lum, col); +} + +/// Noise reduction with explicit parameters (see [`denoise`]). +pub fn apply_nr(img: &mut Rgb32f, lum: Option<NrLum>, col: Option<NrColor>) { let w = img.width; if let Some(nr) = lum { let _t = crate::profiling().then(std::time::Instant::now); @@ -694,7 +730,7 @@ mod nr_tests { let (v0, m0) = var(&img); let mut s = DevelopSettings::default(); s.detail.nr_luminance = 80.0; - denoise(&mut img, &s, 64, 64); + denoise(&mut img, &s, 1.0); let (v1, m1) = var(&img); assert!(v1 < v0 * 0.5, "{v0} -> {v1}"); assert!((m1 - m0).abs() < 0.01); diff --git a/crates/pipeline/src/masks.rs b/crates/pipeline/src/masks.rs index 14fa558f8..f2aac5881 100644 --- a/crates/pipeline/src/masks.rs +++ b/crates/pipeline/src/masks.rs @@ -440,6 +440,44 @@ pub struct BrushDabs { pub erase: bool, /// Auto Mask: paint only pixels like the one under each dab, then snap to edges. pub auto: bool, + /// Pen pressure (0..1) of each dab, parallel to `dabs`; empty when the stroke has no pressure data (full pressure). + pub pressure: Vec<f32>, +} + +impl BrushDabs { + /// Radius, hard core and flow of dab `i`, pen pressure applied ([`pressure_radius`], [`pressure_flow`]). + pub fn dab(&self, i: usize) -> (f64, f64, f32) { + match self.pressure.get(i) { + Some(&p) => { + let r = pressure_radius(self.r, p); + (r, if self.r > 0.0 { self.hard * r / self.r } else { self.hard }, pressure_flow(self.flow, p)) + } + None => (self.r, self.hard, self.flow), + } + } +} + +/// The radius of a dab of a brush of radius `r` (pixels) painted at pen pressure `p` (0..1): from a tenth of the +/// brush at the lightest touch to the full brush at full pressure, never below half a pixel. +#[inline] +pub fn pressure_radius(r: f64, p: f32) -> f64 { + let p = if p.is_nan() { 1.0 } else { f64::from(p.clamp(0.0, 1.0)) }; + (r * (0.1 + 0.9 * p)).max(0.5).min(r.max(0.5)) +} + +/// The flow (0..1) of a dab painted at pen pressure `p` (0..1): proportional to the pressure. +#[inline] +pub fn pressure_flow(flow: f32, p: f32) -> f32 { + let p = if p.is_nan() { 1.0 } else { p.clamp(0.0, 1.0) }; + flow * p +} + +/// Pressures for the `added` dabs [`push_dabs`] just laid from a sample at pressure `from` to one at `to`: evenly +/// interpolated, the last one at `to`, as the dabs themselves are placed. +pub fn push_pressures(pressure: &mut Vec<f32>, added: usize, from: f32, to: f32) { + for k in 1..=added { + pressure.push(from + (to - from) * k as f32 / added as f32); + } } /// Output pixels a brush sample must move from the last dab to count as movement: below this the @@ -456,11 +494,22 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa // long the button is held: a sample where the pointer hasn't moved (a stationary hold records // one per frame) lays no dab, so waiting in place adds nothing (#518). let mut dabs: Vec<Point> = Vec::new(); + let mut pressure: Vec<f32> = Vec::new(); + let with_pressure = !s.pressure.is_empty(); let mut prev = None; - for p in &s.points { + let mut prev_p = 1.0; + for (i, p) in s.points.iter().enumerate() { let q = to_out.apply(*p); + let before = dabs.len(); if push_dabs(&mut dabs, prev, q, r) { + let pq = s.pressure_at(i); + if with_pressure { + // the first dab of the stroke has nothing to interpolate from + let from = if prev.is_some() { prev_p } else { pq }; + push_pressures(&mut pressure, dabs.len() - before, from, pq); + } prev = Some(q); + prev_p = pq; } } BrushDabs { @@ -471,6 +520,7 @@ pub fn brush_dabs(s: &BrushStroke, frame: &Frame, w: usize, h: usize) -> BrushDa density: (s.density / 100.0).clamp(0.0, 1.0) as f32, erase: s.erase, auto: s.auto_mask, + pressure, } } @@ -596,9 +646,11 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i return; } for s in strokes { - let BrushDabs { dabs, r, hard, flow, density: dens, erase, auto } = brush_dabs(s, frame, w, h); + let bd = brush_dabs(s, frame, w, h); + let (dens, erase, auto) = (bd.density, bd.erase, bd.auto); let mut stroke_alpha = Plane::new(w, h); - for d in &dabs { + for (k, d) in bd.dabs.iter().enumerate() { + let (r, hard, flow) = bd.dab(k); let refc = auto.then(|| { let j = dab_pixel(*d, w, h); (log_l.data[j], chromaticity(img.data[j])) @@ -623,7 +675,7 @@ fn rasterize_brush(strokes: &[BrushStroke], frame: &Frame, w: usize, h: usize, i } } if auto { - let (sigma, eps) = auto_refine(r); + let (sigma, eps) = auto_refine(bd.r); let q = guided_cross(log_l, &stroke_alpha, sigma, eps); // the refinement fills gaps along edges; it never takes paint away stroke_alpha = stroke_alpha.zip_map(&q, f32::max); @@ -794,6 +846,39 @@ mod tests { assert!(v.len() <= 1_000_002, "{}", v.len()); } + /// Pen pressure scales each dab's radius and flow; a stroke without pressure data paints as before, and hostile + /// pressures (NaN, out of range, a short list) neither panic nor paint more than full pressure. + #[test] + fn pen_pressure_scales_dab_size_and_flow() { + let (w, h) = (400, 100); + let f = frame(w, h); + let img = Rgb32f::new(w, h); + let l = Plane::new(w, h); + let line = vec![Point::new(0.1, 0.5), Point::new(0.9, 0.5)]; + let alpha = |pressure: Vec<f32>| { + let s = BrushStroke { points: line.clone(), size: 0.05, feather: 0.0, flow: 100.0, pressure, ..Default::default() }; + shape_alpha(&MaskShape::Brush { strokes: vec![s] }, &f, w, h, &img, &l, 0.0, None) + }; + let full = alpha(vec![]); + assert_eq!(full.data, alpha(vec![1.0, 1.0]).data, "full pressure is no pressure"); + // light at the start, full at the end: thin and faint on the left, as wide as the brush on the right + let ramp = alpha(vec![0.1, 1.0]); + let width = |a: &Plane, x: usize| (0..h).filter(|y| a.get(x, *y) > 0.01).count(); + assert!(width(&ramp, 60) < width(&full, 60) / 2, "{} vs {}", width(&ramp, 60), width(&full, 60)); + assert!(width(&ramp, 350) + 2 >= width(&full, 350)); + assert!(ramp.get(60, 50) < full.get(60, 50) + 1e-6); + let faint = alpha(vec![0.2]); + assert!(faint.data.iter().zip(&full.data).all(|(a, b)| *a <= *b + 1e-6), "never more than full pressure"); + assert!(faint.get(200, 50) > 0.0 && faint.get(200, 50) < full.get(200, 50)); + for hostile in [vec![f32::NAN, f32::INFINITY], vec![-3.0], vec![0.5; 10_000]] { + let a = alpha(hostile); + assert!(a.data.iter().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); + } + assert_eq!(pressure_radius(10.0, f32::NAN), 10.0); + assert_eq!(pressure_radius(10.0, 0.0), 1.0); + assert_eq!(pressure_flow(0.8, 2.0), 0.8); + } + #[test] fn brush_and_subtract() { let f = frame(200, 100); diff --git a/crates/pipeline/src/tests.rs b/crates/pipeline/src/tests.rs index 00851fa2a..910705497 100644 --- a/crates/pipeline/src/tests.rs +++ b/crates/pipeline/src/tests.rs @@ -272,6 +272,61 @@ fn exposure_after_spatial_filters_equals_exposing_the_source() { assert!(d <= 2, "max difference {d}"); } +#[test] +fn color_range_sampling_respects_disabled_sections() { + use crate::{MaskView, Overlay, color_range_sample}; + let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]); + let info = SourceInfo::default(); + let req = RenderRequest::fit(32, 24); + let point = lightcraft_geom::Point::new(0.5, 0.5); + let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &req, point).unwrap(); + for section in ["light", "color"] { + let mut s = DevelopSettings::default(); + if section == "light" { + s.light.exposure = 3.0; + } else { + s.wb.mode = lightcraft_develop::WbMode::Custom; + s.wb.temp = 12000.0; + s.wb.tint = 100.0; + } + assert_ne!(color_range_sample(&src, &info, &s, &req, point).unwrap(), plain, "enabled {section}"); + s.set_section_enabled(section, false); + let sample = color_range_sample(&src, &info, &s, &req, point).unwrap(); + assert_eq!(sample, plain, "disabled {section} must not change the picked colour"); + s.masks.push(lightcraft_develop::Mask { + id: 1, + components: vec![lightcraft_develop::MaskComponent { + name: None, + op: lightcraft_develop::MaskOp::Add, + invert: false, + shape: lightcraft_develop::MaskShape::ColorRange { samples: vec![sample], refine: 0.0 }, + }], + ..Default::default() + }); + let overlay = RenderRequest { overlay: Overlay::Mask { id: 1, view: MaskView::WhiteOnBlack, color: [255, 0, 0], opacity: 100 }, ..req }; + let img = render(&src, &info, &s, &overlay).image; + assert_eq!(img.data[12 * 32 + 16], [255; 4], "the sampled colour is selected with {section} off"); + } +} + +#[test] +fn color_range_sampling_uses_the_requested_hdr_rendition() { + use crate::{OutputDepth, color_range_sample}; + let src = Rgb32f::from_fn(32, 24, |_, _| [0.15, 0.25, 0.08]); + let info = SourceInfo::default(); + let point = lightcraft_geom::Point::new(0.5, 0.5); + let mut s = DevelopSettings::default(); + s.hdr.enabled = true; + s.hdr.sdr_brightness = 100.0; + let req = RenderRequest::fit(32, 24); + let expected = color_range_sample(&src, &info, &s.sdr_rendition(), &req, point).unwrap(); + assert_eq!(color_range_sample(&src, &info, &s, &req, point).unwrap(), expected, "SDR preview brightness affects the picked colour"); + let hdr = RenderRequest { depth: OutputDepth::F32Hdr, ..req }; + let plain = color_range_sample(&src, &info, &DevelopSettings::default(), &hdr, point).unwrap(); + assert_eq!(color_range_sample(&src, &info, &s, &hdr, point).unwrap(), plain, "HDR does not use the SDR offset"); + assert_ne!(plain, expected); +} + /// A section whose eye is off renders as if it were at its defaults (issue #316): Light, Color /// and Detail, which the pipeline doesn't switch off where it applies them. #[test] diff --git a/crates/pipeline/src/tests_nr_scale.rs b/crates/pipeline/src/tests_nr_scale.rs new file mode 100644 index 000000000..3247422fa --- /dev/null +++ b/crates/pipeline/src/tests_nr_scale.rs @@ -0,0 +1,211 @@ +//! Noise reduction is resolution independent (issue #960): a preview looks like the full-size +//! render scaled down, whatever the preview's size and whichever source level it is drawn from. + +use crate::local::{denoise, log_lum, nr_params}; +use crate::{RenderRequest, SourceInfo, plan, render}; +use lightcraft_develop::DevelopSettings; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Rgb32f, Rgba8}; + +const W: usize = 1536; +const H: usize = 1024; + +/// Deterministic standard normal samples (xorshift + Box-Muller). +struct Rng(u64); + +impl Rng { + fn uniform(&mut self) -> f32 { + self.0 ^= self.0 << 13; + self.0 ^= self.0 >> 7; + self.0 ^= self.0 << 17; + ((self.0 >> 40) as f32 + 0.5) / (1u64 << 24) as f32 + } + fn normal(&mut self) -> f32 { + let (u, v) = (self.uniform(), self.uniform()); + (-2.0 * u.ln()).sqrt() * (std::f32::consts::TAU * v).cos() + } +} + +/// A `W × H` linear scene: a gradient, an edge and texture from 3 to 750 px wavelength, with white +/// luminance noise of `noise` stops and colour noise. +fn noisy_scene(noise: f32) -> Rgb32f { + let mut rng = Rng(0x9e37_79b9_7f4a_7c15); + let n: Vec<[f32; 4]> = (0..W * H).map(|_| [rng.normal(), rng.normal(), rng.normal(), rng.normal()]).collect(); + Rgb32f::from_fn(W, H, |x, y| { + let r = n[y * W + x]; + let (xf, yf) = (x as f32, y as f32); + let tex = 0.25 * (xf / 120.0).sin() * (yf / 90.0).cos() + + 0.12 * ((xf + yf) / 16.0).sin() + + 0.08 * (xf / 6.0).sin() * (yf / 7.0).sin() + + 0.05 * ((xf - 2.0 * yf) / 3.0).sin(); + let edge = if x > 900 { 1.2 } else { 0.0 }; + let y0 = 0.18 * (-1.5 + 1.5 * yf / H as f32 + tex + edge + noise * r[0]).exp2(); + let c = |v: f32| y0 * (1.0 + 0.3 * noise * v).max(0.05); + [c(r[1]), c(r[2]), c(r[3])] + }) +} + +fn scaled(img: &Rgb32f, s: f32) -> Rgb32f { + resize(img, (W as f32 * s).round() as usize, (H as f32 * s).round() as usize, Filter::Mitchell) +} + +/// RMS difference of log2 luminance (stops). +fn rms_log(a: &Rgb32f, b: &Rgb32f) -> f32 { + let n = a.data.len() as f32; + (a.data.iter().zip(&b.data).map(|(p, q)| (log_lum(*p) - log_lum(*q)).powi(2)).sum::<f32>() / n).sqrt() +} + +/// RMS difference of chromaticity (r and b over r + g + b). +fn rms_chroma(a: &Rgb32f, b: &Rgb32f) -> f32 { + let ch = |p: [f32; 3]| { + let s = (p[0] + p[1] + p[2]).max(1e-6); + [p[0] / s, p[2] / s] + }; + let d = |p: [f32; 3], q: [f32; 3]| (ch(p)[0] - ch(q)[0]).powi(2) + (ch(p)[1] - ch(q)[1]).powi(2); + (a.data.iter().zip(&b.data).map(|(p, q)| d(*p, *q)).sum::<f32>() / a.data.len() as f32).sqrt() +} + +/// For each preview scale `k`: (k, how far the preview is from the full-size render scaled down, +/// how far it would be with noise reduction left off). +fn preview_error(s: &DevelopSettings, scales: &[f32], metric: fn(&Rgb32f, &Rgb32f) -> f32) -> Vec<(f32, f32, f32)> { + let src = noisy_scene(0.15); + let mut full = src.clone(); + denoise(&mut full, s, 1.0); + scales + .iter() + .map(|&k| { + let reference = scaled(&full, k); + let plain = scaled(&src, k); + let mut preview = plain.clone(); + denoise(&mut preview, s, k); + (k, metric(&preview, &reference), metric(&plain, &reference)) + }) + .collect() +} + +const SCALES: [f32; 6] = [0.5, 0.33, 0.25, 0.167, 0.1, 0.05]; + +#[test] +fn luminance_nr_preview_matches_full_size_scaled_down() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 50.0; + s.detail.nr_detail = 50.0; + s.detail.nr_color = 0.0; + for (k, err, off) in preview_error(&s, &SCALES, rms_log) { + let ratio = err / off; + eprintln!("luminance NR, preview at {k}: {err:.4} stops from the full-size render scaled down ({ratio:.2} of NR off)"); + // Before #960 the radius stopped shrinking at 0.4×: 2.3× (at 0.167) to 6× (at 0.05) further + // off than no noise reduction at all, and 0.53 / 0.70 of it at 0.5 / 0.33. + assert!(ratio <= 1.0, "preview at {k} is further off than no noise reduction: {err} vs {off}"); + assert!(err <= 0.01, "preview at {k} is {err} stops off"); + if k >= 0.33 { + assert!(ratio <= 0.4, "preview at {k}: {ratio}"); + } + } +} + +#[test] +fn colour_nr_preview_matches_full_size_scaled_down() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 0.0; + s.detail.nr_color = 50.0; + s.detail.nr_color_detail = 50.0; + s.detail.nr_color_smoothness = 50.0; + for (k, err, off) in preview_error(&s, &SCALES, rms_chroma) { + let ratio = err / off; + eprintln!("colour NR, preview at {k}: {err:.6} from the full-size render scaled down ({ratio:.2} of NR off)"); + // before #960: 0.00020 (0.31 of NR off) at 0.167, 0.00022 (0.75) at 0.1 + assert!(err <= 1.2e-4, "preview at {k} is {err} off"); + if k >= 0.1 { + assert!(ratio <= 0.4, "preview at {k}: {ratio}"); + } + } +} + +#[test] +fn full_size_parameters_unchanged() { + // full-size renders and exports keep their look: these are the parameters from before #960 + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 50.0; + s.detail.nr_detail = 50.0; + s.detail.nr_color = 25.0; + s.detail.nr_color_detail = 50.0; + s.detail.nr_color_smoothness = 50.0; + let (l, c) = nr_params(&s, 1.0); + let (l, c) = (l.unwrap(), c.unwrap()); + assert_eq!(l.sigma, 1.0 + 2.5 * 0.5); + assert_eq!(l.eps, 0.002 + 0.5 * 0.5 * 0.25 * (1.0 - 0.7 * 0.5)); + assert_eq!(l.k, 0.5f32.sqrt()); + assert_eq!(c.sigma, (1.5 + 6.0 * 0.25) * 1.5); + assert_eq!(c.t, 0.25 * (1.0 - 0.25)); +} + +#[test] +fn tiny_previews_skip_noise_reduction_and_hostile_scales_are_safe() { + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 100.0; + s.detail.nr_color = 100.0; + // a 128-px thumbnail of a 6000-px photo: the luminance radius is a few hundredths of a pixel, + // the colour radius (wider) a quarter of one, so that fades to a tenth + let (l, c) = nr_params(&s, 128.0 / 6000.0); + assert_eq!(l, None); + let (c, full) = (c.unwrap(), nr_params(&s, 1.0).1.unwrap()); + assert!(c.t < full.t * 0.1 && c.t > 0.0, "{c:?}"); + assert_eq!(nr_params(&s, 0.0), (None, None)); + for k in [f32::NAN, f32::INFINITY, -1.0, 7.0] { + let (l, c) = nr_params(&s, k); + let vals = [l.map(|l| [l.sigma, l.eps, l.k]), c.map(|c| [c.sigma, c.t, 1.0])]; + for v in vals.into_iter().flatten().flatten() { + assert!(v.is_finite() && v >= 0.0, "{k}: {v}"); + } + } +} + +fn mean_abs_diff(a: &Rgba8, b: &Rgba8) -> f32 { + let px = |p: &[u8; 4], q: &[u8; 4]| (0..3).map(|i| (f32::from(p[i]) - f32::from(q[i])).abs()).sum::<f32>() / 3.0; + a.data.iter().zip(&b.data).map(|(p, q)| px(p, q)).sum::<f32>() / a.data.len() as f32 +} + +#[test] +fn preview_from_a_smaller_source_level_matches_one_from_the_original() { + // The app draws a grid cell from the 512-px thumbnail level and the loupe from the 2560-px + // preview level. Told the original's size, a render from the smaller copy denoises like one from + // the original at the same output size; before #960 its radii were in the copy's pixels. + let mut s = DevelopSettings::default(); + s.detail.nr_luminance = 60.0; + s.detail.nr_color = 40.0; + let original = noisy_scene(0.15); + let thumb_level = scaled(&original, 512.0 / W as f32); + let req = RenderRequest::fit(256, 256); + let raw = SourceInfo { raw: true, ..Default::default() }; + let told = SourceInfo { native_long: Some(W), ..raw.clone() }; + assert!((plan(&thumb_level, &told, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4); + assert!((plan(&original, &raw, &s, &req).detail_scale - 256.0 / W as f32).abs() < 1e-4); + assert!((plan(&thumb_level, &raw, &s, &req).detail_scale - 0.5).abs() < 1e-4, "original unknown: the source's pixels"); + + let mut plain = s.clone(); + plain.detail.nr_luminance = 0.0; + plain.detail.nr_color = 0.0; + let from_original = render(&original, &raw, &s, &req).image; + let told_diff = mean_abs_diff(&render(&thumb_level, &told, &s, &req).image, &from_original); + let untold_diff = mean_abs_diff(&render(&thumb_level, &raw, &s, &req).image, &from_original); + // (the two sources differ a little anyway: the thumbnail level is resampled twice) + let resampling = mean_abs_diff(&render(&thumb_level, &told, &plain, &req).image, &render(&original, &raw, &plain, &req).image); + eprintln!("mean |Δ| (8-bit) from the original's render: told {told_diff:.3}, not told {untold_diff:.3}, NR off {resampling:.3}"); + assert!(told_diff <= resampling + 0.1, "{told_diff} vs {resampling}"); + assert!(untold_diff > told_diff + 0.3, "the copy's own pixels change the result: {untold_diff} vs {told_diff}"); +} + +#[test] +fn crop_does_not_change_the_noise_reduction_scale() { + // a 1:1 render of a crop is at the original's pixels, like a 1:1 render of the whole photo + let s = DevelopSettings::default(); + let src = Rgb32f::filled(400, 200, [0.2; 3]); + let info = SourceInfo::default(); + assert!((plan(&src, &info, &s, &RenderRequest::fit(400, 200)).detail_scale - 1.0).abs() < 1e-6); + let mut cropped = s.clone(); + cropped.crop.geometry.rect = lightcraft_geom::Rect::new(0.25, 0.25, 0.75, 0.75); + let p = plan(&src, &info, &cropped, &RenderRequest::fit(200, 100)); + assert_eq!((p.w, p.h), (200, 100)); + assert!((p.detail_scale - 1.0).abs() < 1e-6, "{}", p.detail_scale); +} diff --git a/crates/pipeline/src/tests_window.rs b/crates/pipeline/src/tests_window.rs index 7e4aff13e..b82a19f45 100644 --- a/crates/pipeline/src/tests_window.rs +++ b/crates/pipeline/src/tests_window.rs @@ -238,6 +238,38 @@ fn a_mask_overlay_window_matches_the_whole_render() { } } +// Given the Show All Masks view (issue #1712), a window of it matches the same pixels of the +// whole render with that overlay: the union is composed from the same mask alphas, so the zoomed +// loupe can show it as a window like a single mask's overlay (issue #547) +#[test] +fn an_all_masks_overlay_window_matches_the_whole_render() { + use crate::{MaskView, Overlay}; + use lightcraft_develop::{LocalAdjustments, Mask, MaskComponent, MaskOp, MaskShape}; + use lightcraft_geom::Point; + let shapes = [ + (3, MaskShape::Radial { center: Point::new(0.45, 0.5), rx: 0.2, ry: 0.15, angle: 20.0, feather: 50.0, invert: false }, 0.0), + (4, MaskShape::Linear { start: Point::new(0.4, 0.55), end: Point::new(0.6, 0.5) }, 100.0), + ]; + let mut s = DevelopSettings::default(); + for (id, shape, refine) in shapes { + s.masks.push(Mask { + id, + components: vec![MaskComponent { name: None, op: MaskOp::Add, invert: false, shape }], + adjust: LocalAdjustments { exposure: 1.0, ..Default::default() }, + refine, + ..Default::default() + }); + } + for view in [MaskView::Color, MaskView::WhiteOnBlack, MaskView::ImageOnBlack] { + let overlay = Overlay::AllMasks { view, color: [255, 0, 0], opacity: 70 }; + let f = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, ..RenderRequest::fit(W, H) }).image; + let w = render(&noisy_scene(), &SourceInfo::default(), &s, &RenderRequest { overlay, window: Some(MID), ..RenderRequest::fit(W, H) }).image; + let (max, mean) = compare(&f, &w, MID); + // (refine edges differs by a rounding step, as in `mask_edge_refinement_does_not_depend_on_the_window`) + assert!(max <= 3 && mean <= 0.25, "{view:?}: max {max} (≤ 3), mean {mean:.3} (≤ 0.25)"); + } +} + // ---- Deep zoom: the wide stages' kernels are far wider than any margin ------------------------- /// Hard-edged blocks of very different brightness at several scales: the wide stages' edge-aware diff --git a/crates/preview/src/disk.rs b/crates/preview/src/disk.rs index e8f4e80bf..7510ffa38 100644 --- a/crates/preview/src/disk.rs +++ b/crates/preview/src/disk.rs @@ -1,15 +1,35 @@ -//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.jpg`, bounded in bytes. +//! Rendered thumbnails on disk: `<dir>/<2 hex>/<32 hex>.lcthumb`, bounded in bytes. //! //! The cache is disposable: files are written via temp + rename (readers never see partial //! files) but not fsynced; unreadable files are deleted and re-rendered. When the total size -//! exceeds the budget, the least recently used files (by modification time, refreshed on read) -//! are removed down to 80 % of the budget. +//! exceeds the budget, the least recently used files (by modification time) are removed down to +//! 80 % of the budget. //! -//! The cache only ever lists, counts or deletes files of its own name shape -//! (`<2 hex>/<32 hex>.jpg` and the `<32 hex>.tmp…` temp files of [`DiskCache::put`], see -//! [`is_cache_file`]): the directory may be shared with other files (e.g. a library opened on a -//! photo folder that already has a `thumbs/` folder), and those are never touched (issue #98). -//! A shard directory that is a symlink or junction is not followed (issue #1504). +//! **The cache is nobody's pictures** (issues #610, #1066). A library is often in the person's +//! Pictures folder, which photo galleries and backups look through, so: +//! - its files are JPEG data under a name that is no picture's ([`EXT`]). They used to be +//! `.jpg`, and every thumbnail (one per photo, size and state of its edits) showed up in the +//! system's gallery as a photo of the person's. Files from then are taken over under the new +//! name: one that is asked for at once, the rest when the cache is tidied +//! ([`DiskCache::take_over_old`], which the engine does once per folder in the background). +//! Nothing is rendered again for it. +//! - reading writes nothing. A file's date was brought forward on every read, so every launch +//! made every thumbnail in view look new; now only one that was not used for [`STALE`] is +//! touched. What was read in this session is remembered in memory, and a full cache lets it +//! go last. +//! - a folder that holds nothing but the cache is tagged as one ([`TAG`]): when the cache +//! makes it, and when it is tidied. +//! +//! An older build that is run again writes `.jpg` files once more; in a folder that was tidied +//! already those are taken over one by one as they are asked for, and the rest go as the cache +//! fills. +//! +//! The cache only ever lists, counts, renames or deletes files of its own name shape +//! (`<2 hex>/<32 hex>.lcthumb`, the `.jpg` of before, and the `<32 hex>.tmp…` temp files of +//! [`DiskCache::put`], see [`is_cache_file`]): the directory may be shared with other files +//! (e.g. a library opened on a photo folder that already has a `thumbs/` folder), and those are +//! never touched (issue #98). A shard directory that is a symlink or junction is not followed +//! (issue #1504). use std::path::{Path, PathBuf}; use std::sync::Mutex; @@ -21,6 +41,32 @@ use crate::Hash128; const QUALITY: u8 = 90; +/// What a cached thumbnail's file is named after its key: not a picture's ending, so that +/// nothing that looks for the person's photos takes the cache for them. +pub const EXT: &str = "lcthumb"; +/// What it was named before (JPEG data then as now). +const OLD_EXT: &str = "jpg"; + +/// How long a thumbnail goes unused before a read brings its date forward (the date is what a +/// full cache lets go by, oldest first). Reads within it write nothing. +pub const STALE: std::time::Duration = std::time::Duration::from_secs(7 * 24 * 3600); + +/// The file that says the cache has looked through its folder for thumbnails under the name +/// of before and taken them over ([`DiskCache::take_over_old`]): written after a whole pass, +/// whatever else the folder holds, so that it is done once. +const TIDIED: &str = ".lightcraft-thumbs"; + +/// Files an operating system leaves in a folder that was looked at: nobody's, and no reason +/// to take a folder for more than a cache. +const DROPPINGS: [&str; 3] = [".DS_Store", "desktop.ini", "Thumbs.db"]; + +/// The file that says a folder is a cache, for backup programs and the like (the Cache +/// Directory Tagging Specification: the first line is the signature). +pub const TAG: &str = "CACHEDIR.TAG"; +const TAG_TEXT: &str = "Signature: 8a477f597d28d172789f06886806bc55\n\ +# This folder holds LightCraft's thumbnail cache. It is safe to delete: LightCraft renders what it needs again.\n\ +# See https://bford.info/cachedir/\n"; + pub struct DiskCache { dir: PathBuf, /// Bytes the cache may hold (changed in place by [`DiskCache::set_budget`]). @@ -30,6 +76,8 @@ pub struct DiskCache { pub hits: AtomicU64, pub misses: AtomicU64, pub writes: AtomicU64, + /// The thumbnails read in this session: a full cache lets them go last ([`Self::prune`]). + read: Mutex<std::collections::HashSet<u128>>, } impl DiskCache { @@ -41,6 +89,7 @@ impl DiskCache { hits: AtomicU64::new(0), misses: AtomicU64::new(0), writes: AtomicU64::new(0), + read: Mutex::new(Default::default()), } } @@ -60,22 +109,57 @@ impl DiskCache { } fn path(&self, key: Hash128) -> PathBuf { + self.named(key, EXT) + } + + fn named(&self, key: Hash128, ext: &str) -> PathBuf { let hex = key.to_string(); - self.dir.join(&hex[..2]).join(format!("{hex}.jpg")) + // (32 hex digits: the first two are there) + self.dir.join(hex.get(..2).unwrap_or("00")).join(format!("{hex}.{ext}")) + } + + /// The file's bytes: under its name, or under the name it had before ([`OLD_EXT`]), which + /// it then no longer has. + fn read(&self, key: Hash128) -> Option<(PathBuf, Vec<u8>)> { + let p = self.path(key); + // a shard that is a symlink (or junction) leads outside the cache: nothing is read + // through it, under either name + if !p.parent().is_some_and(is_real_dir) { + return None; + } + if let Ok(bytes) = std::fs::read(&p) { + return Some((p, bytes)); + } + let old = self.named(key, OLD_EXT); + let Ok(bytes) = std::fs::read(&old) else { + // (taken over by the tidying between the two looks: it is under the new name now) + return std::fs::read(&p).ok().map(|bytes| (p, bytes)); + }; + // (taken over; if that can't be done it is read where it is, and tried again next time) + match std::fs::rename(&old, &p) { + Ok(()) => Some((p, bytes)), + Err(_) => Some((old, bytes)), + } } pub fn get(&self, key: Hash128) -> Option<Rgba8> { - let p = self.path(key); - let Some(bytes) = p.parent().filter(|d| is_real_dir(d)).and_then(|_| std::fs::read(&p).ok()) else { + let Some((p, bytes)) = self.read(key) else { self.misses.fetch_add(1, Ordering::Relaxed); return None; }; match decode_jpeg(&bytes) { Some(img) => { self.hits.fetch_add(1, Ordering::Relaxed); - // refresh recency for pruning - if let Ok(f) = std::fs::File::options().write(true).open(&p) { - let _ = f.set_modified(std::time::SystemTime::now()); + // Recency for pruning, without a write for every read: it is remembered here for + // this session, and only a file that was not used for a long while has its date + // brought forward (for the sessions after). + self.read.lock().unwrap_or_else(|e| e.into_inner()).insert(key.0); + let now = std::time::SystemTime::now(); + let unused = std::fs::metadata(&p).and_then(|m| m.modified()).ok().and_then(|at| now.duration_since(at).ok()); + if unused.is_some_and(|d| d > STALE) + && let Ok(f) = std::fs::File::options().write(true).open(&p) + { + let _ = f.set_modified(now); } Some(img) } @@ -92,9 +176,15 @@ impl DiskCache { let Some(bytes) = encode_jpeg(img) else { return }; let p = self.path(key); let Some(parent) = p.parent() else { return }; + // a folder the cache makes for itself says that it is a cache; one that was there + // already may hold somebody's files, and is not declared one + let made = !self.dir.exists(); if std::fs::create_dir_all(parent).is_err() || !is_real_dir(parent) { return; } + if made { + let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT); + } // Serialize the file replacement and its size accounting. A repeated render of // the same key replaces its JPEG; it must not count as another cache entry. let mut total = self.total.lock().unwrap_or_else(|e| e.into_inner()); @@ -117,9 +207,17 @@ impl DiskCache { /// All cache files (only names of the cache's own shape, see [`is_cache_file`]): /// (path, size, modified). fn scan(&self) -> Vec<(PathBuf, u64, std::time::SystemTime)> { + self.scan_until(&|| false).unwrap_or_default() + } + + /// [`Self::scan`], given up (`None`) when `stop` says so, which is asked for each shard. + fn scan_until(&self, stop: &dyn Fn() -> bool) -> Option<Vec<(PathBuf, u64, std::time::SystemTime)>> { let mut out = Vec::new(); - let Ok(rd) = std::fs::read_dir(&self.dir) else { return out }; + let Ok(rd) = std::fs::read_dir(&self.dir) else { return Some(out) }; for sub in rd.flatten() { + if stop() { + return None; + } let sub_name = sub.file_name(); let Some(sub_name) = sub_name.to_str() else { continue }; if !is_shard_name(sub_name) { @@ -144,21 +242,123 @@ impl DiskCache { } } } - out + Some(out) } - /// Remove least recently used files until the cache is at most 80 % of its budget. + /// Whether the folder may hold thumbnails under the name they had before ([`OLD_EXT`]): it + /// is there, and neither made by a cache that names them as now (which tags it, [`TAG`]) + /// nor looked through by one ([`TIDIED`]). Cheap: asked when a library opens. + pub fn needs_tidying(&self) -> bool { + self.dir.is_dir() && !self.dir.join(TAG).exists() && !self.dir.join(TIDIED).exists() + } + + /// Take over the thumbnails cached under the name of before: each `<32 hex>.jpg` of the + /// cache's own shape is renamed (or removed, when the thumbnail is there under the new name + /// too). After a whole pass the folder is marked as looked through, so that this is done + /// once, and tagged as a cache if it holds nothing but the cache. Returns how many were + /// renamed. `stop` is asked for each shard listed and each file renamed; `progress` is told + /// (done, of) as the old files are gone through. + /// + /// This walks the whole folder and renames file by file (a cache of 2 GB is some 100 000 + /// files): it is for a background thread, never for the interface. + pub fn take_over_old(&self, stop: &dyn Fn() -> bool, progress: &dyn Fn(usize, usize)) -> usize { + // (listed in full before anything is renamed: a folder that changes while it is listed + // lists some of its files twice) + let Some(listed) = self.scan_until(stop) else { return 0 }; + let old: Vec<(PathBuf, u64)> = listed.into_iter().filter(|f| f.0.extension().is_some_and(|ext| ext == OLD_EXT)).map(|f| (f.0, f.1)).collect(); + let (mut taken, mut removed, mut blocked) = (0usize, 0u64, false); + for (done, (path, len)) in old.iter().enumerate() { + if stop() { + self.forget(removed); + return taken; + } + progress(done, old.len()); + let new = path.with_extension(EXT); + match std::fs::symlink_metadata(&new) { + // there under both names: one and the same thumbnail + Ok(m) if m.is_file() => { + if std::fs::remove_file(path).is_ok() { + removed = removed.saturating_add(*len); + } + } + // something that is no file has the name: not the cache's to move aside + Ok(_) => blocked = true, + Err(_) => match std::fs::rename(path, &new) { + Ok(()) => taken += 1, + Err(_) => blocked = true, + }, + } + } + progress(old.len(), old.len()); + self.forget(removed); + // done, also when the folder holds other things; not when something could not be taken + // over (a folder that can't be written: it is tried again at the next open) + if !blocked { + let _ = std::fs::write(self.dir.join(TIDIED), b""); + if self.holds_only_the_cache() { + let _ = std::fs::write(self.dir.join(TAG), TAG_TEXT); + } + } + taken + } + + /// Take `bytes` that are no longer on disk off the total, when it is known. + fn forget(&self, bytes: u64) { + if let Some(total) = self.total.lock().unwrap_or_else(|e| e.into_inner()).as_mut() { + *total = total.saturating_sub(bytes); + } + } + + /// Whether everything in the folder is the cache's: its own marks, and shard folders that + /// hold only its own files under the name they have now. What an operating system leaves in + /// a folder that was looked at ([`DROPPINGS`]) does not count against it. (A folder with + /// anything else in it may be somebody's, issue #98.) + fn holds_only_the_cache(&self) -> bool { + let dropping = |name: &str| DROPPINGS.contains(&name) || name.starts_with("._"); + let Ok(entries) = std::fs::read_dir(&self.dir) else { return false }; + entries.flatten().all(|e| { + let name = e.file_name(); + let Some(name) = name.to_str() else { return false }; + if name == TAG || name == TIDIED || dropping(name) { + return true; + } + // (a shard that is a symlink or junction leads somewhere else: not only the cache's) + if !is_shard_name(name) || !is_real_dir(&e.path()) { + return false; + } + let Ok(inside) = std::fs::read_dir(e.path()) else { return false }; + inside.flatten().all(|f| { + // a link among the files is not a file the cache wrote + let link = f.file_type().is_ok_and(|t| t.is_symlink()); + !link && f.file_name().to_str().is_some_and(|f| dropping(f) || (is_cache_file(name, f) && !f.ends_with(&format!(".{OLD_EXT}")))) + }) + }) + } + + /// Remove least recently used files until the cache is at most 80 % of its budget: by the + /// date of each file, the ones read in this session last. pub fn prune(&self) { let mut files = self.scan(); let mut total: u64 = files.iter().map(|f| f.1).sum(); let target = self.budget() / 5 * 4; - files.sort_by_key(|f| f.2); + let read = self.read.lock().unwrap_or_else(|e| e.into_inner()).clone(); + let in_use = |p: &Path| key_of(p).is_some_and(|k| read.contains(&k)); + files.sort_by_cached_key(|f| (in_use(&f.0), f.2)); + let mut gone = Vec::new(); for (p, len, _) in files { if total <= target { break; } if std::fs::remove_file(&p).is_ok() { - total -= len; + total = total.saturating_sub(len); + gone.extend(key_of(&p)); + } + } + // (what is gone is no longer "read this session": the set holds only what is there) + if !gone.is_empty() { + let mut read = self.read.lock().unwrap_or_else(|e| e.into_inner()); + for key in gone { + read.remove(&key); } } *self.total.lock().unwrap_or_else(|e| e.into_inner()) = Some(total); @@ -183,6 +383,11 @@ fn is_real_dir(p: &Path) -> bool { std::fs::symlink_metadata(p).is_ok_and(|m| m.is_dir()) } +/// The key a cache file is of, by its name. +fn key_of(path: &Path) -> Option<u128> { + path.file_stem().and_then(|s| s.to_str()).and_then(|hex| u128::from_str_radix(hex, 16).ok()) +} + fn is_lower_hex(s: &str) -> bool { s.bytes().all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)) } @@ -192,9 +397,10 @@ fn is_shard_name(s: &str) -> bool { s.len() == 2 && is_lower_hex(s) } -/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.jpg` -/// (whose first two digits are the shard) or a `<32 hex>.tmp<alphanumeric>` temp file of -/// [`DiskCache::put`]. Anything else in the cache directory belongs to someone else. +/// Whether `name` inside shard directory `shard` is a file this cache wrote: `<32 hex>.lcthumb` +/// (whose first two digits are the shard), the `<32 hex>.jpg` it wrote before, or a +/// `<32 hex>.tmp<alphanumeric>` temp file of [`DiskCache::put`]. Anything else in the cache +/// directory belongs to someone else. pub fn is_cache_file(shard: &str, name: &str) -> bool { if !is_shard_name(shard) { return false; @@ -203,7 +409,7 @@ pub fn is_cache_file(shard: &str, name: &str) -> bool { if stem.len() != 32 || !is_lower_hex(stem) || !stem.starts_with(shard) { return false; } - ext == "jpg" || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric())) + ext == EXT || ext == OLD_EXT || ext.strip_prefix("tmp").is_some_and(|t| !t.is_empty() && t.bytes().all(|b| b.is_ascii_alphanumeric())) } /// Encode a thumbnail as the cache stores it (JPEG, RGB, 4:2:0). diff --git a/crates/preview/src/lib.rs b/crates/preview/src/lib.rs index a571ecda0..14bd50a1b 100644 --- a/crates/preview/src/lib.rs +++ b/crates/preview/src/lib.rs @@ -18,6 +18,7 @@ pub mod lru; pub mod pool; use std::path::Path; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, RwLock}; pub use disk::{DiskCache, decode_jpeg, encode_jpeg}; @@ -26,23 +27,58 @@ use lightcraft_raster::Rgba8; pub use lru::{Lru, next_tick}; pub use pool::JobPool; +/// Images that deferred disk writes ([`PreviewCache::put_deferred`]) may hold waiting for the writer, in bytes. A +/// write that would go past it is skipped (the image stays in memory; a later render writes it), so renders that +/// arrive faster than the disk takes them can't pile up threads and memory (issue #1522). +#[cfg(not(target_arch = "wasm32"))] +const MAX_QUEUED_BYTES: usize = 128 << 20; + +/// A deferred disk write waiting for the cache's writer thread. +#[cfg(not(target_arch = "wasm32"))] +struct Write { + generation: u64, + key: Hash128, + img: Arc<Rgba8>, + cost: usize, +} + /// Rendered-thumbnail cache: memory LRU over an optional disk cache. pub struct PreviewCache { /// Current generation; `None` once [`Self::retire`]d (nothing is read or written any more). generation: RwLock<Option<u64>>, mem: Mutex<Lru<Hash128, Arc<Rgba8>>>, disk: Option<DiskCache>, + /// The writer thread's queue (started with the first deferred write), and the bytes waiting in it. + #[cfg(not(target_arch = "wasm32"))] + writer: Mutex<Option<std::sync::mpsc::Sender<Write>>>, + queued: Arc<AtomicUsize>, } impl PreviewCache { /// Memory-only cache of at most `mem_bytes`. pub fn memory(mem_bytes: usize) -> PreviewCache { - PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: None } + Self::new(mem_bytes, None) + } + + fn new(mem_bytes: usize, disk: Option<DiskCache>) -> PreviewCache { + PreviewCache { + generation: RwLock::new(Some(0)), + mem: Mutex::new(Lru::new(mem_bytes)), + disk, + #[cfg(not(target_arch = "wasm32"))] + writer: Mutex::new(None), + queued: Arc::new(AtomicUsize::new(0)), + } + } + + /// Bytes of images waiting for a deferred disk write. + pub fn queued_bytes(&self) -> usize { + self.queued.load(Ordering::Relaxed) } /// Memory LRU + disk cache in `dir` (at most `disk_bytes`). pub fn with_disk(mem_bytes: usize, dir: &Path, disk_bytes: u64) -> PreviewCache { - PreviewCache { generation: RwLock::new(Some(0)), mem: Mutex::new(Lru::new(mem_bytes)), disk: Some(DiskCache::new(dir, disk_bytes)) } + Self::new(mem_bytes, Some(DiskCache::new(dir, disk_bytes))) } pub fn disk(&self) -> Option<&DiskCache> { @@ -102,25 +138,56 @@ impl PreviewCache { let c = cost(&img); self.mem.lock().unwrap_or_else(|e| e.into_inner()).insert(key, img.clone(), c); drop(current); - if self.disk.is_none() { - return; + let Some(d) = &self.disk else { return }; + #[cfg(target_arch = "wasm32")] + { + d.put(key, &img); } - let me = self.clone(); - let write = move || { - let current = me.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner); - if *current != Some(generation) { + #[cfg(not(target_arch = "wasm32"))] + { + let _ = d; + if self.queued.load(Ordering::Relaxed).saturating_add(c) > MAX_QUEUED_BYTES { + log::debug!("preview cache: disk writes are behind; this one is skipped (the image stays in memory)"); return; } - if let Some(d) = &me.disk { - d.put(key, &img); + let mut writer = self.writer.lock().unwrap_or_else(std::sync::PoisonError::into_inner); + if writer.is_none() { + *writer = self.start_writer(); + } + let Some(tx) = writer.as_ref() else { return }; + self.queued.fetch_add(c, Ordering::Relaxed); + if tx.send(Write { generation, key, img, cost: c }).is_err() { + self.queued.fetch_sub(c, Ordering::Relaxed); + *writer = None; } - }; - #[cfg(not(target_arch = "wasm32"))] - if std::thread::Builder::new().name("lc-preview-write".into()).spawn(write).is_err() { - log::warn!("preview cache: could not start a writer thread"); } - #[cfg(target_arch = "wasm32")] - write(); + } + + /// The thread that makes the deferred disk writes, one after another. It holds the cache weakly, so it ends + /// once the cache is dropped (its queue's sender with it). + #[cfg(not(target_arch = "wasm32"))] + fn start_writer(self: &Arc<Self>) -> Option<std::sync::mpsc::Sender<Write>> { + let (tx, rx) = std::sync::mpsc::channel::<Write>(); + let cache = Arc::downgrade(self); + let queued = self.queued.clone(); + let spawned = std::thread::Builder::new().name("lc-preview-write".into()).spawn(move || { + for job in rx { + if let Some(me) = cache.upgrade() { + let current = me.generation.read().unwrap_or_else(std::sync::PoisonError::into_inner); + if *current == Some(job.generation) + && let Some(d) = &me.disk + { + d.put(job.key, &job.img); + } + } + queued.fetch_sub(job.cost, Ordering::Relaxed); + } + }); + if spawned.is_err() { + log::warn!("preview cache: could not start the writer thread"); + return None; + } + Some(tx) } /// Drop everything (memory and disk). Does nothing once [`Self::retire`]d. diff --git a/crates/preview/src/tests.rs b/crates/preview/src/tests.rs index 6c93e51af..2f07e927f 100644 --- a/crates/preview/src/tests.rs +++ b/crates/preview/src/tests.rs @@ -114,7 +114,7 @@ fn disk_cache_roundtrip_and_prune() { let k2 = hash_bytes(b"corrupt"); d.put(k2, &img); let hex = k2.to_string(); - let p = dir.join(&hex[..2]).join(format!("{hex}.jpg")); + let p = dir.join(&hex[..2]).join(format!("{hex}.lcthumb")); std::fs::write(&p, b"not a jpeg").unwrap(); assert!(d.get(k2).is_none()); assert!(!p.exists()); @@ -143,7 +143,7 @@ fn disk_cache_overwrite_accounts_for_replaced_jpeg_only_once() { cache.put(a, &gradient(120, 90, 9)); let path = |key: Hash128| { let hex = key.to_string(); - dir.join(&hex[..2]).join(format!("{hex}.jpg")) + dir.join(&hex[..2]).join(format!("{hex}.lcthumb")) }; let actual = std::fs::metadata(path(a)).unwrap().len() + std::fs::metadata(path(b)).unwrap().len(); assert_eq!(cache.size(), actual); @@ -375,8 +375,57 @@ fn disk_cache_never_touches_foreign_files() { for p in &foreign { assert!(p.exists(), "foreign file deleted: {}", p.display()); } + assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb")); + // (what the cache wrote before it stopped naming its files as pictures is still its own) assert!(disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg")); assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.jpg.bak")); + assert!(!disk::is_cache_file("01", "0123456789abcdef0123456789abcdef.lcthumb.bak")); + let _ = std::fs::remove_dir_all(&dir); +} + +// ---- the cache is nobody's pictures (issues #610, #1066) + +/// Every file under `dir`, by its path from there. +fn files_under(dir: &std::path::Path) -> Vec<String> { + let mut out = Vec::new(); + let mut todo = vec![dir.to_path_buf()]; + while let Some(d) = todo.pop() { + for e in std::fs::read_dir(&d).into_iter().flatten().flatten() { + if e.path().is_dir() { + todo.push(e.path()); + } else { + out.push(e.path().strip_prefix(dir).unwrap().to_string_lossy().replace('\\', "/")); + } + } + } + out.sort(); + out +} + +/// The endings by which a photo gallery, a backup of "my pictures" or a media scanner takes a +/// file for a picture. +const PICTURE_ENDINGS: [&str; 12] = ["jpg", "jpeg", "jpe", "jfif", "png", "gif", "bmp", "tif", "tiff", "webp", "heic", "avif"]; + +fn is_named_as_a_picture(name: &str) -> bool { + name.rsplit_once('.').is_some_and(|(_, ending)| PICTURE_ENDINGS.contains(&ending.to_ascii_lowercase().as_str())) +} + +/// Given a library in the Pictures folder, when thumbnails are cached, then no file the cache +/// writes is named as a picture: a photo gallery that looks through Pictures does not list the +/// cache as the person's photos, however many thumbnails there are. +#[test] +fn nothing_the_cache_writes_is_named_as_a_picture() { + let dir = temp_dir("not-pictures"); + let d = DiskCache::new(&dir, 10 << 20); + for i in 0..12u8 { + d.put(hash_bytes(&[i, 9]), &gradient(160, 100, i)); + } + let written = files_under(&dir); + assert!(written.len() >= 12, "{written:?}"); + let pictures: Vec<&String> = written.iter().filter(|f| is_named_as_a_picture(f)).collect(); + assert!(pictures.is_empty(), "named as pictures: {pictures:?}"); + // and they are found again under those names + assert!((0..12u8).all(|i| d.get(hash_bytes(&[i, 9])).is_some())); let _ = std::fs::remove_dir_all(&dir); } @@ -407,6 +456,231 @@ fn disk_cache_ignores_symlinked_shard_directories() { let _ = std::fs::remove_dir_all(&outside); } +/// A folder whose shard is a symlink to somewhere else is not only the cache's: tidying it +/// neither follows the link nor tags the folder as a cache (issue #1504). +#[cfg(unix)] +#[test] +fn tidying_does_not_declare_a_folder_with_a_symlinked_shard_a_cache() { + let dir = temp_dir("symlink-tidy"); + let outside = temp_dir("symlink-tidy-outside"); + std::fs::create_dir_all(&dir).unwrap(); + std::fs::create_dir_all(&outside).unwrap(); + let hex = hash_bytes(&[9, 8]).to_string(); + // (named as a thumbnail is now: only the link says it is not the cache's) + let sentinel = outside.join(format!("{hex}.lcthumb")); + std::fs::write(&sentinel, b"somebody's file").unwrap(); + std::os::unix::fs::symlink(&outside, dir.join(&hex[..2])).unwrap(); + let d = DiskCache::new(&dir, 100 << 20); + assert!(d.needs_tidying()); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0, "nothing behind the link is taken over"); + assert!(sentinel.is_file(), "the file behind the link is where it was"); + assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with a link in it is not declared a cache"); + let _ = std::fs::remove_dir_all(&dir); + let _ = std::fs::remove_dir_all(&outside); +} + +/// The bytes of every file under `dir` that is the cache's own. +fn cache_bytes_on_disk(dir: &std::path::Path) -> u64 { + files_under(dir) + .iter() + .filter(|f| f.split_once('/').is_some_and(|(shard, name)| disk::is_cache_file(shard, name))) + .map(|f| std::fs::metadata(dir.join(f)).unwrap().len()) + .sum() +} + +/// Given a cache from before, whose thumbnails are `.jpg` files, when one is asked for, then it +/// is found (nothing is rendered again for the change of name) and is no longer a `.jpg`. When +/// the cache is tidied, none of its old files is a `.jpg` any more, each thumbnail is there +/// once and counted once, and the folder (which holds nothing but the cache) is tagged. Asking +/// how big the cache is renames nothing: that is asked from the interface. +#[test] +fn thumbnails_cached_as_jpg_files_are_taken_over_under_the_new_name() { + let dir = temp_dir("taken-over"); + let old_path = |k: Hash128| { + let hex = k.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.jpg")) + }; + // (thousands in one folder: renaming while a folder is listed lists some of them twice) + let keys: Vec<Hash128> = (0..3000u128).map(|i| Hash128((0xab << 120) | i)).collect(); + let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap(); + for k in &keys { + std::fs::create_dir_all(old_path(*k).parent().unwrap()).unwrap(); + std::fs::write(old_path(*k), &jpeg).unwrap(); + } + // one that is there under both names (an older build ran after a newer one) + std::fs::write(old_path(keys[1]).with_extension("lcthumb"), &jpeg).unwrap(); + + let d = DiskCache::new(&dir, 100 << 20); + let back = d.get(keys[0]).expect("found under its old name"); + assert_eq!((back.width, back.height), (32, 20)); + assert!(!old_path(keys[0]).exists(), "and no longer a .jpg"); + assert_eq!(d.writes.load(std::sync::atomic::Ordering::Relaxed), 0, "nothing was rendered or written anew"); + let before = d.size(); + assert_eq!(before, cache_bytes_on_disk(&dir)); + assert!(old_path(keys[2]).exists(), "asking for the size renames nothing"); + + let seen = std::sync::atomic::AtomicUsize::new(0); + let taken = d.take_over_old(&|| false, &|done, of| { + assert_eq!(of, 2999, "the old files, less the one already asked for"); + seen.store(done, std::sync::atomic::Ordering::Relaxed); + }); + assert_eq!(taken, 2998, "all but the one already asked for and the one that was there twice"); + assert_eq!(seen.load(std::sync::atomic::Ordering::Relaxed), 2999, "and its progress came to the end"); + let left: Vec<String> = files_under(&dir).into_iter().filter(|f| is_named_as_a_picture(f)).collect(); + assert_eq!(left, Vec::<String>::new(), "nothing of the cache's is named as a picture"); + assert_eq!(d.size(), cache_bytes_on_disk(&dir), "each thumbnail counted once"); + assert_eq!(d.size(), jpeg.len() as u64 * 3000); + assert!(keys.iter().all(|k| d.get(*k).is_some())); + assert!(dir.join("CACHEDIR.TAG").is_file(), "a folder that holds nothing but the cache is tagged"); + // tidied once: there is nothing more to do, and it says so + assert!(!d.needs_tidying()); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 0); + // a thumbnail written while the size was not known is counted with the rest, not alone + let fresh = DiskCache::new(&dir, 100 << 20); + fresh.put(hash_bytes(b"one more"), &gradient(32, 20, 9)); + assert_eq!(fresh.size(), cache_bytes_on_disk(&dir)); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Given a `thumbs` folder that also holds somebody else's files (a library opened on a photo +/// folder, issue #98), when the cache is tidied, then their files are as they were, whatever +/// they are named, and the folder is not declared a cache. A thing that is no file under a +/// thumbnail's new name keeps the old one from being touched. And tidying can be stopped. +#[test] +fn tidying_leaves_what_is_somebodys_and_can_be_stopped() { + let dir = temp_dir("tidy-shared"); + let jpeg = disk::encode_jpeg(&gradient(32, 20, 3)).unwrap(); + let key = hash_bytes(b"ours"); + let hex = key.to_string(); + let ours = dir.join(&hex[..2]).join(format!("{hex}.jpg")); + std::fs::create_dir_all(ours.parent().unwrap()).unwrap(); + std::fs::write(&ours, &jpeg).unwrap(); + let theirs = [dir.join("holiday.jpg"), dir.join(&hex[..2]).join("holiday.jpg"), dir.join("album").join("one.jpg")]; + for p in &theirs { + std::fs::create_dir_all(p.parent().unwrap()).unwrap(); + std::fs::write(p, b"somebody's picture").unwrap(); + } + // something that is no file has another thumbnail's new name + let blocked = hash_bytes(b"blocked").to_string(); + let blocked_old = dir.join(&blocked[..2]).join(format!("{blocked}.jpg")); + std::fs::create_dir_all(blocked_old.with_extension("lcthumb")).unwrap(); + std::fs::write(&blocked_old, &jpeg).unwrap(); + + let d = DiskCache::new(&dir, 100 << 20); + assert!(d.needs_tidying()); + assert_eq!(d.take_over_old(&|| true, &|_, _| {}), 0, "stopped before anything"); + assert!(ours.exists() && d.needs_tidying(), "and to be gone on with"); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(!ours.exists() && ours.with_extension("lcthumb").is_file()); + assert!(blocked_old.is_file(), "left where it is"); + for p in &theirs { + assert_eq!(std::fs::read(p).unwrap(), b"somebody's picture", "{}", p.display()); + } + assert!(!dir.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache"); + assert!(d.needs_tidying(), "one could not be taken over: it is tried again"); + // with that out of the way it is done, once, though the folder is still shared + std::fs::remove_dir(blocked_old.with_extension("lcthumb")).unwrap(); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(!d.needs_tidying() && !dir.join("CACHEDIR.TAG").exists()); + + // what a file manager leaves behind in a folder it showed does not make it somebody's + let own = temp_dir("tidy-droppings"); + let k = hash_bytes(b"k").to_string(); + std::fs::create_dir_all(own.join(&k[..2])).unwrap(); + std::fs::write(own.join(&k[..2]).join(format!("{k}.jpg")), &jpeg).unwrap(); + for left in [own.join(".DS_Store"), own.join("desktop.ini"), own.join(&k[..2]).join("Thumbs.db"), own.join("._thumbs")] { + std::fs::write(left, b"x").unwrap(); + } + let d = DiskCache::new(&own, 100 << 20); + assert_eq!(d.take_over_old(&|| false, &|_, _| {}), 1); + assert!(own.join("CACHEDIR.TAG").is_file() && !d.needs_tidying()); + let _ = std::fs::remove_dir_all(&own); + let _ = std::fs::remove_dir_all(&dir); +} + +/// Given a full cache, when it has to let thumbnails go, then the ones read in this session +/// stay over ones that were written later and never looked at: what is in use is not the first +/// to go, though reading it wrote nothing. +#[test] +fn a_full_cache_keeps_what_was_read_this_session() { + let dir = temp_dir("read-kept"); + let d = DiskCache::new(&dir, 60_000); + let keys: Vec<Hash128> = (0..8u8).map(|i| hash_bytes(&[i, 7])).collect(); + for (i, k) in keys.iter().enumerate() { + d.put(*k, &gradient(160, 100, i as u8)); + } + // the first two written are the ones in use + assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some()); + for i in 0..40u8 { + d.put(hash_bytes(&[i, 8]), &gradient(160, 100, i)); + } + assert!(d.size() <= 60_000, "{}", d.size()); + assert!(d.get(keys[0]).is_some() && d.get(keys[1]).is_some(), "what was read stayed"); + assert!(d.get(keys[5]).is_none(), "what was written after it and never read went"); + let _ = std::fs::remove_dir_all(&dir); +} + +fn modified(p: &std::path::Path) -> std::time::SystemTime { + std::fs::metadata(p).unwrap().modified().unwrap() +} + +/// Given cached thumbnails, when they are read (as at every launch, for every photo in view), +/// then nothing is written: a file read today is as it was, date and all. Only one that was not +/// used for a long while has its date brought forward, so that what is still in use is not the +/// first to go when the cache is full. +#[test] +fn reading_a_thumbnail_writes_nothing() { + let dir = temp_dir("read-only"); + let d = DiskCache::new(&dir, 10 << 20); + let (fresh, stale) = (hash_bytes(b"fresh"), hash_bytes(b"stale")); + let path = |k: Hash128| { + let hex = k.to_string(); + dir.join(&hex[..2]).join(format!("{hex}.lcthumb")) + }; + d.put(fresh, &gradient(160, 100, 1)); + d.put(stale, &gradient(160, 100, 2)); + let day = std::time::Duration::from_secs(24 * 3600); + let now = std::time::SystemTime::now(); + let set = |k: Hash128, at: std::time::SystemTime| std::fs::File::options().write(true).open(path(k)).unwrap().set_modified(at).unwrap(); + set(fresh, now - day); + set(stale, now - day * 60); + let before = (modified(&path(fresh)), std::fs::read(path(fresh)).unwrap()); + for _ in 0..3 { + assert!(d.get(fresh).is_some()); + } + assert_eq!((modified(&path(fresh)), std::fs::read(path(fresh)).unwrap()), before, "read, and untouched"); + assert!(d.get(stale).is_some()); + assert!(modified(&path(stale)) > now - day, "one not used for two months is in use again"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// A folder the cache makes for itself says that it is a cache (`CACHEDIR.TAG`, which backup +/// programs go by), and the tag is not one of its thumbnails: clearing the cache leaves it. A +/// folder that was there before (it may hold somebody's files, issue #98) is not tagged. +#[test] +fn a_folder_the_cache_makes_is_tagged_as_a_cache_and_one_that_was_there_is_not() { + let (made, theirs) = (temp_dir("tagged"), temp_dir("untagged")); + let own = made.join("thumbs"); + let d = DiskCache::new(&own, 10 << 20); + assert!(!own.exists(), "nothing is made before there is something to keep"); + d.put(hash_bytes(b"a"), &gradient(160, 100, 1)); + let tag = std::fs::read_to_string(own.join("CACHEDIR.TAG")).expect("tagged"); + assert!(tag.starts_with("Signature: 8a477f597d28d172789f06886806bc55"), "{tag}"); + d.clear(); + assert_eq!(d.size(), 0); + assert!(own.join("CACHEDIR.TAG").is_file()); + + std::fs::create_dir_all(&theirs).unwrap(); + std::fs::write(theirs.join("holiday.jpg"), b"somebody's picture").unwrap(); + let d = DiskCache::new(&theirs, 10 << 20); + d.put(hash_bytes(b"a"), &gradient(160, 100, 1)); + assert!(d.get(hash_bytes(b"a")).is_some()); + assert!(!theirs.join("CACHEDIR.TAG").exists(), "a folder with somebody's files in it is not declared a cache"); + for dir in [made, theirs] { + let _ = std::fs::remove_dir_all(dir); + } +} + /// [`JobPool::wait`] returns once a job finishes, which [`JobPool::try_recv`] then hands out, or /// after the timeout. #[test] @@ -495,3 +769,26 @@ fn a_panicking_job_frees_its_slot() { })); assert_eq!(got, Some(2)); } + +/// Deferred disk writes go through one writer with a byte budget: a burst of big renders can't queue more than the +/// budget (each used to start a thread of its own, holding its image; issue #1522), and what was queued reaches disk. +#[test] +fn deferred_writes_are_bounded_and_drain_to_disk() { + let dir = temp_dir("deferred-budget"); + let cache = Arc::new(PreviewCache::with_disk(1 << 30, &dir, 1 << 31)); + let big = Arc::new(gradient(4096, 2048, 3)); // 32 MiB each + let keys: Vec<Hash128> = (0..24u32).map(|i| hash_bytes(&i.to_le_bytes())).collect(); + for key in &keys { + cache.put_deferred(*key, big.clone()); + assert!(cache.queued_bytes() <= MAX_QUEUED_BYTES, "{} bytes queued", cache.queued_bytes()); + } + let t = std::time::Instant::now(); + while cache.queued_bytes() > 0 && t.elapsed() < std::time::Duration::from_secs(120) { + std::thread::sleep(std::time::Duration::from_millis(20)); + } + assert_eq!(cache.queued_bytes(), 0, "the queue drains"); + assert!(cache.disk().unwrap().get(keys[0]).is_some(), "the first write reached disk"); + // every image is still in memory, written to disk or not + assert!(keys.iter().all(|k| cache.get(*k).is_some())); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/raster/src/blur.rs b/crates/raster/src/blur.rs index 33816dffa..36e3a64cf 100644 --- a/crates/raster/src/blur.rs +++ b/crates/raster/src/blur.rs @@ -26,6 +26,16 @@ pub fn box_radii(sigma: f32) -> [usize; 3] { std::array::from_fn(|i| (((if (i as i32) < m { wl } else { wu }) - 1) / 2).max(0) as usize) } +/// Standard deviation (px) of the blur [`gaussian`] actually makes for `sigma`: its boxes are +/// whole pixels wide, so small radii snap to a few steps (0 below about 0.58 px, then √(2/3) px for one +/// 3-px box per axis, …). +pub fn box_sigma(sigma: f32) -> f32 { + if sigma.is_nan() || sigma <= 0.3 { + return 0.0; + } + box_radii(sigma).iter().map(|&r| (((2 * r + 1) * (2 * r + 1) - 1) as f32) / 12.0).sum::<f32>().sqrt() +} + /// One horizontal box pass over `row` (clamped edges), using `tmp` as scratch. #[inline] fn box_row<T: Pixel>(row: &mut [T], tmp: &mut Vec<T>, r: usize) { @@ -185,6 +195,20 @@ mod tests { assert!((b.get(50, 45) - b.get(50, 55)).abs() < 1e-6); } + #[test] + fn box_sigma_is_the_blur_made() { + assert_eq!(box_sigma(0.3), 0.0); + assert_eq!(box_sigma(0.5), 0.0, "no box wider than 1 px"); + assert!((box_sigma(1.0) - (2.0f32 / 3.0).sqrt()).abs() < 1e-6); + for sigma in [1.0f32, 2.5, 8.0] { + let mut imp = Image::<f32>::new(201, 1); + imp.set(100, 0, 1.0); + let b = gaussian(&imp, sigma); + let var: f32 = b.data.iter().enumerate().map(|(i, v)| v * ((i as f32 - 100.0).powi(2))).sum(); + assert!((var.sqrt() - box_sigma(sigma)).abs() < 1e-3, "{sigma}: {} vs {}", var.sqrt(), box_sigma(sigma)); + } + } + #[test] fn variance_close_to_sigma_squared() { let mut imp = Image::<f32>::new(201, 1); diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 19fe1f7ff..0347b8fcc 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,12 +4,14 @@ //! `Plane = Image<f32>`). //! - Resampling ([`resample`]): separable Lanczos-3 / Mitchell / box, rayon-parallel. //! - Blur ([`blur`]): separable Gaussian via repeated box filters (O(1) per pixel in the radius). +//! - Morphology ([`morph`]): square min / max filters (van Herk / Gil-Werman, O(1) per pixel in the radius). //! - [`Histogram`] of display-encoded RGB + luminance. #![forbid(unsafe_code)] #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] pub mod blur; pub mod histogram; +pub mod morph; pub mod resample; pub use histogram::{HdrBins, Histogram}; @@ -98,6 +100,21 @@ impl<T: Copy + Default> Image<T> { pub fn is_empty(&self) -> bool { self.data.is_empty() } + /// The image mirrored about its main diagonal (`width` and `height` swap; row-parallel). A + /// buffer shorter than its size reads as `T::default()` where it runs out. + pub fn transpose(&self) -> Self + where + T: Send + Sync, + { + let (w, h) = (self.width, self.height); + let mut t = Image::<T>::new(h, w); + par_rows(&mut t.data, h, |x, row| { + for (y, v) in row.iter_mut().enumerate() { + *v = self.data.get(y * w + x).copied().unwrap_or_default(); + } + }); + t + } /// [`Self::map`] into the same buffer (no second image in memory). pub fn map_in_place(&mut self, f: impl Fn(T) -> T + Sync + Send) where @@ -273,6 +290,18 @@ mod tests { assert_eq!(img.sample_bilinear(1.5, 0.5), [1.0, 0.0, 1.0], "an ordinary sample is as before"); } + #[test] + fn transpose_swaps_the_axes() { + let p = Plane::from_fn(5, 3, |x, y| (y * 10 + x) as f32); + let t = p.transpose(); + assert_eq!((t.width, t.height), (3, 5)); + assert!((0..3).all(|y| (0..5).all(|x| t.get(y, x) == p.get(x, y)))); + assert_eq!(t.transpose(), p); + // a buffer shorter than its size reads as zero where it runs out, never a panic + let short = Plane { width: 3, height: 2, data: vec![1.0; 4] }; + assert_eq!(short.transpose().data, [1.0, 1.0, 1.0, 0.0, 1.0, 0.0]); + } + #[test] fn crop_and_rotate() { let img = Image::<u32>::from_fn(4, 3, |x, y| (y * 10 + x) as u32); diff --git a/crates/raster/src/morph.rs b/crates/raster/src/morph.rs new file mode 100644 index 000000000..e428f91c2 --- /dev/null +++ b/crates/raster/src/morph.rs @@ -0,0 +1,187 @@ +//! Grey-scale morphology: square min (erosion) and max (dilation) filters. +//! +//! Separable van Herk / Gil-Werman filters: each 1-D pass splits the (edge-extended) line into +//! blocks of the window's width and keeps a running min from the left and from the right within +//! each block; any window then spans at most two blocks, so its min is the min of one suffix and +//! one prefix value. That is three comparisons per pixel per pass, whatever the radius. +//! +//! Edges are clamped (the edge pixel repeats), which for a min or max is the same as clipping the +//! window to the image. NaNs are skipped like [`f32::min`] does: a window yields NaN only when every +//! value in it is NaN. + +use crate::Plane; + +/// Square min filter (erosion) of radius `r`: each output pixel is the minimum over the +/// `(2r+1)²` window around it, clipped to the image. `r == 0` returns a copy. +pub fn min_filter(p: &Plane, r: usize) -> Plane { + filter(p, r, f32::min) +} + +/// Square max filter (dilation) of radius `r` (see [`min_filter`]). +pub fn max_filter(p: &Plane, r: usize) -> Plane { + filter(p, r, f32::max) +} + +fn filter(p: &Plane, r: usize, pick: fn(f32, f32) -> f32) -> Plane { + let (w, h) = (p.width, p.height); + if r == 0 || p.data.is_empty() || p.data.len() != w.saturating_mul(h) { + return p.clone(); + } + let mut out = p.clone(); + rows(&mut out, r, pick); + let mut t = out.transpose(); + rows(&mut t, r, pick); + t.transpose() +} + +/// [`line`] over every row of `p`, in place (row-parallel, one scratch per worker). +fn rows(p: &mut Plane, r: usize, pick: fn(f32, f32) -> f32) { + let w = p.width; + if w == 0 { + return; + } + #[cfg(feature = "parallel")] + { + use rayon::prelude::*; + p.data.par_chunks_mut(w).for_each_init(Scratch::default, |s, row| line(row, r, pick, s)); + } + #[cfg(not(feature = "parallel"))] + { + let mut s = Scratch::default(); + p.data.chunks_mut(w).for_each(|row| line(row, r, pick, &mut s)); + } +} + +#[derive(Default)] +struct Scratch { + ext: Vec<f32>, + pre: Vec<f32>, + suf: Vec<f32>, +} + +/// One 1-D pass over `row`, in place. +fn line(row: &mut [f32], r: usize, pick: fn(f32, f32) -> f32, s: &mut Scratch) { + let n = row.len(); + if n == 0 { + return; + } + // a radius past the line covers it all; capping keeps the scratch bounded + let r = r.min(n - 1); + if r == 0 { + return; + } + let k = 2 * r + 1; + // the line with `r` copies of each edge pixel on either side + s.ext.clear(); + s.ext.extend(std::iter::repeat_n(row[0], r)); + s.ext.extend_from_slice(row); + s.ext.extend(std::iter::repeat_n(row[n - 1], r)); + let m = s.ext.len(); + s.pre.clear(); + s.pre.resize(m, 0.0); + s.suf.clear(); + s.suf.resize(m, 0.0); + for (i, &v) in s.ext.iter().enumerate() { + s.pre[i] = if i % k == 0 { v } else { pick(s.pre[i - 1], v) }; + } + for i in (0..m).rev() { + let v = s.ext[i]; + s.suf[i] = if i + 1 == m || (i + 1) % k == 0 { v } else { pick(s.suf[i + 1], v) }; + } + // window [x, x + 2r] of `ext` is output x + for (x, o) in row.iter_mut().enumerate() { + *o = pick(s.suf[x], s.pre[x + 2 * r]); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The definition: min (or max) over the clipped window. + fn naive(p: &Plane, r: usize, max: bool) -> Plane { + let (w, h) = (p.width, p.height); + let pick = |a: f32, b: f32| if max { a.max(b) } else { a.min(b) }; + Plane::from_fn(w, h, |x, y| { + let mut m = p.get(x, y); + for yy in y.saturating_sub(r)..=(y + r).min(h - 1) { + for xx in x.saturating_sub(r)..=(x + r).min(w - 1) { + m = pick(m, p.get(xx, yy)); + } + } + m + }) + } + + /// splitmix64-ish deterministic noise. + fn noise(w: usize, h: usize, seed: u64) -> Plane { + Plane::from_fn(w, h, |x, y| { + let mut z = seed.wrapping_add((y * w + x) as u64).wrapping_mul(0x9e37_79b9_7f4a_7c15); + z = (z ^ (z >> 30)).wrapping_mul(0xbf58_476d_1ce4_e5b9); + z ^= z >> 31; + (z % 10_000) as f32 / 10_000.0 + }) + } + + #[test] + fn matches_the_naive_filter() { + for (w, h) in [(1, 1), (1, 9), (9, 1), (7, 5), (31, 17), (64, 40)] { + for r in [1, 2, 3, 5, 8, 20, 100] { + let p = noise(w, h, (w * 131 + h * 7 + r) as u64); + assert_eq!(min_filter(&p, r), naive(&p, r, false), "min {w}x{h} r={r}"); + assert_eq!(max_filter(&p, r), naive(&p, r, true), "max {w}x{h} r={r}"); + } + } + } + + #[test] + fn radius_zero_and_empty_are_copies() { + let p = noise(6, 4, 1); + assert_eq!(min_filter(&p, 0), p); + let e = Plane::new(0, 0); + assert_eq!(min_filter(&e, 3), e); + let e = Plane::new(5, 0); + assert_eq!(max_filter(&e, 3), e); + } + + #[test] + fn radius_past_the_image_is_the_global_extreme() { + let p = noise(13, 7, 3); + let lo = p.data.iter().copied().fold(f32::INFINITY, f32::min); + let hi = p.data.iter().copied().fold(f32::NEG_INFINITY, f32::max); + assert!(min_filter(&p, 1_000_000).data.iter().all(|&v| v == lo)); + assert!(max_filter(&p, usize::MAX).data.iter().all(|&v| v == hi)); + } + + #[test] + fn a_dark_dot_grows_to_the_window() { + let mut p = Plane::filled(11, 11, 1.0); + p.set(5, 5, 0.0); + let q = min_filter(&p, 2); + for y in 0..11 { + for x in 0..11 { + let inside = (3..=7).contains(&x) && (3..=7).contains(&y); + assert_eq!(q.get(x, y), if inside { 0.0 } else { 1.0 }, "({x}, {y})"); + } + } + } + + #[test] + fn nans_are_skipped() { + let mut p = Plane::filled(5, 5, 0.5); + p.set(2, 2, f32::NAN); + p.set(0, 0, 0.25); + let q = min_filter(&p, 1); + assert_eq!(q.get(2, 2), 0.5); + assert_eq!(q.get(1, 1), 0.25); + let all = Plane::filled(3, 3, f32::NAN); + assert!(min_filter(&all, 1).data.iter().all(|v| v.is_nan())); + } + + #[test] + fn a_short_buffer_is_returned_unchanged() { + // a plane whose data doesn't fill its size must not panic + let p = Plane { width: 4, height: 4, data: vec![1.0; 3] }; + assert_eq!(min_filter(&p, 2), p); + } +} diff --git a/crates/raw/Cargo.toml b/crates/raw/Cargo.toml index 2ca9ef246..3cb2003b2 100644 --- a/crates/raw/Cargo.toml +++ b/crates/raw/Cargo.toml @@ -20,6 +20,7 @@ rayon = { workspace = true } miniz_oxide = { workspace = true } zune-jpeg = { workspace = true } zune-core = { workspace = true } +jpeg-encoder = { workspace = true } jxl-oxide = { workspace = true, optional = true } [features] @@ -28,8 +29,11 @@ default = ["jxl"] jxl = ["dep:jxl-oxide"] [dev-dependencies] -jpeg-encoder = { workspace = true } +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } proptest = { workspace = true } zune-jpegxl = { workspace = true } # Pinned CC0 corpus identities; not a product dependency. sha2 = { version = "0.10", default-features = false, features = ["std"] } +# Peak-heap regression tests (tests/sr2_heap.rs); not a product dependency. +dhat = { workspace = true } diff --git a/crates/raw/examples/rawinfo.rs b/crates/raw/examples/rawinfo.rs index d5cc21b15..23e540b96 100644 --- a/crates/raw/examples/rawinfo.rs +++ b/crates/raw/examples/rawinfo.rs @@ -1,9 +1,11 @@ //! `cargo run --release -p lightcraft-raw --example rawinfo -- [--tree] FILE...` //! //! Prints the TIFF structure (with `--tree`), decode result, timings (MP/s) and basic statistics. +//! Exits unsuccessfully if any input cannot be read, decoded or developed, while inspecting the remaining files. use lightcraft_raw::{Method, RawData, decode, embedded_preview, probe}; use lightcraft_tiff::{Ifd, Tiff, Value}; +use std::process::ExitCode; use std::time::Instant; fn short(v: &Value) -> String { @@ -34,11 +36,23 @@ fn dump(ifd: &Ifd, depth: usize, label: &str) { } } -fn main() { - let args: Vec<String> = std::env::args().skip(1).collect(); +fn inspect_files(args: &[String]) -> usize { let tree = args.iter().any(|a| a == "--tree"); - for path in args.iter().filter(|a| !a.starts_with("--")) { - let bytes = std::fs::read(path).expect("read"); + let paths: Vec<_> = args.iter().filter(|a| !a.starts_with("--")).collect(); + if paths.is_empty() { + eprintln!("usage: rawinfo [--tree] FILE..."); + return 1; + } + let mut failures = 0; + for path in paths { + let bytes = match std::fs::read(path) { + Ok(bytes) => bytes, + Err(e) => { + eprintln!("{path}: read error: {e}"); + failures += 1; + continue; + } + }; println!("== {path} ({:.1} MB) format {:?}", bytes.len() as f64 / 1e6, probe(&bytes)); if tree && let Ok(t) = Tiff::parse(&bytes) { for (i, ifd) in t.ifds.iter().enumerate() { @@ -119,15 +133,106 @@ fn main() { s[2] / n ); } - Err(e) => println!(" develop {m:?}: {e}"), + Err(e) => { + println!(" develop {m:?}: {e}"); + failures += 1; + } } } } - Err(e) => println!(" decode error: {e}"), + Err(e) => { + println!(" decode error: {e}"); + failures += 1; + } } match embedded_preview(&bytes) { Some(p) => println!(" preview: {} bytes", p.len()), None => println!(" preview: none"), } } + failures +} + +fn main() -> ExitCode { + let args: Vec<String> = std::env::args().skip(1).collect(); + if inspect_files(&args) == 0 { ExitCode::SUCCESS } else { ExitCode::FAILURE } +} + +#[cfg(test)] +mod tests { + use super::inspect_files; + use lightcraft_raw::{BlackLevel, Cfa, ColorData, DngWriteOptions, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, write_dng}; + use std::path::PathBuf; + use std::sync::atomic::{AtomicUsize, Ordering}; + + struct TempDir(PathBuf); + + impl TempDir { + fn new() -> Self { + static NEXT: AtomicUsize = AtomicUsize::new(0); + let path = std::env::temp_dir().join(format!("lightcraft-rawinfo-{}-{}", std::process::id(), NEXT.fetch_add(1, Ordering::Relaxed))); + std::fs::create_dir(&path).expect("create test directory"); + Self(path) + } + + fn path(&self, name: &str) -> String { + self.0.join(name).to_string_lossy().into_owned() + } + } + + impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + #[test] + fn unreadable_input_is_a_failure_without_panicking() { + let dir = TempDir::new(); + assert_eq!(inspect_files(&[dir.path("absent.arw")]), 1); + } + + #[test] + fn invalid_inputs_are_counted_without_stopping_the_batch() { + let dir = TempDir::new(); + let path = dir.path("invalid.arw"); + std::fs::write(&path, b"not a raw file").expect("write invalid fixture"); + assert_eq!(inspect_files(&[path.clone(), path]), 2); + } + + #[test] + fn input_paths_are_required() { + assert_eq!(inspect_files(&[]), 1); + assert_eq!(inspect_files(&["--tree".into()]), 1); + } + + #[test] + fn valid_generated_dng_succeeds_after_an_unreadable_input() { + let dir = TempDir::new(); + let raw = RawImage { + format: RawFormat::Dng, + width: 16, + height: 16, + cpp: 1, + data: RawData::U16(vec![2048; 16 * 16]), + cfa: Some(Cfa::bayer("RGGB").expect("valid CFA")), + bits: 14, + black: BlackLevel::uniform(0.0), + white: vec![16383.0], + active_area: Rect::new(0, 0, 16, 16), + crop: Rect::new(0, 0, 16, 16), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: Some([1.0; 3]), + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + let path = dir.path("valid.dng"); + std::fs::write(&path, write_dng(&raw, &DngWriteOptions::default()).expect("encode generated fixture")).expect("write fixture"); + assert_eq!(inspect_files(std::slice::from_ref(&path)), 0); + assert_eq!(inspect_files(&[dir.path("absent.arw"), path]), 1); + } } diff --git a/crates/raw/src/binned.rs b/crates/raw/src/binned.rs index 674651c90..db725a2ec 100644 --- a/crates/raw/src/binned.rs +++ b/crates/raw/src/binned.rs @@ -24,7 +24,7 @@ impl RawImage { /// and B; Bayer: `k` even, so that every block has the same layout)? pub fn can_bin(&self, k: usize) -> bool { let Some(cfa) = &self.cfa else { return false }; - if k < 2 || self.cpp != 1 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { + if k < 2 || self.cpp != 1 || (self.pixel_aspect - 1.0).abs() >= 0.01 || !cfa.valid() || (cfa.is_bayer() && !k.is_multiple_of(2)) { return false; } // every window position relative to the pattern @@ -163,8 +163,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/color.rs b/crates/raw/src/color.rs index a7cea813d..2caafcfde 100644 --- a/crates/raw/src/color.rs +++ b/crates/raw/src/color.rs @@ -287,8 +287,10 @@ mod tests { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: Default::default(), metadata: Default::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/demosaic/ahd.rs b/crates/raw/src/demosaic/ahd.rs index 3f17413ff..d9fc27d76 100644 --- a/crates/raw/src/demosaic/ahd.rs +++ b/crates/raw/src/demosaic/ahd.rs @@ -8,7 +8,10 @@ //! adaptive luminance / chrominance tolerances (ε_L, ε_C from the paper) — the homogeneity. //! 4. Sum homogeneity over a 3×3 window and pick the more homogeneous candidate (average on ties). //! -//! Work is done in overlapping tiles (bounded memory, rayon-parallel). +//! Work is done in overlapping tiles (bounded memory, rayon-parallel), one band of tiles at a time written +//! straight into the output. Each step runs only where the tile's output depends on it: the choice reads the +//! homogeneity 1 pixel around the tile, which reads Lab 2 pixels around, the candidates read the greens 3 +//! pixels around, and those read the mosaic 5 pixels around, inside the `MARGIN`. use super::Mosaic; use crate::Rgb32f; @@ -19,20 +22,26 @@ const MARGIN: usize = 6; pub(crate) fn ahd(m: &Mosaic) -> Rgb32f { let (w, h) = (m.w, m.h); - let tiles: Vec<(usize, usize)> = (0..h.div_ceil(TILE)).flat_map(|ty| (0..w.div_ceil(TILE)).map(move |tx| (tx * TILE, ty * TILE))).collect(); - let results: Vec<(usize, usize, usize, usize, Vec<[f32; 3]>)> = tiles - .par_iter() - .map(|&(x0, y0)| { - let (tw, th) = (TILE.min(w - x0), TILE.min(h - y0)); - (x0, y0, tw, th, tile(m, x0, y0, tw, th)) - }) - .collect(); let mut out = Rgb32f::new(w, h); - for (x0, y0, tw, th, px) in results { - for y in 0..th { - out.data[(y0 + y) * w + x0..(y0 + y) * w + x0 + tw].copy_from_slice(&px[y * tw..(y + 1) * tw]); - } + if w == 0 || h == 0 { + return out; } + // one row of tiles per task, its tiles in parallel too, written straight into the picture + out.data.par_chunks_mut((w * TILE).max(1)).enumerate().for_each(|(ty, band)| { + let (y0, th) = (ty * TILE, band.len() / w.max(1)); + let tiles: Vec<(usize, usize, Vec<[f32; 3]>)> = (0..w.div_ceil(TILE)) + .into_par_iter() + .map(|tx| { + let (x0, tw) = (tx * TILE, TILE.min(w - tx * TILE)); + (x0, tw, tile(m, x0, y0, tw, th)) + }) + .collect(); + for (x0, tw, px) in tiles { + for (row, src) in band.chunks_exact_mut(w).zip(px.chunks_exact(tw)) { + row[x0..x0 + tw].copy_from_slice(src); + } + } + }); out } @@ -56,110 +65,100 @@ fn tile(m: &Mosaic, x0: usize, y0: usize, tw: usize, th: usize) -> Vec<[f32; 3]> let (ox, oy) = (x0 as isize - MARGIN as isize, y0 as isize - MARGIN as isize); let mut mos = vec![0f32; n]; let mut col = vec![0u8; n]; - for y in 0..bh { - for x in 0..bw { - mos[y * bw + x] = m.get(ox + x as isize, oy + y as isize); - col[y * bw + x] = m.color(ox + x as isize, oy + y as isize); + for (y, (mrow, crow)) in mos.chunks_exact_mut(bw).zip(col.chunks_exact_mut(bw)).enumerate() { + let ry = super::reflect(oy + y as isize, m.h); + if ox >= 0 && ox as usize + bw <= m.w { + // inside the image: the row as it is, and its colours repeating with the pattern + let x0 = ry * m.w + ox as usize; + mrow.copy_from_slice(&m.data[x0..x0 + bw]); + let period: Vec<u8> = (0..m.cfa.width).map(|k| m.cfa.color_at(ox as usize + k, ry)).collect(); + for (c, &p) in crow.iter_mut().zip(period.iter().cycle()) { + *c = p; + } + } else { + for x in 0..bw { + mrow[x] = m.get(ox + x as isize, oy + y as isize); + crow[x] = m.color(ox + x as isize, oy + y as isize); + } } } - let cl = |x: isize, y: isize| -> usize { y.clamp(0, bh as isize - 1) as usize * bw + x.clamp(0, bw as isize - 1) as usize }; + // flat indices of the pixels `d` or more from the buffer's edge, row by row + let inner = |d: usize| (d..bh - d).flat_map(move |y| y * bw + d..y * bw + bw - d); // 1. directional greens let mut gh = vec![0f32; n]; let mut gv = vec![0f32; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let c = mos[i]; - if col[i] == 1 { - gh[i] = c; - gv[i] = c; - continue; - } - let (l, r) = (mos[cl(x - 1, y)], mos[cl(x + 1, y)]); - let est = (l + r) * 0.5 + (2.0 * c - mos[cl(x - 2, y)] - mos[cl(x + 2, y)]) * 0.25; - gh[i] = super::clamp_between(est, l, r); - let (u, d) = (mos[cl(x, y - 1)], mos[cl(x, y + 1)]); - let est = (u + d) * 0.5 + (2.0 * c - mos[cl(x, y - 2)] - mos[cl(x, y + 2)]) * 0.25; - gv[i] = super::clamp_between(est, u, d); + for i in inner(MARGIN - 3) { + let c = mos[i]; + if col[i] == 1 { + gh[i] = c; + gv[i] = c; + continue; } + let (l, r) = (mos[i - 1], mos[i + 1]); + let est = (l + r) * 0.5 + (2.0 * c - mos[i - 2] - mos[i + 2]) * 0.25; + gh[i] = super::clamp_between(est, l, r); + let (u, d) = (mos[i - bw], mos[i + bw]); + let est = (u + d) * 0.5 + (2.0 * c - mos[i - 2 * bw] - mos[i + 2 * bw]) * 0.25; + gv[i] = super::clamp_between(est, u, d); } - // 2. full candidates - let complete = |g: &[f32]| -> Vec<[f32; 3]> { + // 2. full candidates, and 3. their Lab + // (`p` with channel `c` set to `v`, in registers: writing channels through an index and reading the pixel + // back for its Lab stalled on store forwarding) + let set = |p: [f32; 3], c: u8, v: f32| [if c == 0 { v } else { p[0] }, if c == 1 { v } else { p[1] }, if c >= 2 { v } else { p[2] }]; + let complete = |g: &[f32]| -> (Vec<[f32; 3]>, Vec<[f32; 3]>) { let mut rgb = vec![[0f32; 3]; n]; - for y in 0..bh as isize { - for x in 0..bw as isize { - let i = cl(x, y); - let own = col[i] as usize; - let gvv = g[i]; - let d = |dx: isize, dy: isize| { - let j = cl(x + dx, y + dy); - mos[j] - g[j] - }; - let p = &mut rgb[i]; - p[1] = gvv; - if own == 1 { - let ch = col[cl(x + 1, y)] as usize; - let cv = col[cl(x, y + 1)] as usize; - p[ch] = gvv + (d(-1, 0) + d(1, 0)) * 0.5; - p[cv] = gvv + (d(0, -1) + d(0, 1)) * 0.5; - } else { - p[own] = mos[i]; - p[2 - own] = gvv + (d(-1, -1) + d(1, -1) + d(-1, 1) + d(1, 1)) * 0.25; - } - } + let mut lab_of = vec![[0f32; 3]; n]; + for i in inner(MARGIN - 2) { + let own = col[i]; + let gvv = g[i]; + let d = |j: usize| mos[j] - g[j]; + let p = if own == 1 { + let p = set([0.0, gvv, 0.0], col[i + 1], gvv + (d(i - 1) + d(i + 1)) * 0.5); + set(p, col[i + bw], gvv + (d(i - bw) + d(i + bw)) * 0.5) + } else { + let p = set([0.0, gvv, 0.0], own, mos[i]); + set(p, 2 - own, gvv + (d(i - bw - 1) + d(i - bw + 1) + d(i + bw - 1) + d(i + bw + 1)) * 0.25) + }; + rgb[i] = p; + lab_of[i] = lab(p); } - rgb + (rgb, lab_of) }; - let rh = complete(&gh); - let rv = complete(&gv); - // 3. Lab + homogeneity - let lh: Vec<[f32; 3]> = rh.iter().map(|&p| lab(p)).collect(); - let lv: Vec<[f32; 3]> = rv.iter().map(|&p| lab(p)).collect(); + let (rh, lh) = complete(&gh); + let (rv, lv) = complete(&gv); + // homogeneity let dl = |a: &[f32; 3], b: &[f32; 3]| (a[0] - b[0]).abs(); let dc = |a: &[f32; 3], b: &[f32; 3]| (a[1] - b[1]).powi(2) + (a[2] - b[2]).powi(2); let mut hh = vec![0u8; n]; let mut hv = vec![0u8; n]; - for y in 1..bh as isize - 1 { - for x in 1..bw as isize - 1 { - let i = cl(x, y); - let (l, r, u, d) = (cl(x - 1, y), cl(x + 1, y), cl(x, y - 1), cl(x, y + 1)); - let eps_l = dl(&lh[i], &lh[l]).max(dl(&lh[i], &lh[r])).min(dl(&lv[i], &lv[u]).max(dl(&lv[i], &lv[d]))); - let eps_c = dc(&lh[i], &lh[l]).max(dc(&lh[i], &lh[r])).min(dc(&lv[i], &lv[u]).max(dc(&lv[i], &lv[d]))); - let mut ch = 0; - let mut cv = 0; - for j in [l, r, u, d] { - if dl(&lh[i], &lh[j]) <= eps_l && dc(&lh[i], &lh[j]) <= eps_c { - ch += 1; - } - if dl(&lv[i], &lv[j]) <= eps_l && dc(&lv[i], &lv[j]) <= eps_c { - cv += 1; - } - } - hh[i] = ch; - hv[i] = cv; - } + for i in inner(MARGIN - 1) { + // left, right, up, down + let (l, r, u, d) = (i - 1, i + 1, i - bw, i + bw); + let (a, b) = (lh[i], lv[i]); + let dlh = [dl(&a, &lh[l]), dl(&a, &lh[r]), dl(&a, &lh[u]), dl(&a, &lh[d])]; + let dch = [dc(&a, &lh[l]), dc(&a, &lh[r]), dc(&a, &lh[u]), dc(&a, &lh[d])]; + let dlv = [dl(&b, &lv[l]), dl(&b, &lv[r]), dl(&b, &lv[u]), dl(&b, &lv[d])]; + let dcv = [dc(&b, &lv[l]), dc(&b, &lv[r]), dc(&b, &lv[u]), dc(&b, &lv[d])]; + let eps_l = dlh[0].max(dlh[1]).min(dlv[2].max(dlv[3])); + let eps_c = dch[0].max(dch[1]).min(dcv[2].max(dcv[3])); + hh[i] = (0..4).map(|k| u8::from(dlh[k] <= eps_l && dch[k] <= eps_c)).sum(); + hv[i] = (0..4).map(|k| u8::from(dlv[k] <= eps_l && dcv[k] <= eps_c)).sum(); } // 4. choose let mut out = Vec::with_capacity(tw * th); - for y in MARGIN..MARGIN + th { - for x in MARGIN..MARGIN + tw { - let (mut sh, mut sv) = (0u32, 0u32); - for dy in -1isize..=1 { - for dx in -1isize..=1 { - let j = cl(x as isize + dx, y as isize + dy); - sh += hh[j] as u32; - sv += hv[j] as u32; - } - } - let i = y * bw + x; - out.push(if sh > sv { - rh[i] - } else if sv > sh { - rv[i] - } else { - [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] - }); + for i in inner(MARGIN) { + let (mut sh, mut sv) = (0u32, 0u32); + for j in [i - bw - 1, i - bw, i - bw + 1, i - 1, i, i + 1, i + bw - 1, i + bw, i + bw + 1] { + sh += hh[j] as u32; + sv += hv[j] as u32; } + out.push(if sh > sv { + rh[i] + } else if sv > sh { + rv[i] + } else { + [(rh[i][0] + rv[i][0]) * 0.5, (rh[i][1] + rv[i][1]) * 0.5, (rh[i][2] + rv[i][2]) * 0.5] + }); } out } diff --git a/crates/raw/src/dng.rs b/crates/raw/src/dng.rs index 026aae90d..e9fc0999d 100644 --- a/crates/raw/src/dng.rs +++ b/crates/raw/src/dng.rs @@ -338,15 +338,34 @@ pub(crate) fn decode_as(bytes: &[u8], mode: Mode, format: RawFormat) -> Result<R color: color_data(ifd0, raw, tiff.order), wb_multipliers: None, linearized, + pixel_aspect: default_scale_aspect(raw.f64s(t::DEFAULT_SCALE).as_deref()), opcodes, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) } +/// Pixel height over width from `DefaultScale` (horizontal, vertical): only a clear 1.5 to 4 ratio counts (the rows stretch; a wider-than-tall pixel is left alone). +fn default_scale_aspect(scale: Option<&[f64]>) -> f32 { + match scale { + Some([h, v]) if *h > 0.0 && *v > 0.0 && (1.5..=4.0).contains(&(v / h)) => (v / h) as f32, + _ => 1.0, + } +} + #[cfg(test)] mod tests { + #[test] + fn default_scale_makes_tall_pixels() { + assert_eq!(default_scale_aspect(Some(&[1.0, 2.0])), 2.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[2.0, 1.0])), 1.0); + assert_eq!(default_scale_aspect(Some(&[1.0, 1.2])), 1.0); + assert_eq!(default_scale_aspect(None), 1.0); + } + use super::*; /// Store `image` the way DNG 1.7 interleaves it: row `r` of the image is written to stored row diff --git a/crates/raw/src/dngwrite.rs b/crates/raw/src/dngwrite.rs index 379267c87..d7ba42776 100644 --- a/crates/raw/src/dngwrite.rs +++ b/crates/raw/src/dngwrite.rs @@ -23,12 +23,77 @@ pub enum DngCompression { tile: u32, half: bool, }, + /// Lossy DNG (DNG 1.4 compression 34892): the raw data demosaiced to linear camera RGB (with `OpcodeList3` + /// and the default crop applied), scaled down to at most `long_edge` pixels on the long side when given, + /// then stored as 8-bit baseline JPEG tiles of `tile × tile` pixels at JPEG `quality` (1–100). The 8-bit + /// codes follow a 1/2.2 power curve that a `LinearizationTable` maps back to linear 16-bit values, so + /// shadows keep their precision. The colour tags (matrices, white balance, profile look) are kept. + LossyJpeg { + tile: u32, + quality: u8, + long_edge: Option<u32>, + }, +} + +/// Gamma of the 8-bit codes in a lossy DNG (own choice: perceptually close to even steps, like sRGB). +const LOSSY_GAMMA: f64 = 2.2; + +/// The 256-entry `LinearizationTable` of a lossy DNG: code → linear 16-bit value. +fn lossy_linearization() -> Vec<u16> { + (0..256u32).map(|i| ((i as f64 / 255.0).powf(LOSSY_GAMMA) * 65535.0).round() as u16).collect() +} + +/// The image a lossy DNG stores: `raw` developed to linear camera RGB (white = 1), scaled to fit +/// `long_edge`, as 8-bit codes (in `u16` samples) on the [`LOSSY_GAMMA`] curve, with the levels, +/// geometry and opcode lists that this data needs (black 0, white 65535 after linearization). +fn lossy_source(raw: &RawImage, long_edge: Option<u32>) -> Result<RawImage> { + let mut rgb = raw.develop(crate::Method::Ahd)?; + if let Some(edge) = long_edge.map(|e| e.max(1) as usize) { + let long = rgb.width.max(rgb.height); + if long > edge { + let s = edge as f64 / long as f64; + let w = ((rgb.width as f64 * s).round() as usize).max(1); + let h = ((rgb.height as f64 * s).round() as usize).max(1); + rgb = lightcraft_raster::resample::resize(&rgb, w, h, lightcraft_raster::resample::Filter::Lanczos3); + } + } + let (w, h) = (rgb.width, rgb.height); + if w == 0 || h == 0 { + return Err(RawError::Corrupt("empty image".into())); + } + let inv = 1.0 / LOSSY_GAMMA as f32; + let data: Vec<u16> = rgb + .data + .par_iter() + .flat_map_iter(|px| px.map(|v| if v.is_finite() { (v.clamp(0.0, 1.0).powf(inv) * 255.0).round() as u16 } else { 0 })) + .collect(); + let full = crate::Rect::new(0, 0, w, h); + Ok(RawImage { + width: w, + height: h, + cpp: 3, + data: RawData::U16(data), + cfa: None, + bits: 8, + black: crate::BlackLevel::default(), + white: vec![65535.0], + active_area: full, + crop: full, + // list 1 and 2 work on the undemosaiced data and list 3 is applied by `develop` + opcodes: crate::OpcodeLists::default(), + linearized: true, + ..raw.clone() + }) } /// Software tag of the DNGs this writer makes. A bare "LightCraft" marks older files, whose CFAPattern is anchored at /// the sensor origin instead of the ActiveArea (the reader keeps their original reading). pub const SOFTWARE: &str = "LightCraft DNG 2"; +/// zlib level of Deflate tiles. On a 67 MP focus-stack merge (half floats) level 6 took 7× as long as level 1 +/// (3.9 s vs 0.55 s on 4 cores) to save 3 % of the file (266 vs 275 MB); 5–12 % on raw and rendered DNGs. +const DEFLATE_LEVEL: u8 = 1; + /// IEEE 754 binary32 → binary16 (round to nearest even; overflow → ±inf, NaN kept). pub fn f32_to_f16(v: f32) -> u16 { let b = v.to_bits(); @@ -102,6 +167,14 @@ fn rat_vec(v: &[f64]) -> Value { /// Serialise `raw` as a DNG. pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { raw.validate()?; + let lossy; + let raw = match opts.compression { + DngCompression::LossyJpeg { long_edge, .. } => { + lossy = lossy_source(raw, long_edge)?; + &lossy + } + _ => raw, + }; let (w, h, cpp) = (raw.width, raw.height, raw.cpp); let mut ifd = IfdBuilder::new(); ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); @@ -274,7 +347,7 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } buf.extend_from_slice(&float_predict(&row, tile * cpp, bp, cpp)); } - miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + miniz_oxide::deflate::compress_to_vec_zlib(&buf, DEFLATE_LEVEL) }) .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); @@ -291,6 +364,37 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } ifd.set_image(ImageData::Strips { rows_per_strip: h as u32, strips: vec![bytes] }); } + (RawData::U16(v), DngCompression::LossyJpeg { tile, quality, .. }) => { + // `lossy_source` made 3-sample 8-bit codes + let tile = (tile.clamp(16, 4096) & !15) as usize; + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8; cpp])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression::LOSSY_JPEG])); + ifd.set(t::LINEARIZATION_TABLE, Value::Short(lossy_linearization())); + let quality = quality.clamp(1, 100); + let (ta, td) = (w.div_ceil(tile), h.div_ceil(tile)); + let tiles: Vec<Vec<u8>> = (0..ta * td) + .into_par_iter() + .map(|i| { + let (tx, ty) = (i % ta * tile, i / ta * tile); + let mut buf = Vec::with_capacity(tile * tile * 3); + for y in 0..tile { + let sy = (ty + y).min(h - 1); + for x in 0..tile { + let sx = (tx + x).min(w - 1); + for s in 0..3 { + buf.push(v.get((sy * w + sx) * cpp + s.min(cpp - 1)).map_or(0, |&c| c.min(255) as u8)); + } + } + } + let mut out = Vec::new(); + jpeg_encoder::Encoder::new(&mut out, quality) + .encode(&buf, tile as u16, tile as u16, jpeg_encoder::ColorType::Rgb) + .map(|()| out) + .map_err(|e| RawError::Corrupt(format!("lossy JPEG tile: {e}"))) + }) + .collect::<Result<_>>()?; + ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); + } (RawData::U16(v), _) => { let maxv = v.iter().copied().max().unwrap_or(0); let bits = (16 - maxv.leading_zeros()).max(raw.bits.min(16)).max(2) as u8; @@ -337,11 +441,13 @@ pub fn write_dng(raw: &RawImage, opts: &DngWriteOptions) -> Result<Vec<u8>> { } row.iter().for_each(|&x| opts.order.put_u16(&mut buf, x)); } - miniz_oxide::deflate::compress_to_vec_zlib(&buf, 6) + miniz_oxide::deflate::compress_to_vec_zlib(&buf, DEFLATE_LEVEL) }) .collect(); ifd.set_image(ImageData::Tiles { tile_width: tile as u32, tile_height: tile as u32, tiles }); } + // handled above (the data is converted first) + DngCompression::LossyJpeg { .. } => return Err(RawError::Unsupported("lossy DNG of this data".into())), DngCompression::Lj92 { tile } => { let tile = (tile.max(2) & !1) as usize; if tile > 65534 { @@ -432,8 +538,10 @@ pub fn write_rendered_dng( color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: crate::OpcodeLists::default(), metadata: metadata.clone(), + lens_metadata: Default::default(), }; let order = ByteOrder::Little; write_dng(&raw, &DngWriteOptions { compression: DngCompression::Deflate { tile: 256, half: true }, order, xmp }) diff --git a/crates/raw/src/lens_metadata.rs b/crates/raw/src/lens_metadata.rs new file mode 100644 index 000000000..4529950c8 --- /dev/null +++ b/crates/raw/src/lens_metadata.rs @@ -0,0 +1,187 @@ +//! Lens-correction data a raw file records, kept with where it came from and separately from what LightCraft +//! applies. +//! +//! A readable table is not a correction. Each component carries a [`SonyLensStatus`]: only +//! [`SonyLensStatus::Applied`] components were converted into the image's `OpcodeList3`, and that is decided by +//! independent validation per model, coding and component (see `docs/sony-lens-corrections.md`), never by the mere +//! presence of a table. Everything else here is metadata for diagnostics and later validation work. + +use crate::Rect; +use serde::{Deserialize, Serialize}; + +/// Vendor lens-correction metadata of a raw file (see the module docs). Empty for formats that record none, or +/// whose tables are read straight into opcodes. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct LensMetadata { + /// Sony ARW: the raw image IFD's shading, lateral chromatic aberration and distortion records, with their + /// encrypted `SR2SubIFD` copies. `None` when neither directory holds any of them. + #[serde(default)] + pub sony: Option<SonyLensCorrections>, +} + +/// Sony's in-file lens-correction record: three independent components, each a camera setting and a radial table. +/// +/// Tag identities and storage types from the ExifTool EXIF / Sony tag-name documentation: raw-IFD `0x7031` / `0x7032` +/// (shading, "VignettingCorrection" / "VignettingCorrParams"), `0x7034` / `0x7035` (lateral chromatic aberration), +/// `0x7036` / `0x7037` (distortion), and the `SR2SubIFD` tables `0x797d`, `0x7980`, `0x7982`. The `SR2SubIFD` setting +/// tags `0x797c`, `0x797f`, `0x7981` are unnamed there; they sit next to the tables and equal the raw-IFD settings +/// in every file inspected. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensCorrections { + /// Shading (vignetting): 16 radial samples whose gain function, radius convention and processing stage are not + /// established. Opaque signed data. + pub shading: SonyComponent<[i16; 16]>, + /// Lateral chromatic aberration: two 16-sample radial curves. A public analysis reads them as red, then blue, + /// relative to green; that is not validated here. + pub chromatic_aberration: SonyComponent<[[i16; 16]; 2]>, + /// Distortion: 16 radial samples (see `docs/sony-lens-corrections.md` for the validated interpretation). + pub distortion: SonyComponent<[i16; 16]>, + /// Whether the encrypted `SR2SubIFD` was found and decrypted. `false`: no copies to fall back on or compare. + pub sr2_read: bool, + /// The framing and coding the tables have to be interpreted against. + pub geometry: SonyLensGeometry, +} + +/// One correction component: the camera's setting, its table, and whether LightCraft applies it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyComponent<T> { + pub setting: SonyField<SonySetting>, + /// The table's samples after the leading sample count, which must equal their number. + pub table: SonyField<T>, + pub status: SonyLensStatus, +} + +/// One field read from the raw IFD and from the `SR2SubIFD`. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyField<T> { + /// The value in use: the raw IFD's when it holds a well-formed one, else the `SR2SubIFD`'s when the raw IFD + /// lacks the tag. `None` when no copy is usable, and when the raw IFD's copy is malformed: a corrupt field is + /// reported, not silently replaced by the other copy. The raw IFD's copy is the one the TIFF reader that decodes + /// the image parsed (its first occurrence, within the reader's limits). + pub value: Option<T>, + pub source: Option<SonySource>, + pub raw_ifd: SonyCopy, + pub sr2: SonyCopy, + /// Whether the two copies are equal, when both are well-formed. The raw IFD's wins when they differ. + pub copies_agree: Option<bool>, + /// Diagnostics: the tag occurs more than once in the raw IFD / the `SR2SubIFD`. Only the first occurrence is used, + /// as the TIFF reader does (the raw IFD's: the first one whose values it could read). + #[serde(default)] + pub raw_ifd_repeated: bool, + #[serde(default)] + pub sr2_repeated: bool, +} + +/// Where a value was read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySource { + /// The plain raw image IFD. + RawIfd, + /// The encrypted `SR2SubIFD`. + Sr2SubIfd, +} + +/// How one copy of a field was found. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyCopy { + /// Not in that directory (or the `SR2SubIFD` could not be read). + Absent, + /// Present with exactly the supported type and layout. + Valid, + /// Present, but refused. + Rejected(SonyFieldError), +} + +/// Why a present field was refused. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyFieldError { + /// Another TIFF field type than SSHORT (8) for a table, or SHORT (3) / SSHORT for a setting. + FieldType(u16), + /// Another number of values than the supported layout's: 17 (shading, distortion), 33 (CA) or 1 (setting). + Count(u64), + /// The leading sample count is not the layout's 16 (32 for CA). Older bodies store 11 (22) samples padded to the + /// same length, a layout that is not interpreted; a count of 0 goes with an "unavailable" setting. + CountPrefix(i16), + /// The values lie outside the file (raw IFD) or the decrypted block (`SR2SubIFD`). + OutsideData, + /// In the raw IFD but past what the TIFF reader that decodes the image reads (8,192 entries per directory, a + /// total value budget), so the image doesn't use it either. + PastReaderLimits, + /// A setting stored as a negative SSHORT. + NegativeSetting(i16), +} + +/// A camera correction setting: the stored number and its documented meaning. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub struct SonySetting { + pub value: u16, + pub meaning: SonySettingMeaning, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation). Values are compared whole; +/// they are not read as bit masks. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonySettingMeaning { + /// Shading 256, CA 0, distortion 0. The table is still recorded: Off does not mean absent. + Off, + /// Shading 257 or 272, CA 1, distortion 1. + Auto, + /// Distortion 17: Auto, fixed by the lens. + ForcedAuto, + /// "No correction params available": shading 511, CA 255, distortion 255. + Unavailable, + /// Any other value (e.g. a shading "Low" setting, whose number is not documented). Never treated as Off. + Unknown(u16), +} + +/// Whether a component is applied, and if not, why. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SonyLensStatus { + /// Converted into the image's `OpcodeList3` (today: ILCE-7RM4A and ILCE-7RM6 Bayer distortion in native 3:2 framing). + Applied, + /// No usable table. + NoTable, + /// Decoded only: no independent validation covers this component's rendering semantics on any body (shading, + /// lateral CA). + NotValidatedComponent, + /// Decoded only: the component is validated on other camera models, not on this one. + NotValidatedModel, + /// Decoded only: the model is validated, but not this coding or framing (linear YCbCr, a non-3:2 crop) or a + /// table found only in the `SR2SubIFD`. + NotValidatedMode, + /// Validated scope, but the table is all zeros: a valid no-op, nothing to apply. + Neutral, + /// Validated scope, but the conversion refused this table (values outside the validated range, a degenerate + /// crop, or a fitted map that misses the table or folds). + Rejected, +} + +/// The framing and coding evidence for interpreting the tables, all in raw-frame pixels. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct SonyLensGeometry { + /// The stored raw frame, including any padding (lossless L files are padded). + pub storage_width: usize, + pub storage_height: usize, + /// Sony's crop tags (`0x74c7` / `0x74c8`) as recorded. + pub sony_crop: Option<Rect>, + /// The raw IFD's `DefaultCropOrigin` / `DefaultCropSize` as recorded. + pub default_crop: Option<Rect>, + /// The image's crop (the decoder's precedence: Sony's tags, then the default crop, then the recorded size). + pub image_crop: Rect, + /// The rectangle the validated distortion conversion is centred on and normalised to: the default crop when + /// the file has one, else the image crop. Clipped to the frame. + pub reference: Rect, + /// TIFF compression of the raw image (1 uncompressed, 7 lossless, 32767 ARW2, 32766 ARW6). + pub compression: u16, + /// Downsized lossless M/S data (linear YCbCr, not sensor CFA). + pub linear_ycbcr: bool, + /// Both the image crop and the reference are 3:2 within 0.5 %, the only framing validated. + pub native_3_2: bool, +} + +impl<T> SonyField<T> { + /// Whether either directory holds the tag (well-formed or not). + pub fn is_present(&self) -> bool { + self.raw_ifd != SonyCopy::Absent || self.sr2 != SonyCopy::Absent + } +} diff --git a/crates/raw/src/lib.rs b/crates/raw/src/lib.rs index 0f366648c..2800f0823 100644 --- a/crates/raw/src/lib.rs +++ b/crates/raw/src/lib.rs @@ -35,6 +35,7 @@ pub mod highlight; #[cfg(feature = "jxl")] mod jxl; pub mod kelvin_presets; +pub mod lens_metadata; pub mod ljpeg; pub(crate) mod llvc; pub mod opcodes; @@ -47,9 +48,11 @@ pub mod spectral; mod tiffraw; mod unpack; mod vendor; +mod wb_estimate; pub use demosaic::{Method, demosaic}; pub use dngwrite::{DngCompression, DngWriteOptions, write_dng, write_rendered_dng}; +pub use lens_metadata::LensMetadata; pub use lightcraft_color::Mat3; pub use lightcraft_geom::Orientation; pub use lightcraft_meta::Metadata; @@ -107,10 +110,14 @@ pub enum RawFormat { X3f, /// A TIFF-based raw that has no DNG version tag but describes itself the way DNG does (a /// full-resolution CFA image with black/white levels, a default crop and `AsShotNeutral`): - /// Hasselblad 3FR and FFF. Read by the DNG reader. + /// Hasselblad 3FR and FFF. Read by `vendor::hasselblad` (the DNG reader plus what Hasselblad adds). CfaTiff, /// Kodak DCS520C TIFF: a two-component lossless JPEG CFA sub-IFD with a response curve (`vendor::kodak`). KodakDcs, + /// Epson ERF: a CFA sub-IFD of 12-bit samples in 16-byte blocks (`vendor::erf`). + Erf, + /// Mamiya MEF: an uncompressed 12-bit packed CFA sub-IFD (`vendor::mef`). + Mef, /// Another TIFF-based raw (3FR, IIQ, ERF, KDC, DCR, MOS, …). OtherTiff, } @@ -132,6 +139,8 @@ impl RawFormat { | RawFormat::CfaTiff | RawFormat::Srw | RawFormat::KodakDcs + | RawFormat::Erf + | RawFormat::Mef ) } } @@ -206,6 +215,12 @@ pub fn probe(bytes: &[u8]) -> Option<RawFormat> { if make.starts_with("SAMSUNG") && samsung_raw { return Some(RawFormat::Srw); } + if vendor::erf::raw_ifd(&t, bytes.len()).is_some() { + return Some(RawFormat::Erf); + } + if vendor::mef::raw_ifd(&t, bytes.len()).is_some() { + return Some(RawFormat::Mef); + } if vendor::kodak::raw_ifd(&t, bytes).is_some() { return Some(RawFormat::KodakDcs); } @@ -382,9 +397,11 @@ fn decode_with(bytes: &[u8], mode: Mode) -> Result<RawImage> { 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).map(lift_clipped), + RawFormat::CfaTiff => vendor::hasselblad::decode(bytes, mode).map(lift_clipped), RawFormat::Srw => vendor::srw::decode(bytes, mode), RawFormat::KodakDcs => vendor::kodak::decode(bytes, mode), + RawFormat::Erf => vendor::erf::decode(bytes, mode), + RawFormat::Mef => vendor::mef::decode(bytes, mode), RawFormat::OtherTiff => Err(RawError::Unsupported(other_tiff_reason(bytes))), other => Err(RawError::Unsupported(format!("{other:?} files are not decoded yet"))), } @@ -415,13 +432,30 @@ pub struct RawInfo { pub wb_multipliers: Option<[f32; 3]>, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]). + #[serde(default)] + pub lens_metadata: LensMetadata, + /// See [`RawImage::pixel_aspect`]. + #[serde(default = "square_pixels")] + pub pixel_aspect: f32, +} + +fn square_pixels() -> f32 { + 1.0 +} + +/// Rows of a picture `height` rows tall once its pixels (taller than wide by `aspect`) are made square. +pub fn stretched_height(height: usize, aspect: f32) -> usize { + if (aspect - 1.0).abs() < 0.01 { height } else { ((height as f32 * aspect).round() as usize).max(1) } } impl RawInfo { - /// Size of the developed image before orientation: the default crop, else the active area. + /// Size of the developed image before orientation: the default crop, else the active area, with the rows + /// stretched for non-square pixels. pub fn developed_size(&self) -> (usize, usize) { let c = self.crop.clipped(self.active_area.width, self.active_area.height); - if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) } + let (w, h) = if c.width > 1 && c.height > 1 { (c.width, c.height) } else { (self.active_area.width, self.active_area.height) }; + (w, stretched_height(h, self.pixel_aspect)) } } @@ -646,8 +680,16 @@ pub struct RawImage { pub wb_multipliers: Option<[f32; 3]>, /// Whether a `LinearizationTable` was applied to `data`. pub linearized: bool, + /// Height of a sensor pixel over its width (1.0 for square pixels). The D1X and D1H write 2.0: their frame is + /// 4028 x 1324 and the picture is 3:2 only once the rows are doubled. [`Self::develop`] stretches the rows by + /// this factor. + pub pixel_aspect: f32, pub opcodes: OpcodeLists, pub metadata: Metadata, + /// Lens-correction tables the file records, decoded but not necessarily applied (see [`crate::lens_metadata`]); only + /// validated components are in `opcodes`. + #[serde(default)] + pub lens_metadata: LensMetadata, } /// Black-subtracted, white-normalised data covering the active area. @@ -679,6 +721,8 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes.clone(), metadata: self.metadata.clone(), + lens_metadata: self.lens_metadata.clone(), + pixel_aspect: self.pixel_aspect, } } @@ -698,6 +742,8 @@ impl RawImage { wb_multipliers: self.wb_multipliers, opcodes: self.opcodes, metadata: self.metadata, + lens_metadata: self.lens_metadata, + pixel_aspect: self.pixel_aspect, } } @@ -728,7 +774,10 @@ impl RawImage { return Err(RawError::Unsupported(format!("CFA pattern {}", c.name()))); } let a = self.active_area; - if a.width == 0 || a.height == 0 || a.x + a.width > self.width || a.y + a.height > self.height { + // checked: a crop origin from a maker note can be `u64::MAX`, and `x + width` would wrap + // back inside the image here and again in `normalized`'s index arithmetic. + let fits = |x: usize, len: usize, limit: usize| x.checked_add(len).is_some_and(|end| end <= limit); + if a.width == 0 || a.height == 0 || !fits(a.x, a.width, self.width) || !fits(a.y, a.height, self.height) { return Err(RawError::Corrupt("active area outside image".into())); } Ok(()) @@ -798,10 +847,13 @@ impl RawImage { pub fn finish_demosaiced(&self, mut rgb: Rgb32f) -> Rgb32f { opcodes::apply_list3(&self.opcodes.list3, &mut rgb); let c = self.develop_crop(rgb.width, rgb.height); - if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { + let rgb = + if c.x == 0 && c.y == 0 && c.width == rgb.width && c.height == rgb.height { rgb } else { rgb.into_crop(c.x, c.y, c.width, c.height) }; + let h = stretched_height(rgb.height, self.pixel_aspect); + if h == rgb.height { return rgb; } - rgb.into_crop(c.x, c.y, c.width, c.height) + lightcraft_raster::resample::resize(&rgb, rgb.width, h, lightcraft_raster::resample::Filter::Mitchell) } /// The part of a `width × height` demosaiced picture (the active area) that [`Self::develop`] keeps: the default @@ -860,8 +912,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), }; let direct = raw.develop(Method::Bilinear).unwrap(); let by_hand = raw.finish_demosaiced(demosaic(&raw.normalized().unwrap(), Method::Bilinear)); @@ -876,6 +930,50 @@ mod tests { // a crop that pokes out is clipped to the picture whole.crop = Rect::new(6, 4, 10, 10); assert_eq!(whole.develop_crop(8, 6), Rect::new(6, 4, 2, 2)); + + // tall pixels (D1X: 2.0) are stretched to square ones: the rows double, `developed_size` says so + let mut tall = raw.clone(); + tall.pixel_aspect = 2.0; + let d = tall.develop(Method::Bilinear).unwrap(); + assert_eq!((d.width, d.height), (5, 8)); + assert_eq!(tall.info().developed_size(), (5, 8)); + assert_eq!(raw.info().developed_size(), (5, 4)); + assert!(!tall.can_bin(2)); + assert_eq!(stretched_height(1324, 2.0), 2648); + assert_eq!(stretched_height(1324, 1.0), 1324); + } + + /// A maker-note crop (`PEF` AOC / `ORF` ImageProcessing) with an origin of `u64::MAX` used to + /// pass the bounds check (`x + width` wraps to 0) and then index out of bounds in + /// `normalized`. It must be a `Corrupt` error at validate time instead. + #[test] + fn an_active_area_that_wraps_past_the_image_is_corrupt() { + let (w, h) = (12usize, 10usize); + let mut raw = RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(vec![100; w * h]), + cfa: Cfa::bayer("RGGB"), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![1023.0], + active_area: Rect::new(usize::MAX, 0, 1, 1), + crop: Rect::default(), + orientation: Orientation::Normal, + color: ColorData::default(), + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Metadata::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + assert!(raw.validate().is_err(), "`x + width` wrapped: the area is outside the image"); + assert!(raw.normalized().is_err()); + raw.active_area = Rect::new(0, 0, w + 1, h); + assert!(raw.validate().is_err(), "one past the last column is outside too"); } #[test] diff --git a/crates/raw/src/llvc.rs b/crates/raw/src/llvc.rs index 0e2cc39cc..17408c760 100644 --- a/crates/raw/src/llvc.rs +++ b/crates/raw/src/llvc.rs @@ -14,6 +14,7 @@ fn corrupt(why: &str) -> RawError { } /// One bit, failing when the reader has run past the `source_bits` of its half. +#[inline(always)] fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { let b = bits.get(1); if bits.consumed_bits() > source_bits { @@ -24,12 +25,14 @@ fn bit(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { /// Count zero bits up to and including the terminating 1 (the `0^n 1` codes), bounded by `MAX_DPT`. fn zeros_then_one(bits: &mut Bits<'_>, source_bits: usize) -> Result<u32> { - let mut n = 0; - while bit(bits, source_bits)? == 0 { - n += 1; - if n > MAX_DPT { - return Err(corrupt("DPT code exceeds MAX_DPT")); - } + // the next MAX_DPT + 1 bits at once (more zeros than that is corrupt either way) + let n = bits.peek(MAX_DPT + 1).leading_zeros() - (32 - (MAX_DPT + 1)); + bits.skip((n + 1).min(MAX_DPT + 1)); + if bits.consumed_bits() > source_bits { + return Err(corrupt("past the end of its half")); + } + if n > MAX_DPT { + return Err(corrupt("DPT code exceeds MAX_DPT")); } Ok(n) } @@ -42,7 +45,7 @@ pub(crate) fn vld_decode_line(bits: &mut Bits<'_>, source_bits: usize, width: us let sets = width.div_ceil(4); let (mut abs, mut dpt, mut cnt, mut dpts) = ([0u32; 4], 0u32, sets, 0u8); let (mut abs_v, mut abs_e, mut dpt_v, mut dpt_e, mut zr) = (false, false, true, false, 0u64); - let mut out: Vec<i32> = Vec::new(); + let mut out: Vec<i32> = Vec::with_capacity(sets * 4); loop { // DPT value decoding (Figure 6.6). if cnt == 0 { @@ -180,43 +183,80 @@ pub(crate) fn band_rows(n: usize, phase: u8) -> (usize, usize) { (low, n - low) } -/// `v[i]` with `i` clamped into range (the whole-sample symmetric extension of the band); 0 for an empty band. -fn at(v: &[i32], i: isize) -> i32 { - let last = v.len() as isize - 1; - if last < 0 { - return 0; - } - v.get(i.clamp(0, last) as usize).copied().unwrap_or(0) -} - /// Saturating narrowing: hostile coefficients clamp instead of overflowing. fn sat(v: i64) -> i32 { v.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 } -/// 1-D inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes). -fn inverse_1d(l: &[i32], h: &[i32], phase: u8) -> Result<Vec<i32>> { - let (n, p) = (l.len() + h.len(), isize::from(phase)); - if band_rows(n, phase) != (l.len(), h.len()) || phase > 1 { - return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); +/// Sample `i` (`w` values wide) of a band of `n` samples, clamped into range (the whole-sample symmetric extension); +/// `zeros` for an empty band. +#[inline] +fn band_sample<'a>(v: &'a [i32], w: usize, n: usize, i: isize, zeros: &'a [i32]) -> &'a [i32] { + if n == 0 { + return zeros; } - let even: Vec<i32> = l - .iter() - .enumerate() - .map(|(j, &lv)| sat(i64::from(lv) - ((i64::from(at(h, j as isize - 1 + p)) + i64::from(at(h, j as isize + p)) + 2) >> 2))) - .collect(); - let mut x = vec![0; n]; - let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); - for (xv, &e) in x.iter_mut().skip(lo).step_by(2).zip(&even) { - *xv = e; - } - for (k, (xv, &hv)) in x.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { - *xv = sat(i64::from(hv) + ((i64::from(at(&even, k as isize - p)) + i64::from(at(&even, k as isize + 1 - p))) >> 1)); - } - Ok(x) + let i = i.clamp(0, n as isize - 1) as usize; + v.get(i * w..(i + 1) * w).unwrap_or(zeros) } -/// Gathers the lines of the `low`/`high` pair along one axis (columns when `vertical`), transforms them, and scatters back. +/// Inverse 5/3 lifting, RDD 34 §6.5, with X[-1] := X[1], X[n] := X[n-2] (see `band_rows` for the sizes), of a +/// signal whose samples are `w` values wide: a band's width for all its columns at once (row by row, as each column +/// alone would give; rows in parallel). One line (`w = 1`) goes through [`lift_row`]. +fn inverse_lift(l: &[i32], h: &[i32], w: usize, phase: u8) -> Result<Vec<i32>> { + use rayon::prelude::*; + let w = w.max(1); + let (nl, nh) = (l.len() / w, h.len() / w); + let (n, p) = (nl + nh, isize::from(phase)); + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + let zeros = vec![0; w]; + let mut even = vec![0; nl * w]; + even.par_chunks_exact_mut(w).zip(l.par_chunks_exact(w)).enumerate().for_each(|(j, (e, lv))| { + let (a, b) = (band_sample(h, w, nh, j as isize - 1 + p, &zeros), band_sample(h, w, nh, j as isize + p, &zeros)); + for (((e, &lv), &a), &b) in e.iter_mut().zip(lv).zip(a).zip(b) { + *e = sat(i64::from(lv) - ((i64::from(a) + i64::from(b) + 2) >> 2)); + } + }); + let mut out = vec![0; n * w]; + let lo = usize::from(phase); + out.par_chunks_exact_mut(w).enumerate().for_each(|(t, o)| { + if t % 2 == lo { + if let Some(e) = even.get((t - lo) / 2 * w..((t - lo) / 2 + 1) * w) { + o.copy_from_slice(e); + } + return; + } + let k = (t - (1 - lo)) / 2; + let Some(hv) = h.get(k * w..(k + 1) * w) else { return }; + let (a, b) = (band_sample(&even, w, nl, k as isize - p, &zeros), band_sample(&even, w, nl, k as isize + 1 - p, &zeros)); + for (((o, &hv), &a), &b) in o.iter_mut().zip(hv).zip(a).zip(b) { + *o = sat(i64::from(hv) + ((i64::from(a) + i64::from(b)) >> 1)); + } + }); + Ok(out) +} + +/// [`inverse_lift`] of one line (`w = 1`) into `out` (`l.len() + h.len()` values), without allocating: `even` is +/// scratch space. The caller has checked the band sizes. +fn lift_row(l: &[i32], h: &[i32], phase: u8, even: &mut Vec<i32>, out: &mut [i32]) { + let (nl, nh, p) = (l.len(), h.len(), isize::from(phase)); + let hs = |i: isize| if nh == 0 { 0 } else { h.get(i.clamp(0, nh as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + even.clear(); + even.extend(l.iter().enumerate().map(|(j, &lv)| sat(i64::from(lv) - ((hs(j as isize - 1 + p) + hs(j as isize + p) + 2) >> 2)))); + let even = &*even; + let es = |i: isize| if nl == 0 { 0 } else { even.get(i.clamp(0, nl as isize - 1) as usize).map_or(0, |&v| i64::from(v)) }; + let (lo, hi) = (usize::from(phase), 1 - usize::from(phase)); + for (o, &e) in out.iter_mut().skip(lo).step_by(2).zip(even.iter()) { + *o = e; + } + for (k, (o, &hv)) in out.iter_mut().skip(hi).step_by(2).zip(h).enumerate() { + *o = sat(i64::from(hv) + ((es(k as isize - p) + es(k as isize + 1 - p)) >> 1)); + } +} + +/// Inverse transform of the `low`/`high` pair along one axis: down the columns when `vertical` (all at once, row by +/// row), else along each row. fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> { if !low.ok() || !high.ok() { return Err(RawError::Corrupt("ARW6 wavelet: plane size mismatch".into())); @@ -228,20 +268,22 @@ fn lines(low: &Plane, high: &Plane, phase: u8, vertical: bool) -> Result<Plane> let (nl, nh) = if vertical { (low.height, high.height) } else { (low.width, high.width) }; let n = nl + nh; let (w, h) = if vertical { (across_l, n) } else { (n, across_l) }; - let mut out = Plane::zeros(w, h); - let get = |p: &Plane, i: usize| -> Vec<i32> { - if vertical { p.data.iter().skip(i).step_by(p.width.max(1)).copied().collect() } else { p.row(i).map(<[i32]>::to_vec).unwrap_or_default() } - }; - for i in 0..across_l { - let x = inverse_1d(&get(low, i), &get(high, i), phase)?; - if vertical { - for (o, v) in out.data.iter_mut().skip(i).step_by(w.max(1)).zip(x) { - *o = v; - } - } else if let Some(r) = out.data.get_mut(i * w..(i + 1) * w) { - r.copy_from_slice(&x); - } + if across_l == 0 { + return Ok(Plane::zeros(w, h)); } + if vertical { + return Ok(Plane { width: w, height: h, data: inverse_lift(&low.data, &high.data, w, phase)? }); + } + if band_rows(n, phase) != (nl, nh) || phase > 1 { + return Err(RawError::Corrupt("ARW6 wavelet: band sizes do not match".into())); + } + use rayon::prelude::*; + let mut out = Plane::zeros(w, h); + out.data.par_chunks_exact_mut(w.max(1)).enumerate().for_each_init(Vec::new, |even, (i, r)| { + if let (Some(l), Some(hi)) = (low.row(i), high.row(i)) { + lift_row(l, hi, phase, even, r); + } + }); Ok(out) } @@ -448,6 +490,29 @@ mod tests { assert_eq!(inverse_53_1d_rows(&col(&[3, -4]), &col(&[10, 20, 30]), 1).unwrap().data, vec![5, -5, 9, -17, 13]); } #[test] + fn horizontal_pass_gives_the_vertical_passs_samples() { + // two implementations of one lifting: `lift_row` along rows, `inverse_lift` down columns + let mut s = 0x9e37_79b9_7f4a_7c15u64; + let mut next = || { + s ^= s << 13; + s ^= s >> 7; + s ^= s << 17; + (s % 8191) as i32 - 4095 + }; + for n in 1..12usize { + for phase in 0..2u8 { + let ((nl, nh), rows) = (band_rows(n, phase), 3); + let low = Plane { width: nl, height: rows, data: (0..nl * rows).map(|_| next()).collect() }; + let high = Plane { width: nh, height: rows, data: (0..nh * rows).map(|_| next()).collect() }; + let across = inverse_53_1d_cols(&low, &high, phase).unwrap(); + for r in 0..rows { + let down = inverse_53_1d_rows(&col(low.row(r).unwrap()), &col(high.row(r).unwrap()), phase).unwrap(); + assert_eq!(across.row(r).unwrap(), &down.data[..], "n {n}, phase {phase}, row {r}"); + } + } + } + } + #[test] fn inverse_53_rejects_mismatched_bands() { assert!(inverse_53_1d_rows(&col(&[1, 2, 3]), &col(&[1]), 0).is_err()); assert!(inverse_53_1d_rows(&col(&[1, 2]), &Plane::zeros(2, 2), 0).is_err()); diff --git a/crates/raw/src/opcodes.rs b/crates/raw/src/opcodes.rs index bcc59ab57..6cf224984 100644 --- a/crates/raw/src/opcodes.rs +++ b/crates/raw/src/opcodes.rs @@ -524,7 +524,7 @@ fn gain_at(points_v: u32, points_h: u32, spacing: [f64; 2], origin: [f64; 2], ma } /// Radius normaliser: distance from the centre to the farthest image corner. -fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { +pub(crate) fn max_radius(cx: f64, cy: f64, w: f64, h: f64) -> f64 { [(0.0, 0.0), (w, 0.0), (0.0, h), (w, h)].iter().map(|&(x, y)| (x - cx).hypot(y - cy)).fold(0.0, f64::max).max(1e-9) } diff --git a/crates/raw/src/preview.rs b/crates/raw/src/preview.rs index bbd954579..71dfad10d 100644 --- a/crates/raw/src/preview.rs +++ b/crates/raw/src/preview.rs @@ -148,8 +148,8 @@ fn candidates<'a>(data: &'a [u8], ifd: &Ifd, base: u64, out: &mut Vec<&'a [u8]>) } } -/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file — both decode with -/// `lightcraft_codecs::decode`. +/// The largest embedded preview, if any: a JPEG, or (DNG 1.7) a JPEG XL file, or for a Hasselblad 3FR / FFF, which +/// has no JPEG, its uncompressed RGB preview wrapped as a baseline TIFF — all decode with `lightcraft_codecs::decode`. pub fn embedded_preview(bytes: &[u8]) -> Option<Vec<u8>> { preview_covering(bytes, None) } @@ -218,8 +218,10 @@ fn preview_covering(bytes: &[u8], min_edge: Option<u32>) -> Option<Vec<u8>> { .and_then(|(min, big)| smallest_covering(&found, big, min)) .or(largest) .map(|s| if is_jxl(s) { s.to_vec() } else { trim_eoi(s).to_vec() }); - // a TIFF raw we can't decode whose preview no tag points to (Leaf MOS, Epson ERF) - best.or_else(|| format.filter(|f| !f.is_supported()).and_then(|_| scan_for_jpeg(bytes))) + // a TIFF raw whose preview no tag points to: one we can't decode (Leaf MOS), or Epson ERF (in its maker note) + best.or_else(|| format.filter(|f| !f.is_supported() || *f == crate::RawFormat::Erf).and_then(|_| scan_for_jpeg(bytes))) + // Hasselblad 3FR / FFF carry no JPEG, only uncompressed RGB images: one wrapped as a baseline TIFF + .or_else(|| (format == Some(crate::RawFormat::CfaTiff)).then(|| crate::vendor::hasselblad::rgb_preview(&tiff, bytes)).flatten()) } /// The end of the DCT JPEG whose SOI is at `soi` (exclusive index after its EOI) and its component count, diff --git a/crates/raw/src/profile.rs b/crates/raw/src/profile.rs index 566c2b1c3..7ca6b0e38 100644 --- a/crates/raw/src/profile.rs +++ b/crates/raw/src/profile.rs @@ -123,6 +123,177 @@ impl HsvTable { }; hsv_to_rgb(hue + shift, s2, v2) } + + /// [`Self::apply`] to `N` colours at once (`rgb[channel][lane]`), bit for bit: the same operations, with the + /// branches on the data (largest channel, hue sector, wrap-around) turned into selects so the compiler + /// vectorises them. The table reads, `powf` and the rare `rem_euclid` run lane by lane. + pub fn apply_lanes<const N: usize>(&self, rgb: [[f32; N]; 3]) -> [[f32; N]; 3] { + let [r, g, b] = rgb; + if !self.fits_lanes() { + let mut out = rgb; + for l in 0..N { + [out[0][l], out[1][l], out[2][l]] = self.apply([r[l], g[l], b[l]]); + } + return out; + } + // `active`: the colours the table applies to (`apply` returns black, NaN and infinite ones as they are) + let (mut max, mut active, mut first) = ([0.0f32; N], [true; N], [false; N]); + let (mut d, mut ratio, mut second, mut third) = ([0.0f32; N], [0.0f32; N], [0.0f32; N], [0.0f32; N]); + for l in 0..N { + let m = r[l].max(g[l]).max(b[l]); + active[l] = !(m.is_nan() | (m <= 0.0) | !m.is_finite()); + d[l] = m - r[l].min(g[l]).min(b[l]); + // hue_of, for each largest channel + ratio[l] = (g[l] - b[l]) / d[l]; + second[l] = (b[l] - r[l]) / d[l] + 2.0; + third[l] = (r[l] - g[l]) / d[l] + 4.0; + first[l] = active[l] & (d[l] > 0.0) & (m == r[l]); + max[l] = m; + } + let ratio = wrap_lanes(ratio, 6.0, first); + let (mut hue, mut sat) = ([0.0f32; N], [0.0f32; N]); + for l in 0..N { + let h = if max[l] == r[l] { + ratio[l] + } else if max[l] == g[l] { + second[l] + } else { + third[l] + }; + let h = h * 60.0; + hue[l] = if d[l] <= 0.0 { 0.0 } else { h }; + sat[l] = d[l] / max[l]; + } + let mut v_index = max; + if self.srgb_value { + for l in 0..N { + if active[l] { + v_index[l] = linear_to_srgb(max[l].min(1.0)); + } + } + } + let [shift, ss, vs] = self.lookup_lanes(&hue, &sat, &v_index, active); + let (mut s2, mut v2, mut curve) = ([0.0f32; N], [0.0f32; N], [false; N]); + for l in 0..N { + s2[l] = limited_select(sat[l], ss[l]); + v2[l] = if self.srgb_value { max[l] } else { limited_select(max[l], vs[l]) }; + curve[l] = self.srgb_value & active[l] & !((max[l] <= 1.0) & (vs[l] == 1.0)); + } + for l in 0..N { + if curve[l] { + v2[l] = if max[l] <= 1.0 { srgb_to_linear((v_index[l] * vs[l]).min(1.0)) } else { max[l] * srgb_to_linear(vs[l].min(1.0)).min(1.0) }; + } + } + // hsv_to_rgb + let hue = wrap_lanes(std::array::from_fn(|l| hue[l] + shift[l]), 360.0, active); + let mut out = rgb; + for l in 0..N { + let hp = hue[l] / 60.0; + // `(hp as u32).min(5) as f32` for every `hp`, NaN included + let step = |k: f32| if hp >= k { 1.0 } else { 0.0 }; + let sector = step(1.0) + step(2.0) + step(3.0) + step(4.0) + step(5.0); + let f = hp - sector; + let (s, v) = (s2[l], v2[l]); + let (p, q, t) = (v * (1.0 - s), v * (1.0 - s * f), v * (1.0 - s * (1.0 - f))); + let red = if (sector == 0.0) | (sector == 5.0) { + v + } else if sector == 1.0 { + q + } else if sector == 4.0 { + t + } else { + p + }; + let green = if sector == 0.0 { + t + } else if sector <= 2.0 { + v + } else if sector == 3.0 { + q + } else { + p + }; + let blue = if sector <= 1.0 { + p + } else if sector == 2.0 { + t + } else if sector == 5.0 { + q + } else { + v + }; + out[0][l] = if active[l] { red } else { r[l] }; + out[1][l] = if active[l] { green } else { g[l] }; + out[2][l] = if active[l] { blue } else { b[l] }; + } + out + } + + /// Whether [`Self::lookup_lanes`] can do this table's index arithmetic in `f32`: every index below 2²³. + fn fits_lanes(&self) -> bool { + let entries = self.hue_divisions.checked_mul(self.sat_divisions).and_then(|n| n.checked_mul(self.val_divisions.max(1))); + self.hue_divisions >= 1 && self.sat_divisions >= 1 && entries.is_some_and(|n| n <= 1 << 23) + } + + /// [`Self::lookup`] at `N` points (`(shift, saturation scale, value scale)[lane]`), bit for bit for the lanes + /// `wanted`, on a table that [`Self::fits_lanes`]. The indices are computed as `f32` (exact integers below 2²³: + /// `x as usize` is a saturating conversion that doesn't vectorise). + fn lookup_lanes<const N: usize>(&self, h: &[f32; N], s: &[f32; N], v: &[f32; N], wanted: [bool; N]) -> [[f32; N]; 3] { + const EXACT: f32 = 8_388_608.0; // 2²³: ulp 1 from here + // `x as usize` for `0 ≤ x < 2²³`: rounded to an integer, minus 1 if it rounded up; NaN is 0 + let floor = |x: f32| { + let nearest = (x + EXACT) - EXACT; + let floor = if nearest > x { nearest - 1.0 } else { nearest }; + if x.is_nan() { 0.0 } else { floor } + }; + let (hn, sn, vn) = (self.hue_divisions as f32, self.sat_divisions as f32, self.val_divisions as f32); + let axis = |x: f32, n: f32| { + let f = x.clamp(0.0, 1.0) * (n - 1.0); + let i0 = floor(f).min((n - 2.0).max(0.0)); + let i1 = (i0 + 1.0).min(n - 1.0); + (i0, i1, (f - i0).clamp(0.0, 1.0)) + }; + let h = wrap_lanes(*h, 360.0, wanted); + // the corners' indices (v, h, s), s fastest, and the weights between them + let (mut at, mut t) = ([[0u32; N]; 8], [[0.0f32; N]; 3]); + for l in 0..N { + let hf = (h[l] / 360.0 * hn).clamp(0.0, hn); + let h0 = floor(hf).min(hn - 1.0); + // (h0 + 1) % hn + let h1 = if h0 + 1.0 == hn { 0.0 } else { h0 + 1.0 }; + let (s0, s1, ts) = axis(s[l], sn); + let (v0, v1, tv) = axis(v[l], vn); + let (v0, v1, tv) = if self.val_divisions > 1 { (v0, v1, tv) } else { (0.0, 0.0, 0.0) }; + for (k, (vi, hi)) in [(v0, h0), (v0, h1), (v1, h0), (v1, h1)].into_iter().enumerate() { + let row = (vi * hn + hi) * sn; + // an exact integer below 2²³ plus 2²³ has it as its mantissa + at[2 * k][l] = (row + s0 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + at[2 * k + 1][l] = (row + s1 + EXACT).to_bits().wrapping_sub(EXACT.to_bits()); + } + [t[0][l], t[1][l], t[2][l]] = [(hf - h0).clamp(0.0, 1.0), ts, tv]; + } + let mut corner = [[[0.0f32; N]; 3]; 8]; + for l in 0..N { + for (corner, at) in corner.iter_mut().zip(&at) { + [corner[0][l], corner[1][l], corner[2][l]] = self.data.get(at[l] as usize).copied().unwrap_or([0.0, 1.0, 1.0]); + } + } + let lerp = |a: &[f32; N], b: &[f32; N], t: &[f32; N]| { + let mut out = [0.0f32; N]; + for l in 0..N { + out[l] = a[l] + (b[l] - a[l]) * t[l]; + } + out + }; + let [th, ts, tv] = &t; + let mut out = [[0.0f32; N]; 3]; + for (c, out) in out.iter_mut().enumerate() { + let plane = |k: usize| lerp(&lerp(&corner[k][c], &corner[k + 1][c], ts), &lerp(&corner[k + 2][c], &corner[k + 3][c], ts), th); + // `lookup`'s `v0 == v1` for every lane: `axis` gives `v1 = v0 + 1` whenever there are two values or more + *out = if self.val_divisions > 1 { lerp(&plane(0), &plane(4), tv) } else { plane(0) }; + } + out + } } /// `x · k` clipped to 1, for `x ≤ 1`; above 1, only scale factors below 1 apply (continuous at 1). @@ -145,6 +316,37 @@ fn wrap(x: f32, p: f32) -> f32 { } } +/// [`wrap`] of `N` values, bit for bit for the lanes `wanted`: branch-free but for the `rem_euclid` fallback. +#[inline] +fn wrap_lanes<const N: usize>(x: [f32; N], p: f32, wanted: [bool; N]) -> [f32; N] { + let (mut out, mut slow) = ([0.0f32; N], [false; N]); + for l in 0..N { + let (first, below, second) = ((x[l] >= 0.0) & (x[l] < p), (x[l] > -p) & (x[l] < 0.0), (x[l] >= p) & (x[l] < 2.0 * p)); + let (up, down) = (x[l] + p, x[l] - p); + out[l] = if first { + x[l] + } else if below { + up + } else { + down + }; + slow[l] = wanted[l] & !(first | below | second); + } + for l in 0..N { + if slow[l] { + out[l] = x[l].rem_euclid(p); + } + } + out +} + +/// [`limited`] without a branch. +#[inline] +fn limited_select(x: f32, k: f32) -> f32 { + let (below, above) = ((x * k).min(1.0), x * k.min(1.0)); + if x <= 1.0 { below } else { above } +} + fn hue_of(c: [f32; 3], max: f32, d: f32) -> f32 { if d <= 0.0 { return 0.0; @@ -330,6 +532,81 @@ mod tests { } } + /// Every combination of awkward components (zero, negative, tiny, around 1, huge, NaN, ±infinity), and random + /// colours from black to above 1: grey, each channel largest, hues either side of the wrap-around at 0°. + fn awkward_colours() -> Vec<[f32; 3]> { + let special = [0.0, -0.0, 1e-30, -1e-30, 1e-45, 0.003_130_8, 0.5, -0.5, 1.0, 1.0f32.next_up(), 2.0, 1e30, -1e30]; + let special = [&special[..], &[f32::MAX, f32::INFINITY, f32::NEG_INFINITY, f32::NAN]].concat(); + let mut out = Vec::new(); + for a in &special { + for b in &special { + out.extend(special.iter().map(|c| [*a, *b, *c])); + } + } + let mut seed = 0x2545_f491_4f6c_dd1du64; + for _ in 0..40_000 { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let scale = [1.0, 1e-4, 8.0, 1e-38][(seed >> 56) as usize & 3]; + let c: [f32; 3] = std::array::from_fn(|i| (((seed >> (16 * i)) & 0xffff) as f32 / 65535.0 * 1.6 - 0.2) * scale); + out.push(match seed >> 60 { + 0 => [c[0]; 3], + 1 => [c[0].abs() + 0.1, c[1], c[1].next_up()], + 2 => [c[0].abs() + 0.1, c[1], c[1].next_down()], + _ => c, + }); + } + out + } + + /// `apply_lanes` against `apply`, bit for bit, `N` colours at a time (the last batch padded). + fn assert_lanes_match<const N: usize>(t: &HsvTable, colours: &[[f32; 3]]) { + for chunk in colours.chunks(N) { + let rgb: [[f32; N]; 3] = std::array::from_fn(|c| std::array::from_fn(|l| chunk.get(l).map_or(0.25, |p| p[c]))); + let out = t.apply_lanes(rgb); + for (l, p) in chunk.iter().enumerate() { + let (got, want) = ([out[0][l], out[1][l], out[2][l]], t.apply(*p)); + assert_eq!(got.map(f32::to_bits), want.map(f32::to_bits), "{p:?}: {got:?} vs {want:?}"); + } + } + } + + #[test] + fn lanes_match_one_by_one_bit_for_bit() { + let mut seed = 0x9e37_79b9_7f4a_7c15u64; + let mut table = |h: usize, s: usize, v: usize, srgb_value: bool, shift: f32, value_scale: bool| { + let data = (0..h * s * v) + .map(|_| { + seed ^= seed << 13; + seed ^= seed >> 7; + seed ^= seed << 17; + let x = |k: u64| ((seed >> k) & 0xffff) as f32 / 65535.0; + [(x(0) * 2.0 - 1.0) * shift, 0.5 + x(16), if value_scale { 0.5 + x(32) } else { 1.0 }] + }) + .collect(); + HsvTable { hue_divisions: h, sat_divisions: s, val_divisions: v, data, srgb_value } + }; + let colours = awkward_colours(); + // a fitted camera look's shape; with value scales; a 2D DNG map turning hues by up to 5 turns; one hue and + // one saturation + for t in [ + table(72, 5, 5, true, 30.0, false), + table(72, 5, 5, true, 30.0, true), + table(90, 30, 1, false, 1800.0, true), + table(1, 1, 3, true, 30.0, true), + ] { + assert_lanes_match::<16>(&t, &colours); + assert_lanes_match::<5>(&t, &colours); + } + // entries missing (the identity fallback); too big for the lanes' index arithmetic (one by one) + let short = HsvTable { data: vec![[10.0, 1.5, 0.8]; 7], ..identity(4, 3, 2) }; + let huge = HsvTable { data: Vec::new(), ..identity(4096, 4096, 1) }; + for t in [short, huge] { + assert_lanes_match::<16>(&t, &colours); + } + } + fn close(a: [f32; 3], b: [f32; 3], tol: f32) -> bool { a.iter().zip(&b).all(|(x, y)| (x - y).abs() <= tol) } diff --git a/crates/raw/src/saturation.rs b/crates/raw/src/saturation.rs index aaa0a6dff..16768b16d 100644 --- a/crates/raw/src/saturation.rs +++ b/crates/raw/src/saturation.rs @@ -135,8 +135,10 @@ mod tests { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Metadata::default(), + lens_metadata: Default::default(), } } diff --git a/crates/raw/src/sparse.rs b/crates/raw/src/sparse.rs index e5a8e787d..d67a92b40 100644 --- a/crates/raw/src/sparse.rs +++ b/crates/raw/src/sparse.rs @@ -75,6 +75,21 @@ impl Ranges { self.0 = out; } + /// Add every `[start, end)` of `spans` in one sort and merge (empty ones add nothing). + pub fn extend(&mut self, spans: impl IntoIterator<Item = (u64, u64)>) { + let mut all: Vec<(u64, u64)> = std::mem::take(&mut self.0); + all.extend(spans.into_iter().filter(|&(a, b)| a < b)); + all.sort_unstable(); + let mut out: Vec<(u64, u64)> = Vec::with_capacity(all.len()); + for (a, b) in all { + match out.last_mut() { + Some(last) if a <= last.1 => last.1 = last.1.max(b), + _ => out.push((a, b)), + } + } + self.0 = out; + } + /// Whether all of `[start, end)` is covered (an empty range always is). pub fn contains(&self, start: u64, end: u64) -> bool { start >= end || self.0.iter().any(|&(a, b)| a <= start && end <= b) @@ -227,11 +242,12 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { return plan; } // (the Make is usually in the first kilobytes: a NEF, PEF or ORF is known before the rest is read) - if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef)) { + if matches!(crate::probe(bytes), Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef)) { return HeaderPlan::Whole("its header probe reads samples"); } let Ok(tiff) = Tiff::parse(bytes) else { return HeaderPlan::Whole("TIFF structure") }; let mut holes = Ranges::new(); + let mut spans = Vec::new(); for ifd in tiff.all_ifds() { let chunks: Vec<(u64, u64)> = match ifd.image() { // a preview's strips are read when the preview is wanted: only the raw image is skipped @@ -249,11 +265,12 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { if total < BULK { continue; } - for (i, &(offset, size)) in chunks.iter().enumerate() { + spans.extend(chunks.iter().enumerate().map(|(i, &(offset, size))| { let start = if i == 0 { offset.saturating_add(HEAD) } else { offset }; - holes.insert(start, offset.saturating_add(size).min(len)); - } + (start, offset.saturating_add(size).min(len)) + })); } + holes.extend(spans); // A Panasonic RW2 keeps its raw data from `RawDataOffset` to the end, outside any TIFF image. if holes.is_empty() && bytes.starts_with(b"IIU\0") @@ -262,7 +279,9 @@ fn tiff_plan(bytes: &[u8], loaded: &Ranges, preview: bool) -> HeaderPlan { holes.insert(off.saturating_add(HEAD), len); } finish(len, loaded, &holes, &meta, || match crate::probe(bytes) { - Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef) => HeaderPlan::Whole("its header probe reads samples"), + Some(RawFormat::Nef | RawFormat::Nrw | RawFormat::Orf | RawFormat::Pef | RawFormat::Erf | RawFormat::Mef) => { + HeaderPlan::Whole("its header probe reads samples") + } Some(f) if f.is_supported() => HeaderPlan::Ready, _ => HeaderPlan::Whole("not a supported raw"), }) @@ -565,4 +584,40 @@ mod tests { assert!(crate::probe_info(&file).is_ok()); assert_eq!(run(&file, 16, 0).0, HeaderPlan::Whole("an ARW6 header probe reads its tiles")); } + + /// A TIFF with a million one-byte strips listed in descending order and all byte counts 0: the plan is + /// built in one sort and merge, not one pass over all strips (or all holes) per strip. + #[test] + fn many_strips_without_byte_counts_plan_fast() { + let n = 1_000_000u32; + let (offsets_at, counts_at) = (8 + 2 + 5 * 12 + 4, 8 + 2 + 5 * 12 + 4 + 4 * n); + let data = counts_at + 4 * n; + let mut tiff = b"II*\0\x08\0\0\0".to_vec(); + tiff.extend_from_slice(&5u16.to_le_bytes()); + let entry = |tiff: &mut Vec<u8>, tag: u16, typ: u16, count: u32, value: u32| { + tiff.extend_from_slice(&tag.to_le_bytes()); + tiff.extend_from_slice(&typ.to_le_bytes()); + tiff.extend_from_slice(&count.to_le_bytes()); + tiff.extend_from_slice(&value.to_le_bytes()); + }; + entry(&mut tiff, 256, 4, 1, 1); + entry(&mut tiff, 257, 4, 1, n); + entry(&mut tiff, 273, 4, n, offsets_at); + entry(&mut tiff, 278, 4, 1, 1); + entry(&mut tiff, 279, 4, n, counts_at); + tiff.extend_from_slice(&0u32.to_le_bytes()); + for i in (0..n).rev() { + tiff.extend_from_slice(&(data + i).to_le_bytes()); + } + tiff.resize(data as usize + n as usize, 0); + let len = tiff.len() as u64; + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let mut loaded = Ranges::new(); + loaded.insert(0, len); + let _ = tx.send(header_plan(&tiff, &loaded)); + }); + let plan = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("header_plan took longer than 20 s"); + assert!(!matches!(plan, HeaderPlan::Need(_)), "{plan:?}"); + } } diff --git a/crates/raw/src/spectral.rs b/crates/raw/src/spectral.rs index cfa99ff5d..b80442c02 100644 --- a/crates/raw/src/spectral.rs +++ b/crates/raw/src/spectral.rs @@ -4,7 +4,8 @@ //! `tools/spectral_camera_table.py` from rawtoaces-data (Academy Software Foundation, Apache-2.0): per model, DNG //! `ColorMatrix1/2` and `ForwardMatrix1/2` for Standard Illuminant A and D65, fitted to the camera's measured spectral //! sensitivities. [`Camera::fill`] writes them into a file's [`ColorData`], and [`crate::color`]'s DNG model does the -//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list. No number +//! rest. Other names of a covered camera (Rebel T3i for EOS 600D) come from rawtoaces-data's alias list, and other +//! bodies built on a covered camera's sensor from [`SAME_SENSOR`]. No number //! comes from Adobe or from a GPL raw decoder. Which colour a raw starts from is the engine's choice //! (`docs/camera-preview-colour.md`). use crate::ColorData; @@ -40,6 +41,19 @@ impl Camera { } } +/// `(make, model as sold, table model)`: bodies sharing a table camera's sensor that rawtoaces-data's alias list +/// doesn't name, kept here because the table is generated. Fujifilm's 26.1 MP X-Trans CMOS 4 bodies use the X-T3's +/// sensor (X-T30 issue: a black-and-white Acros camera JPEG gives no colour to fit, and without matrices the photo +/// opened in the muted neutral fallback). +const SAME_SENSOR: &[(&str, &str, &str)] = &[ + ("Fujifilm", "X-T30", "X-T3"), + ("Fujifilm", "X-T30 II", "X-T3"), + ("Fujifilm", "X-Pro3", "X-T3"), + ("Fujifilm", "X100V", "X-T3"), + ("Fujifilm", "X-S10", "X-T3"), + ("Fujifilm", "X-E4", "X-T3"), +]; + /// Model names compared without case, the brand, a leading `EOS` or a trailing `DIGITAL` /// (Exif "Canon EOS 400D DIGITAL" is the table's "EOS 400D"). fn key(brand: &str, model: &str) -> String { @@ -70,7 +84,7 @@ pub fn find(make: Option<&str>, model: &str) -> Option<&'static Camera> { let wanted = key(&row_brand, model); make.as_ref().is_none_or(|m| *m == row_brand) && !wanted.is_empty() && key(&row_brand, row_model) == wanted }; - match table::ALIASES.iter().find(|(m, alias, _)| same(m, alias)) { + match table::ALIASES.iter().chain(SAME_SENSOR).find(|(m, alias, _)| same(m, alias)) { Some((m, _, target)) => table::CAMERAS.iter().find(|c| brand(c.make) == brand(m) && c.model == *target), None => table::CAMERAS.iter().find(|c| same(c.make, c.model)), } @@ -142,10 +156,14 @@ mod tests { ("Canon", "Canon EOS REBEL T3i", "EOS 600D"), ("Canon", "Canon EOS Kiss X5", "EOS 600D"), ("Canon", "Canon EOS R5 Mark II", "EOS R5m2"), + ("Canon", "Canon EOS R6 Mark II", "EOS R6m2"), ("NIKON CORPORATION", "NIKON D800", "D800E"), ("NIKON CORPORATION", "NIKON Z 6_2", "Z f"), ("SONY", "ILCE-7RM4A", "ILCE-7RM4"), ("FUJIFILM", "GFX100S", "GFX 100"), + ("FUJIFILM", "X-T30", "X-T3"), + ("FUJIFILM", "X-T30 II", "X-T3"), + ("FUJIFILM", "X100V", "X-T3"), ] { assert_eq!(find(Some(make), model).map(|c| c.model), Some(row), "{make} {model}"); } @@ -171,7 +189,7 @@ mod tests { assert_eq!(find_camera(&joined), Some(c), "{joined}"); assert_eq!(find_camera(&joined), find(Some(c.make), c.model), "{joined}"); } - for (make, alias, _) in table::ALIASES { + for (make, alias, _) in table::ALIASES.iter().chain(SAME_SENSOR) { let joined = format!("{make} {alias}"); assert_eq!(find_camera(&joined), find(Some(make), alias), "{joined}"); assert!(find_camera(&joined).is_some(), "{joined}"); diff --git a/crates/raw/src/spectral_table.rs b/crates/raw/src/spectral_table.rs index f116e2335..e8703e471 100644 --- a/crates/raw/src/spectral_table.rs +++ b/crates/raw/src/spectral_table.rs @@ -335,6 +335,7 @@ pub(super) const ALIASES: &[(&str, &str, &str)] = &[ ("Canon", "EOS Rebel T3i", "EOS 600D"), // same model, alternative name ("Canon", "Kiss X5", "EOS 600D"), // same model, alternative name ("Canon", "EOS R5 Mark II", "EOS R5m2"), // same model, alternative name + ("Canon", "EOS R6 Mark II", "EOS R6m2"), // same model, alternative name ("Canon", "EOS 5DS R", "EOS 5DS"), // different model, same sensor ("Canon", "EOS 6D Mark II", "EOS RP"), // different model, same sensor ("Canon", "EOS R5 C", "EOS R5"), // different model, same sensor diff --git a/crates/raw/src/tiffraw.rs b/crates/raw/src/tiffraw.rs index 293fafb61..17032f694 100644 --- a/crates/raw/src/tiffraw.rs +++ b/crates/raw/src/tiffraw.rs @@ -156,7 +156,15 @@ fn decode_chunk(data: &[u8], info: &ImageInfo, c: &Chunk, order: ByteOrder, pack return Err(RawError::Limit("chunk too large")); } match info.compression { - comp::NONE => unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1), + comp::NONE => { + // uncompressed: missing rows are filled with zeros, but a chunk holding a small fraction of what its + // samples need is no picture: refused before the nominal chunk is allocated (issue #1512) + let need = (n as u64).saturating_mul(u64::from(bits)) / 8; + if (src.len() as u64).saturating_mul(64) < need { + return Err(RawError::Corrupt(format!("an uncompressed chunk of {n} samples has only {} bytes", src.len()))); + } + unpack_chunk(src, order, packing, cw, ch, cpp, bits, float, 1) + } comp::JPEG => { let f = ljpeg::decode(src, n.saturating_mul(2).max(1 << 16))?; if f.data.len() < n { @@ -217,7 +225,7 @@ fn jxl_chunk_check(src: &[u8], cw: usize, ch: usize, vw: usize, vh: usize, cpp: } /// Decode one baseline JPEG chunk to 8-bit samples: (samples, width, height, channels). -fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> { +pub(crate) fn lossy_jpeg(src: &[u8], cpp: usize) -> Result<(Vec<u8>, usize, usize, usize)> { use zune_core::bytestream::ZCursor; use zune_core::colorspace::ColorSpace; use zune_core::options::DecoderOptions; @@ -405,6 +413,31 @@ mod lossy_tests { assert!(read_image(&file, &past_end, ByteOrder::Little, Packing::Msb).is_err()); } + /// A small image whose uncompressed tile claims to be enormous (16384 x 16384 nominal, a few bytes of data) is + /// refused before that tile is allocated (issue #1512); an edge tile stored at its full nominal size decodes. + #[test] + fn a_huge_nominal_uncompressed_tile_with_little_data_is_refused() { + let info = |tile: u32, bytes: u64| ImageInfo { + width: 8, + height: 8, + bits_per_sample: vec![16], + samples_per_pixel: 1, + compression: comp::NONE, + photometric: 32803, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Tiles { tile_width: tile, tile_height: tile }, + offsets: vec![0], + byte_counts: vec![bytes], + }; + let small = vec![1u8; 2 * 16 * 16]; + assert!(read_image(&small, &info(16, small.len() as u64), ByteOrder::Little, Packing::Msb).is_ok()); + let data = vec![1u8; 4096]; + assert!(matches!(read_image(&data, &info(16384, data.len() as u64), ByteOrder::Little, Packing::Msb), Err(RawError::Corrupt(_)))); + } + /// A lossless-JPEG tile may be coded only as tall as the part inside the image, never shorter. #[test] fn short_lossless_jpeg_tiles_are_errors() { diff --git a/crates/raw/src/vendor/arw.rs b/crates/raw/src/vendor/arw.rs index 59a9cf59e..a662799ae 100644 --- a/crates/raw/src/vendor/arw.rs +++ b/crates/raw/src/vendor/arw.rs @@ -2,7 +2,8 @@ //! //! Sources: H. Dietz, "Sony ARW2 Compression: Artifacts And Credible Repair" (IS&T Electronic Imaging 2016) for //! the ARW2 ("cRAW") scheme, and the ExifTool Sony tag-name documentation for the meaning of the raw-IFD tags -//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop). +//! (`0x7010` tone-curve thresholds, `0x7310` black levels, `0x7313` WB levels, `0x74c7/0x74c8` crop, `0x7031`–`0x7037` +//! lens-correction tables: see [`super::arw_lens`] for the ILCE-7RM4A and [`super::arw_lens_rm6`] for the ILCE-7RM6). //! //! ARW2, as described in the paper: //! 1. Sensor values are tone-mapped to 11-bit codes by a five-segment piecewise-linear curve whose step doubles at @@ -44,8 +45,9 @@ //! through the generic TIFF path. use super::arw6; +use crate::lens_metadata::SonyLensGeometry; use crate::tiffraw::{Packing, check_image, read_image_in}; -use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; +use crate::{BlackLevel, Cfa, ColorData, LensMetadata, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result, ljpeg}; use lightcraft_geom::Orientation; use lightcraft_tiff::image::{Chunk, ImageInfo, Layout, chunk_bytes}; use lightcraft_tiff::tags::{self as t, photometric}; @@ -552,6 +554,14 @@ fn dng_default_crop(raw: &Ifd, w: usize, h: usize) -> Option<Rect> { None } +/// A rectangle exactly as an origin tag and a size tag record it (two values each), when both exist and fit `usize`. +fn recorded_rect(raw: &Ifd, origin: u16, size: u16) -> Option<Rect> { + let (Some(origin), Some(size)) = (raw.u64s(origin), raw.u64s(size)) else { return None }; + let (&[x, y], &[w, h]) = (origin.as_slice(), size.as_slice()) else { return None }; + let n = |v: u64| usize::try_from(v).ok(); + Some(Rect::new(n(x)?, n(y)?, n(w)?, n(h)?)) +} + /// The image area for files without Sony's crop tags (`0x74c7/0x74c8`, written since about 2017): the DNG-style /// default crop when the raw IFD has one, else the image size the camera records (see [`recorded_size_crop`]). /// @@ -748,6 +758,39 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); metadata.height = Some(crop.height as u32); + // The lens-correction record. The distortion table is centred on the DNG-style default crop when the file has + // one: on the ILCE-7RM4A it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and + // the warp was validated against Sony's exports with that centre. The opcode's centre is in active-area + // coordinates, so it stays on the optical centre whichever crop frames the image. + let reference = dng_default_crop(raw, w, h).unwrap_or(crop); + let geometry = SonyLensGeometry { + storage_width: w, + storage_height: h, + sony_crop: recorded_rect(raw, CROP_TOP_LEFT, CROP_SIZE), + default_crop: recorded_rect(raw, t::DEFAULT_CROP_ORIGIN, t::DEFAULT_CROP_SIZE), + image_crop: crop, + reference, + compression: info.compression, + linear_ycbcr: linear_rgb, + native_3_2: super::arw_lens::is_3_2(crop) && super::arw_lens::is_3_2(reference), + }; + // decrypted for the copies' agreement check too, not only when a plain field is missing: a few dozen kB, found by + // reading only three `SR2Private` entries (see `sr2::SubIfd::read`) + let (mut sony_lens, warp) = super::arw_lens::corrections(bytes, tiff.order, (raw, tiff.bigtiff), sr2(), &model, geometry); + // The ILCE-7RM6's tables follow other conventions, measured against its camera JPEGs ([`super::arw_lens_rm6`]); + // its allowlist and `arw_lens`'s are disjoint, so at most one of them produces an opcode. Both the image framing + // and the table geometry must stay within the validated 3:2 scope. + let rm6: Vec<crate::Opcode> = if linear_rgb || !super::arw_lens::is_3_2(crop) { + Vec::new() + } else { + super::arw_lens_rm6::lens_opcodes(raw, &model, Rect::new(0, 0, w, h), reference) + }; + if !rm6.is_empty() + && let Some(record) = sony_lens.as_mut() + { + // distortion only: its lateral CA planes are off (see `arw_lens_rm6::lens_opcodes`) + record.distortion.status = crate::lens_metadata::SonyLensStatus::Applied; + } let img = RawImage { format: RawFormat::Arw, width: w, @@ -767,22 +810,11 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { // makes a neutral surface magenta. WB edits remain relative to this as-shot RGB. wb_multipliers: if linear_rgb { Some([1.0; 3]) } else { wb }, linearized: false, - // The distortion table is centred on the DNG-style default crop when the file has one: on the ILCE-7RM4A - // it starts at x = 32 where Sony's crop tags (the image crop, unchanged) start at 0, and the warp was - // validated against Sony's exports with that centre. The opcode's centre is in active-area coordinates, so - // it stays on the optical centre whichever crop frames the image. - opcodes: OpcodeLists { - // DefaultCrop can retain native geometry while Sony's tags select an aspect crop. - // Both the image framing and the table geometry must stay within the validated 3:2 scope. - list3: if linear_rgb || crop.height == 0 || (crop.width as f64 / crop.height as f64 - 1.5).abs() > 0.005 { - Vec::new() - } else { - let geometry = dng_default_crop(raw, w, h).unwrap_or(crop); - super::arw_lens::distortion(&model, raw, Rect::new(0, 0, w, h), geometry).into_iter().collect() - }, - ..Default::default() - }, + pixel_aspect: 1.0, + // only validated components become opcodes (see `arw_lens`, and `arw_lens_rm6` for the ILCE-7RM6) + opcodes: OpcodeLists { list3: warp.into_iter().chain(rm6).collect(), ..Default::default() }, metadata, + lens_metadata: LensMetadata { sony: sony_lens }, }; img.validate_for(mode)?; Ok(img) @@ -867,6 +899,46 @@ mod tests { TiffWriter::default().write(&[raw]).unwrap() } + /// The ILCE-7RM6's distortion table reaches `OpcodeList3`, in the headers-only probe and in the full decode, as + /// one plane, also with a CA table (lateral CA is off by default); another body's tables don't (see + /// [`super::super::arw_lens_rm6`]). + #[test] + fn lens_tables_reach_opcode_list3() { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + let file = |model: &str, ca: bool| { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(model.into())); + // 3:2, the aspect the opcode is limited to + raw.set(t::IMAGE_WIDTH, Value::Long(vec![96])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![64])); + 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])); + let table = vec![16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + raw.set(super::super::arw_lens_rm6::DISTORTION_PARAMS, Value::SShort(table)); + if ca { + let table = vec![ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, + -6, 20, 42, 56, 60, 54, 32, + ]; + raw.set(super::super::arw_lens_rm6::CA_PARAMS, Value::SShort(table)); + } + raw.set_image(ImageData::Strips { rows_per_strip: 64, strips: vec![vec![0u8; 96 * 64]] }); + TiffWriter::default().write(&[raw]).unwrap() + }; + for mode in [Mode::Header, Mode::Full] { + for ca in [false, true] { + let list3 = |model| decode(&file(model, ca), mode).unwrap().opcodes.list3; + let ops = list3("ILCE-7RM6"); + assert!(matches!(ops.as_slice(), [crate::Opcode::WarpRectilinear { planes, .. }] if planes.len() == 1), "{mode:?} {ca}: {ops:?}"); + assert!(list3("ILCE-7M4").is_empty(), "{mode:?} {ca}"); + } + } + } + #[test] fn strip_larger_or_smaller_than_one_byte_per_pixel_is_the_packed_variant() { for strip in [32 * 4 + 8, 32 * 4 * 5 / 4 - 1, 32 * 4 - 1] { diff --git a/crates/raw/src/vendor/arw6.rs b/crates/raw/src/vendor/arw6.rs index f6f535bd6..9837f3e7b 100644 --- a/crates/raw/src/vendor/arw6.rs +++ b/crates/raw/src/vendor/arw6.rs @@ -329,9 +329,10 @@ type HalfLines = (Half, Vec<Vec<i32>>); fn decode_stream(tile: &[u8], st: &StreamRef, ntu: usize, w2: usize, layouts: &[HalfRows]) -> Result<Vec<HalfLines>> { let halves = stream_halves(tile, st, ntu)?; let width = line_width(st.group, w2); + // halves are independent bit streams: decode them in parallel halves - .into_iter() - .zip(layouts) + .into_par_iter() + .zip(layouts.par_iter()) .map(|(half, rows)| { let end = half.start.checked_add(half.len).ok_or_else(|| corrupt("half position"))?; let bytes = tile.get(half.start..end).ok_or_else(|| corrupt("half outside the tile"))?; @@ -468,36 +469,36 @@ pub(crate) fn colour(p: &TilePlanes) -> Result<Vec<u16>> { } let mw = w2.checked_mul(2).ok_or_else(|| corrupt("tile size"))?; let total = len.checked_mul(4).ok_or_else(|| corrupt("tile size"))?; - let at = |q: &Plane, j: usize, x: usize| -> i64 { q.data.get(j * w2 + x).copied().unwrap_or(0) as i64 }; + // (rows as slices: `at` is 0 past a row's end, as never happens once the sizes above match) + let at = |r: &[i32], x: usize| i64::from(r.get(x).copied().unwrap_or(0)); let mut g1 = vec![0i64; len]; - for j in 0..h2 { - let jp = j.saturating_sub(1); - for x in 0..w2 { + g1.par_chunks_mut(w2.max(1)).enumerate().for_each(|(j, row)| { + let (Some(m), Some(rp), Some(rj)) = (p.m.row(j), p.res.row(j.saturating_sub(1)), p.res.row(j)) else { return }; + for (x, g) in row.iter_mut().enumerate() { let xn = (x + 1).min(w2 - 1); - let r = at(&p.res, jp, x) + at(&p.res, jp, xn) + at(&p.res, j, x) + at(&p.res, j, xn); - if let Some(g) = g1.get_mut(j * w2 + x) { - *g = at(&p.m, j, x) - ((r + 4) >> 3); - } + let r = at(rp, x) + at(rp, xn) + at(rj, x) + at(rj, xn); + *g = at(m, x) - ((r + 4) >> 3); } - } - let g = |j: usize, x: usize| g1.get(j * w2 + x).copied().unwrap_or(0); + }); + let g1_row = |j: usize| g1.get(j * w2..(j + 1) * w2).unwrap_or(&[]); + let g = |r: &[i64], x: usize| r.get(x).copied().unwrap_or(0); let mut out = vec![0u16; total]; - for j in 0..h2 { - let jn = (j + 1).min(h2 - 1); - for x in 0..w2 { + // each plane row gives two mosaic rows + out.par_chunks_mut((2 * mw).max(1)).enumerate().for_each(|(j, rows)| { + let (gj, gn) = (g1_row(j), g1_row((j + 1).min(h2 - 1))); + let (Some(res), Some(c1), Some(c2), Some((top, bottom))) = (p.res.row(j), p.c1.row(j), p.c2.row(j), rows.split_at_mut_checked(mw)) else { + return; + }; + for (x, (t, b)) in top.as_chunks_mut::<2>().0.iter_mut().zip(bottom.as_chunks_mut::<2>().0.iter_mut()).enumerate() { let xp = x.saturating_sub(1); - let g2 = ((g(j, xp) + g(j, x) + g(jn, xp) + g(jn, x)) >> 2) + at(&p.res, j, x); - let mean = (g(j, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; - let r = 2 * at(&p.c1, j, x) + mean; - let b = 2 * at(&p.c2, j, x) + mean; - let cells = [(0, r), (1, g(j, x)), (mw, g2), (mw + 1, b)]; - for (off, v) in cells { - if let Some(o) = out.get_mut(2 * j * mw + 2 * x + off) { - *o = curve(v.clamp(0, 4095) as i32); - } + let g2 = ((g(gj, xp) + g(gj, x) + g(gn, xp) + g(gn, x)) >> 2) + at(res, x); + let mean = (g(gj, x).clamp(0, 4095) + g2.clamp(0, 4095)) >> 1; + let cells = [2 * at(c1, x) + mean, g(gj, x), g2, 2 * at(c2, x) + mean]; + for (o, v) in t.iter_mut().chain(b.iter_mut()).zip(cells) { + *o = curve(v.clamp(0, 4095) as i32); } } - } + }); Ok(out) } @@ -524,10 +525,10 @@ pub(crate) fn decode(strip: &[u8], width: usize, height: usize, mode: crate::Mod } return Ok(Vec::new()); } - let decoded: Vec<Result<Vec<u16>>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect(); + // a tile that fails is reported before the mosaic is allocated (issue #1512) + let decoded: Vec<Vec<u16>> = tiles.par_iter().enumerate().map(|(i, t)| decode_tile(bytes(i)?, t.width, t.height)).collect::<Result<_>>()?; let mut out = vec![0u16; width.checked_mul(height).ok_or_else(|| corrupt("image size"))?]; for (t, d) in tiles.iter().zip(decoded) { - let d = d?; for (r, row) in d.chunks_exact(t.width.max(1)).enumerate() { let start = (t.y + r).checked_mul(width).and_then(|a| a.checked_add(t.x)).ok_or_else(|| corrupt("tile placement"))?; let dst = start.checked_add(row.len()).and_then(|end| out.get_mut(start..end)).ok_or_else(|| corrupt("tile outside image"))?; diff --git a/crates/raw/src/vendor/arw_lens.rs b/crates/raw/src/vendor/arw_lens.rs index f4e02d69d..aa7313e23 100644 --- a/crates/raw/src/vendor/arw_lens.rs +++ b/crates/raw/src/vendor/arw_lens.rs @@ -1,28 +1,348 @@ -//! Sony's plain raw-IFD distortion table, expressed as a DNG warp for the shared optics pipeline. +//! Sony's in-file lens-correction records (shading, lateral chromatic aberration, distortion): bounded decoding of +//! the raw image IFD's fields and their encrypted `SR2SubIFD` copies into [`SonyLensCorrections`], and the one +//! validated conversion, the ILCE-7RM4A's distortion table, into a DNG warp for the shared optics pipeline. //! -//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7037`, signed shorts). -//! Geometry inferred independently from files and Sony Imaging Edge exports; see docs/sony-lens-corrections.md. -//! No lens database or external decoder code. The unrelated MakerNote table has a different scale. +//! Tag identity/layout: https://exiftool.org/TagNames/EXIF.html (`0x7031` to `0x7037`, signed shorts) and +//! https://exiftool.org/TagNames/Sony.html#SR2SubIFD (`0x797d`, `0x7980`, `0x7982`). The `SR2SubIFD` setting tags +//! `0x797c`, `0x797f` and `0x7981` sit next to those tables, unnamed there; they equal the raw IFD's settings in every +//! file inspected. Geometry inferred independently from files and Sony Imaging Edge exports; see +//! docs/sony-lens-corrections.md. No lens database and no external decoder code. The MakerNote's similarly named +//! tables have another scale and layout and are never read here. +//! +//! A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses (its first occurrence, within +//! the reader's limits). The directory is also scanned, for diagnostics only: whether a tag is repeated, and why an +//! entry the reader left out was dropped (values outside the file, an unknown type, or past the reader's limits). +//! `SR2SubIFD` fields are read straight from their first entry. Only the exact layouts are accepted: SHORT\[1\] (or +//! nonnegative SSHORT\[1\]) settings, SSHORT\[17\] headed by 16 for shading and distortion, SSHORT\[33\] headed by +//! 32 for CA. Nothing is allocated from a count. The raw IFD's copy wins; the `SR2SubIFD`'s is used only when the raw +//! IFD lacks the tag, and a malformed raw-IFD copy is reported, not replaced. Decoding grants nothing: only components +//! whose interpretation was independently validated for the model and mode are converted ([`SonyLensStatus`]), and +//! the conversion receives exactly the table the decoder applied before this record existed. +use super::sr2::SubIfd; +use crate::lens_metadata::{ + SonyComponent, SonyCopy, SonyField, SonyFieldError, SonyLensCorrections, SonyLensGeometry, SonyLensStatus, SonySetting, SonySettingMeaning, + SonySource, +}; use crate::{Opcode, Rect}; -use lightcraft_tiff::{Ifd, Value}; +use lightcraft_tiff::{ByteOrder, FieldType, Ifd, Value}; -const DISTORTION: u16 = 0x7037; +/// Each component's setting and table tags, `(raw IFD, SR2SubIFD)`. +const SHADING: Tags = Tags { setting: (0x7031, 0x797c), table: (0x7032, 0x797d) }; +const CA: Tags = Tags { setting: (0x7034, 0x797f), table: (0x7035, 0x7980) }; +const DISTORTION: Tags = Tags { setting: (0x7036, 0x7981), table: (0x7037, 0x7982) }; +/// TIFF field types. +const SHORT: u16 = 3; +const SSHORT: u16 = 8; +/// Camera models whose raw-IFD distortion table was independently validated. Exact names: ILCE-7RM4 and ILCE-7RM4A +/// are distinct models, not interchangeable aliases. +const DISTORTION_MODELS: &[&str] = &["ILCE-7RM4A"]; +/// Most directory entries scanned: a classic TIFF directory's largest count, also bounding a BigTIFF one. +const MAX_ENTRIES: usize = 1 << 16; const SAMPLES: usize = 1024; const MAX_ERROR: f64 = 0.0005; -/// Only independently validated camera models and the 16-entry layout. Unknown models/layouts -/// and implausible/non-smooth maps are left uncorrected. See docs/sony-lens-corrections.md. -/// A camera setting of Off does not remove the lens data: the user can enable it in the developer. -pub(super) fn distortion(model: &str, ifd: &Ifd, active: Rect, crop: Rect) -> Option<Opcode> { +#[derive(Clone, Copy)] +struct Tags { + setting: (u16, u16), + table: (u16, u16), +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Shading, + Ca, + Distortion, +} + +/// The documented meaning of a setting value (ExifTool EXIF tag-name documentation), compared whole. +fn meaning(kind: Kind, value: u16) -> SonySettingMeaning { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + match (kind, value) { + (Shading, 256) | (Ca | Distortion, 0) => Off, + (Shading, 257 | 272) | (Ca | Distortion, 1) => Auto, + (Distortion, 17) => ForcedAuto, + (Shading, 511) | (Ca | Distortion, 255) => Unavailable, + _ => Unknown(value), + } +} + +/// One copy of a field. +#[derive(Clone, Debug, PartialEq)] +enum Read<T> { + Absent, + Valid(T), + Rejected(SonyFieldError), +} + +impl<T> Read<T> { + fn map<U>(self, f: impl FnOnce(T) -> U) -> Read<U> { + match self { + Read::Absent => Read::Absent, + Read::Valid(v) => Read::Valid(f(v)), + Read::Rejected(e) => Read::Rejected(e), + } + } + fn copy(&self) -> SonyCopy { + match self { + Read::Absent => SonyCopy::Absent, + Read::Valid(_) => SonyCopy::Valid, + Read::Rejected(e) => SonyCopy::Rejected(*e), + } + } +} + +/// One copy of a field, and whether its directory holds the tag more than once. +struct Found<T> { + read: Read<T>, + repeated: bool, +} + +impl<T> Found<T> { + fn map<U>(self, f: impl FnOnce(T) -> U) -> Found<U> { + Found { read: self.read.map(f), repeated: self.repeated } + } +} + +/// A TIFF directory whose out-of-line values sit at `offset - base` in `data`: the raw IFD in the file (`base` 0), or +/// the decrypted `SR2SubIFD`, whose offsets are file offsets into the block itself. +struct Directory<'a> { + /// From the entry count on. + entries: &'a [u8], + data: &'a [u8], + base: usize, + order: ByteOrder, + /// BigTIFF layout: 8-byte entry count, 20-byte entries with an 8-byte count and value field. + big: bool, +} + +impl<'a> Directory<'a> { + /// The first entry of `tag`, and whether another follows. Only the first counts, as in the TIFF reader and + /// `sr2.rs`. Reads at most the entries the data holds, and at most [`MAX_ENTRIES`]. + fn entry(&self, tag: u16) -> (Option<&'a [u8]>, bool) { + let o = self.order; + let (n, head, size) = if self.big { (o.read_u64(self.entries, 0), 8, 20) } else { (o.read_u16(self.entries, 0).map(u64::from), 2, 12) }; + let Some(n) = n else { return (None, false) }; + let mut found = None; + for i in 0..usize::try_from(n).unwrap_or(usize::MAX).min(MAX_ENTRIES) { + let at = i.checked_mul(size).and_then(|at| at.checked_add(head)); + let Some(e) = at.and_then(|at| self.entries.get(at..at.checked_add(size)?)) else { break }; + if o.read_u16(e, 0) == Some(tag) { + if found.is_some() { + return (found, true); + } + found = Some(e); + } + } + (found, false) + } + + /// Field type and count of entry `e`. + fn field(&self, e: &[u8]) -> (u16, u64) { + let o = self.order; + let count = if self.big { o.read_u64(e, 4) } else { o.read_u32(e, 4).map(u64::from) }; + (o.read_u16(e, 2).unwrap_or(0), count.unwrap_or(u64::MAX)) + } + + /// `len` value bytes of entry `e`: inline when they fit the value field, else at the entry's offset. + fn values(&self, e: &'a [u8], len: usize) -> Option<&'a [u8]> { + let (field, width) = if self.big { (12, 8) } else { (8, 4) }; + if len <= width { + return e.get(field..field + len); + } + let offset = if self.big { self.order.read_u64(e, field)? } else { u64::from(self.order.read_u32(e, field)?) }; + let at = usize::try_from(offset).ok()?.checked_sub(self.base)?; + self.data.get(at..at.checked_add(len)?) + } + + /// Why the TIFF reader left out entry `e`, which the directory holds: a field type it doesn't know, values outside + /// the data, or else its own limits (the entries it reads per directory, its value budget). + fn unread(&self, e: &'a [u8]) -> SonyFieldError { + let (kind, count) = self.field(e); + let Some(size) = FieldType::from_u16(kind).map(FieldType::size) else { return SonyFieldError::FieldType(kind) }; + match count.checked_mul(size as u64).and_then(|len| usize::try_from(len).ok()).and_then(|len| self.values(e, len)) { + Some(_) => SonyFieldError::PastReaderLimits, + None => SonyFieldError::OutsideData, + } + } + + /// A radial table of exactly `N` samples in entry `e`: SSHORT\[N + 1\], the first value `N`. + fn table<const N: usize>(&self, e: &'a [u8]) -> Read<[i16; N]> { + let (kind, count) = self.field(e); + if kind != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(kind)); + } + if count != (N + 1) as u64 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(bytes) = self.values(e, 2 * (N + 1)) else { return Read::Rejected(SonyFieldError::OutsideData) }; + let at = |i: usize| self.order.read_u16(bytes, 2 * i).map_or(0, |v| v as i16); + let prefix = at(0); + if usize::try_from(prefix).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(prefix)); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) + } + + /// A setting in entry `e`: SHORT\[1\], or SSHORT\[1\] when not negative. + fn setting(&self, e: &'a [u8], kind: Kind) -> Read<SonySetting> { + let (field_type, count) = self.field(e); + if field_type != SHORT && field_type != SSHORT { + return Read::Rejected(SonyFieldError::FieldType(field_type)); + } + if count != 1 { + return Read::Rejected(SonyFieldError::Count(count)); + } + let Some(value) = self.values(e, 2).and_then(|b| self.order.read_u16(b, 0)) else { + return Read::Rejected(SonyFieldError::OutsideData); + }; + if field_type == SSHORT && (value as i16) < 0 { + return Read::Rejected(SonyFieldError::NegativeSetting(value as i16)); + } + Read::Valid(SonySetting { value, meaning: meaning(kind, value) }) + } +} + +/// A table as the TIFF reader parsed it: SSHORT\[N + 1\], the first value `N`. +fn parsed_table<const N: usize>(value: &Value) -> Read<[i16; N]> { + let Value::SShort(v) = value else { return Read::Rejected(SonyFieldError::FieldType(value.field_type() as u16)) }; + if v.len() != N + 1 { + return Read::Rejected(SonyFieldError::Count(v.len() as u64)); + } + let at = |i: usize| v.get(i).copied().unwrap_or(0); + if usize::try_from(at(0)).ok() != Some(N) { + return Read::Rejected(SonyFieldError::CountPrefix(at(0))); + } + Read::Valid(std::array::from_fn(|i| at(i + 1))) +} + +/// A setting as the TIFF reader parsed it: SHORT\[1\], or SSHORT\[1\] when not negative (the documented and the stored +/// type differ). +fn parsed_setting(value: &Value, kind: Kind) -> Read<SonySetting> { + let number = match value { + Value::Short(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::SShort(v) if v.len() != 1 => return Read::Rejected(SonyFieldError::Count(v.len() as u64)), + Value::Short(v) => v.first().copied(), + Value::SShort(v) => match v.first().copied() { + Some(n) if n < 0 => return Read::Rejected(SonyFieldError::NegativeSetting(n)), + n => n.map(|n| n as u16), + }, + other => return Read::Rejected(SonyFieldError::FieldType(other.field_type() as u16)), + }; + number.map_or(Read::Rejected(SonyFieldError::Count(0)), |value| Read::Valid(SonySetting { value, meaning: meaning(kind, value) })) +} + +/// The raw IFD's copy of `tag`: the one the TIFF reader parsed, which is what the image itself uses. The directory +/// itself only adds diagnostics: whether the tag is repeated, and why an entry the reader left out was dropped. +fn raw_copy<T>(ifd: &Ifd, dir: Option<&Directory<'_>>, tag: u16, read: impl Fn(&Value) -> Read<T>) -> Found<T> { + let (entry, repeated) = dir.map_or((None, false), |d| d.entry(tag)); + let read = match (ifd.get(tag), entry.zip(dir)) { + (Some(parsed), _) => read(&parsed.value), + (None, Some((e, d))) => Read::Rejected(d.unread(e)), + (None, None) => Read::Absent, + }; + Found { read, repeated } +} + +/// The `SR2SubIFD`'s copy of `tag`, read straight from its first entry. +fn sr2_copy<'a, T>(dir: Option<&Directory<'a>>, tag: u16, read: impl Fn(&Directory<'a>, &'a [u8]) -> Read<T>) -> Found<T> { + let Some(d) = dir else { return Found { read: Read::Absent, repeated: false } }; + let (entry, repeated) = d.entry(tag); + Found { read: entry.map_or(Read::Absent, |e| read(d, e)), repeated } +} + +/// One field from its two copies: the raw IFD's when well-formed, else the `SR2SubIFD`'s when the raw IFD lacks the +/// tag. +fn combine<T: PartialEq>(raw: Found<T>, sr2: Found<T>) -> SonyField<T> { + let (raw_ifd, sr2_copy) = (raw.read.copy(), sr2.read.copy()); + let copies_agree = match (&raw.read, &sr2.read) { + (Read::Valid(a), Read::Valid(b)) => Some(a == b), + _ => None, + }; + let (value, source) = match (raw.read, sr2.read) { + (Read::Valid(v), _) => (Some(v), Some(SonySource::RawIfd)), + (Read::Absent, Read::Valid(v)) => (Some(v), Some(SonySource::Sr2SubIfd)), + _ => (None, None), + }; + SonyField { value, source, raw_ifd, sr2: sr2_copy, copies_agree, raw_ifd_repeated: raw.repeated, sr2_repeated: sr2.repeated } +} + +/// Whether `r` has the native 3:2 framing, within 0.5 %. +pub(super) fn is_3_2(r: Rect) -> bool { + r.height > 0 && (r.width as f64 / r.height as f64 - 1.5).abs() <= 0.005 +} + +/// Decode the file's record and the validated distortion warp. `raw` is the raw image IFD as the TIFF reader parsed +/// it (in a BigTIFF file when `big`), `sr2` the decrypted `SR2SubIFD`. The record is `None` when neither directory +/// holds any of the six fields. +pub(super) fn corrections( + bytes: &[u8], + order: ByteOrder, + (raw, big): (&Ifd, bool), + sr2: Option<&SubIfd>, + model: &str, + geometry: SonyLensGeometry, +) -> (Option<SonyLensCorrections>, Option<Opcode>) { + let plain = usize::try_from(raw.offset).ok().and_then(|at| Some(Directory { entries: bytes.get(at..)?, data: bytes, base: 0, order, big })); + let encrypted = sr2.map(|s| { + let (block, start) = s.block(); + Directory { entries: block, data: block, base: start, order: s.order(), big: false } + }); + let (p, s) = (plain.as_ref(), encrypted.as_ref()); + // CA: two contiguous 16-sample curves after the count + let split = |v: [i16; 32]| -> [[i16; 16]; 2] { std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) }; + let table16 = |tags: (u16, u16)| combine(raw_copy(raw, p, tags.0, parsed_table::<16>), sr2_copy(s, tags.1, |d, e| d.table::<16>(e))); + let shading_table = table16(SHADING.table); + let distortion_table = table16(DISTORTION.table); + let ca_table = combine(raw_copy(raw, p, CA.table.0, parsed_table::<32>).map(split), sr2_copy(s, CA.table.1, |d, e| d.table::<32>(e)).map(split)); + let setting = |tags: Tags, kind: Kind| { + combine(raw_copy(raw, p, tags.setting.0, |v| parsed_setting(v, kind)), sr2_copy(s, tags.setting.1, |d, e| d.setting(e, kind))) + }; + let settings = [setting(SHADING, Kind::Shading), setting(CA, Kind::Ca), setting(DISTORTION, Kind::Distortion)]; + if !shading_table.is_present() && !ca_table.is_present() && !distortion_table.is_present() && !settings.iter().any(SonyField::is_present) { + return (None, None); + } + let [shading_setting, ca_setting, distortion_setting] = settings; + // Only the validated scope reaches the conversion: the raw IFD's table, as the TIFF reader parsed it, of a Bayer + // image whose image crop and reference are native 3:2 (DefaultCrop can keep native geometry while Sony's tags + // select an aspect crop). + let validated_model = DISTORTION_MODELS.contains(&model); + let in_mode = !geometry.linear_ycbcr && geometry.native_3_2 && distortion_table.source == Some(SonySource::RawIfd); + let (w, h) = (geometry.storage_width, geometry.storage_height); + let warp = match &distortion_table.value { + Some(table) if in_mode => distortion(model, table, Rect::new(0, 0, w, h), geometry.reference), + _ => None, + }; + let distortion_status = match &distortion_table.value { + None => SonyLensStatus::NoTable, + Some(_) if warp.is_some() => SonyLensStatus::Applied, + Some(_) if !validated_model => SonyLensStatus::NotValidatedModel, + Some(_) if !in_mode => SonyLensStatus::NotValidatedMode, + Some(table) if table.iter().all(|v| *v == 0) => SonyLensStatus::Neutral, + Some(_) => SonyLensStatus::Rejected, + }; + // No independent validation of shading or CA semantics exists on any body: decoded, never applied. + let unvalidated = |present: bool| if present { SonyLensStatus::NotValidatedComponent } else { SonyLensStatus::NoTable }; + let record = SonyLensCorrections { + shading: SonyComponent { status: unvalidated(shading_table.value.is_some()), setting: shading_setting, table: shading_table }, + chromatic_aberration: SonyComponent { status: unvalidated(ca_table.value.is_some()), setting: ca_setting, table: ca_table }, + distortion: SonyComponent { setting: distortion_setting, table: distortion_table, status: distortion_status }, + sr2_read: sr2.is_some(), + geometry, + }; + (Some(record), warp) +} + +/// The validated conversion of a raw-IFD distortion table into a `WarpRectilinear`. Only independently validated +/// camera models and the 16-entry layout; unknown models and implausible or non-smooth maps are left uncorrected. +/// See docs/sony-lens-corrections.md. A camera setting of Off does not remove the lens data: the user can enable it +/// in the developer. +fn distortion(model: &str, table: &[i16; 16], active: Rect, crop: Rect) -> Option<Opcode> { // A matching table shape does not establish its scale or radial normalization on another body. - // Keep this exact: ILCE-7RM4 and ILCE-7RM4A are distinct models, not interchangeable aliases. - if model != "ILCE-7RM4A" { + if !DISTORTION_MODELS.contains(&model) { return None; } - let Value::SShort(values) = &ifd.get(DISTORTION)?.value else { return None }; - let [16, table @ ..] = values.as_slice() else { return None }; - let table: &[i16; 16] = table.try_into().ok()?; if table.iter().all(|v| *v == 0) || table.iter().any(|v| !(-4096..=4096).contains(v)) { return None; } @@ -122,22 +442,720 @@ fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { #[cfg(test)] mod tests { use super::*; - use lightcraft_tiff::Entry; + use crate::lens_metadata::SonyCopy::{Absent, Rejected, Valid}; + use crate::lens_metadata::SonyFieldError::*; + use crate::{LensMetadata, Mode, RawImage}; + use lightcraft_tiff::ByteOrder::{Big, Little}; + use lightcraft_tiff::{IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; - fn ifd(value: Value) -> Ifd { - Ifd { entries: vec![Entry { tag: DISTORTION, value, offset: 0 }], ..Default::default() } - } - fn table(values: &[i16]) -> Ifd { - ifd(Value::SShort(std::iter::once(16).chain(values.iter().copied()).collect())) - } const TELE: [i16; 16] = [-2, 1, 7, 17, 31, 48, 68, 93, 120, 152, 187, 225, 265, 309, 356, 403]; const WIDE: [i16; 16] = [14, 0, -27, -59, -106, -158, -223, -291, -367, -440, -519, -593, -667, -731, -792, -839]; const AREA: Rect = Rect { x: 0, y: 0, width: 9504, height: 6336 }; + /// The shading samples of the public ILCE-7CR samples (raw.pixls.us DSC00795/6/8). + const SHADING_T: [i16; 16] = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + const KEY: [u8; 4] = [0x11, 0x22, 0x33, 0x44]; + + /// A CA table with negative and positive samples and two different curves. + fn ca() -> [i16; 32] { + std::array::from_fn(|i| (i as i16 - 16) * 128) + } + fn ca_curves() -> [[i16; 16]; 2] { + let v = ca(); + std::array::from_fn(|c| std::array::from_fn(|i| v[16 * c + i])) + } + fn sshort(prefix: i16, samples: &[i16]) -> Value { + Value::SShort(std::iter::once(prefix).chain(samples.iter().copied()).collect()) + } + /// The six raw-IFD fields: shading 272 (Auto), CA 1 (Auto), and the distortion `setting` and `table`. + fn plain(table: &[i16; 16], setting: u16) -> Vec<(u16, Value)> { + vec![ + (SHADING.setting.0, Value::Short(vec![272])), + (SHADING.table.0, sshort(16, &SHADING_T)), + (CA.setting.0, Value::Short(vec![1])), + (CA.table.0, sshort(32, &ca())), + (DISTORTION.setting.0, Value::Short(vec![setting])), + (DISTORTION.table.0, sshort(16, table)), + ] + } + /// The same fields as `SR2SubIFD` entries `(tag, type, values)`. + fn encrypted(table: &[i16; 16], setting: u16) -> Vec<(u16, u16, Vec<u16>)> { + let t = |prefix: i16, v: &[i16]| std::iter::once(prefix).chain(v.iter().copied()).map(|x| x as u16).collect::<Vec<u16>>(); + vec![ + (SHADING.setting.1, SHORT, vec![272]), + (SHADING.table.1, SSHORT, t(16, &SHADING_T)), + (CA.setting.1, SHORT, vec![1]), + (CA.table.1, SSHORT, t(32, &ca())), + (DISTORTION.setting.1, SHORT, vec![setting]), + (DISTORTION.table.1, SSHORT, t(16, table)), + ] + } + fn set(fields: &mut Vec<(u16, Value)>, tag: u16, value: Value) { + fields.retain(|(t, _)| *t != tag); + fields.push((tag, value)); + } + fn word(order: ByteOrder, v: u32) -> Vec<u8> { + let mut out = Vec::new(); + order.put_u32(&mut out, v); + out + } + + /// A small uncompressed Sony CFA file (the raw IFD is IFD0) with the lens fields `plain` in its raw IFD and, when + /// `sr2` is set, an encrypted `SR2SubIFD` holding those entries, reached as Sony stores it: `DNGPrivateData` -> + /// `SR2Private` IFD -> block. + struct Arw { + model: &'static str, + order: ByteOrder, + bigtiff: bool, + size: (u32, u32), + sony_crop: Option<[u32; 4]>, + default_crop: Option<[u32; 4]>, + plain: Vec<(u16, Value)>, + sr2: Option<Vec<(u16, u16, Vec<u16>)>>, + /// The key stored in `SR2SubIFDKey`; the block is always encrypted with the usual one. + stored_key: [u8; 4], + /// An edit to the decrypted `SR2SubIFD` before it is encrypted. + sr2_patch: Option<Box<dyn Fn(&mut [u8])>>, + } + + impl Arw { + fn new(model: &'static str, order: ByteOrder) -> Arw { + Arw { + model, + order, + bigtiff: false, + size: (32, 24), + sony_crop: Some([2, 2, 30, 20]), + default_crop: Some([2, 2, 30, 20]), + plain: plain(&TELE, 0), + sr2: Some(encrypted(&TELE, 0)), + stored_key: KEY, + sr2_patch: None, + } + } + + fn build(&self) -> Vec<u8> { + let (w, h) = self.size; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii(self.model.into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + if let Some([x, y, cw, ch]) = self.sony_crop { + raw.set(0x74c7, Value::Long(vec![x, y])); + raw.set(0x74c8, Value::Long(vec![cw, ch])); + } + if let Some([x, y, cw, ch]) = self.default_crop { + raw.set(t::DEFAULT_CROP_ORIGIN, Value::Long(vec![x, y])); + raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![cw, ch])); + } + if self.sr2.is_some() { + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + } + for (tag, value) in &self.plain { + raw.set(*tag, value.clone()); + } + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![0; (w * h * 2) as usize]] }); + let mut file = TiffWriter::new(self.order, self.bigtiff).write(&[raw]).unwrap(); + let Some(entries) = &self.sr2 else { return file }; + let o = self.order; + let at = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + let private = file.len(); + let start = private + 2 + 3 * 12 + 4; + let refs: Vec<(u16, u16, &[u16])> = entries.iter().map(|(tag, kind, v)| (*tag, *kind, v.as_slice())).collect(); + let mut block = super::super::sr2::tests::block(start, &refs, o); + if let Some(patch) = &self.sr2_patch { + super::super::sr2::tests::encrypt(&mut block); + patch(&mut block); + super::super::sr2::tests::encrypt(&mut block); + } + o.put_u16(&mut file, 3); + for (tag, value) in [(0x7200u16, start as u32), (0x7201, block.len() as u32)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, 4); + o.put_u32(&mut file, 1); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend(self.stored_key); + o.put_u32(&mut file, 0); + file.extend(block); + file[at..at + 4].copy_from_slice(&word(o, private as u32)); + file + } + } + + /// Byte position of IFD0's entry for `tag` in a classic TIFF `file`. + fn entry_at(file: &[u8], order: ByteOrder, tag: u16) -> usize { + let ifd = order.read_u32(file, 4).unwrap() as usize; + let n = usize::from(order.read_u16(file, ifd).unwrap()); + (0..n).map(|i| ifd + 2 + 12 * i).find(|&e| order.read_u16(file, e) == Some(tag)).unwrap() + } + + /// Header and full decode of `file`, which must describe the same image; the full one. + fn decode(file: &[u8]) -> RawImage { + let header = super::super::arw::decode(file, Mode::Header).unwrap(); + let full = super::super::arw::decode(file, Mode::Full).unwrap(); + assert_eq!(header.info(), full.info()); + full + } + fn sony(img: &RawImage) -> &SonyLensCorrections { + img.lens_metadata.sony.as_ref().expect("a Sony lens record") + } + /// (has a value, source, raw-IFD copy, SR2SubIFD copy, agreement) of one field. + fn prov<T>(f: &SonyField<T>) -> (bool, Option<SonySource>, SonyCopy, SonyCopy, Option<bool>) { + (f.value.is_some(), f.source, f.raw_ifd, f.sr2, f.copies_agree) + } + /// [`prov`] of the three settings, then the three tables. + fn provenance(s: &SonyLensCorrections) -> [(bool, Option<SonySource>, SonyCopy, SonyCopy, Option<bool>); 6] { + [ + prov(&s.shading.setting), + prov(&s.chromatic_aberration.setting), + prov(&s.distortion.setting), + prov(&s.shading.table), + prov(&s.chromatic_aberration.table), + prov(&s.distortion.table), + ] + } + + #[test] + fn decodes_all_three_components_with_settings_and_provenance() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + for bigtiff in [false, true] { + for (model, warps, distortion_status) in + [("ILCE-7CR", 0, SonyLensStatus::NotValidatedModel), ("ILCE-7RM4A", 1, SonyLensStatus::Applied)] + { + let case = format!("{model} {order:?} bigtiff={bigtiff}"); + let mut arw = Arw::new(model, order); + arw.bigtiff = bigtiff; + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(img.opcodes.list3.len(), warps, "{case}"); + assert!(s.sr2_read, "{case}"); + assert_eq!(provenance(s), [(true, raw, Valid, Valid, Some(true)); 6], "{case}"); + assert_eq!(s.shading.table.value, Some(SHADING_T), "{case}"); + assert_eq!(s.chromatic_aberration.table.value, Some(ca_curves()), "{case}"); + assert_eq!(s.distortion.table.value, Some(TELE), "{case}"); + assert_eq!(s.shading.setting.value, Some(SonySetting { value: 272, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.chromatic_aberration.setting.value, Some(SonySetting { value: 1, meaning: SonySettingMeaning::Auto })); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + // readable shading and CA tables are not corrections + assert_eq!(s.shading.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.chromatic_aberration.status, SonyLensStatus::NotValidatedComponent, "{case}"); + assert_eq!(s.distortion.status, distortion_status, "{case}"); + let crop = Rect::new(2, 2, 30, 20); + let geometry = SonyLensGeometry { + storage_width: 32, + storage_height: 24, + sony_crop: Some(crop), + default_crop: Some(crop), + image_crop: crop, + reference: crop, + compression: 1, + linear_ycbcr: false, + native_3_2: true, + }; + assert_eq!(s.geometry, geometry, "{case}"); + // the applied warp is the validated conversion of the raw IFD's table, as before + let reference: Vec<Opcode> = distortion(model, &TELE, Rect::new(0, 0, 32, 24), crop).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference, "{case}"); + // DNG export keeps the warp and invents no Sony record + let dng = crate::write_dng(&img, &Default::default()).unwrap(); + let back = crate::decode(&dng).unwrap(); + assert_eq!(back.opcodes.list3, img.opcodes.list3, "{case}"); + assert_eq!(back.lens_metadata, LensMetadata::default(), "{case}"); + } + } + } + } + + #[test] + fn settings_keep_their_number_and_unknown_values_are_never_off() { + use Kind::{Ca, Distortion, Shading}; + use SonySettingMeaning::*; + for (kind, value, expected) in [ + (Shading, 256, Off), + (Shading, 257, Auto), + (Shading, 272, Auto), + (Shading, 511, Unavailable), + (Shading, 0, Unknown(0)), + (Shading, 1, Unknown(1)), + (Shading, 255, Unknown(255)), + (Shading, 258, Unknown(258)), + (Shading, 273, Unknown(273)), + (Ca, 0, Off), + (Ca, 1, Auto), + (Ca, 255, Unavailable), + (Ca, 2, Unknown(2)), + (Ca, 17, Unknown(17)), + (Ca, 256, Unknown(256)), + (Distortion, 0, Off), + (Distortion, 1, Auto), + (Distortion, 17, ForcedAuto), + (Distortion, 255, Unavailable), + (Distortion, 2, Unknown(2)), + (Distortion, 16, Unknown(16)), + (Distortion, 256, Unknown(256)), + (Distortion, u16::MAX, Unknown(u16::MAX)), + ] { + assert_eq!(meaning(kind, value), expected, "{kind:?} {value}"); + } + for order in [Little, Big] { + let read = |value: Value| { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + set(&mut arw.plain, DISTORTION.setting.0, value); + sony(&decode(&arw.build())).distortion.setting.clone() + }; + assert_eq!(read(Value::Short(vec![17])).value, Some(SonySetting { value: 17, meaning: ForcedAuto })); + assert_eq!(read(Value::Short(vec![9])).value, Some(SonySetting { value: 9, meaning: Unknown(9) })); + // the documented editor type (SSHORT) is accepted when not negative + assert_eq!(read(Value::SShort(vec![1])).value, Some(SonySetting { value: 1, meaning: Auto })); + for (value, error) in [ + (Value::SShort(vec![-1]), NegativeSetting(-1)), + (Value::SShort(vec![i16::MIN]), NegativeSetting(i16::MIN)), + (Value::Long(vec![1]), FieldType(4)), + (Value::Ascii("1".into()), FieldType(2)), + (Value::Short(vec![1, 1]), Count(2)), + (Value::Short(vec![0; 40]), Count(40)), + ] { + let f = read(value.clone()); + assert_eq!(prov(&f), (false, None, Rejected(error), Absent, None), "{value:?} {order:?}"); + } + } + } + + #[test] + fn malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy() { + let d = DISTORTION.table.0; + let cases = [ + (d, sshort(16, &[]), Count(1)), + (d, Value::SShort(vec![16, 0]), Count(2)), + (d, sshort(16, &TELE[..15]), Count(16)), + (d, Value::SShort(std::iter::once(16).chain(TELE).chain([0]).collect()), Count(18)), + (d, Value::SShort(vec![15; 17]), CountPrefix(15)), + (d, sshort(-16, &TELE), CountPrefix(-16)), + (d, sshort(0, &TELE), CountPrefix(0)), + (d, Value::Short(vec![16; 17]), FieldType(3)), + (d, Value::Long(vec![16; 17]), FieldType(4)), + (d, Value::Double(vec![f64::NAN; 17]), FieldType(12)), + (d, Value::Undefined(vec![16; 34]), FieldType(7)), + (SHADING.table.0, sshort(32, &[0; 32]), Count(33)), + (SHADING.table.0, Value::Short(std::iter::once(16).chain([0; 16]).collect()), FieldType(3)), + (CA.table.0, sshort(16, &ca()[..16]), Count(17)), + (CA.table.0, sshort(16, &ca()), CountPrefix(16)), + (CA.table.0, sshort(33, &ca()), CountPrefix(33)), + ]; + for order in [Little, Big] { + for (tag, value, error) in &cases { + let mut arw = Arw::new("ILCE-7RM4A", order); + set(&mut arw.plain, *tag, value.clone()); + let img = decode(&arw.build()); + let s = sony(&img); + let field = if *tag == SHADING.table.0 { + prov(&s.shading.table) + } else if *tag == CA.table.0 { + prov(&s.chromatic_aberration.table) + } else { + prov(&s.distortion.table) + }; + // the SR2SubIFD's good copy does not hide the corruption + assert_eq!(field, (false, None, Rejected(*error), Valid, None), "{tag:#x} {value:?} {order:?}"); + // the validated distortion needs its own raw-IFD table; a bad unrelated table doesn't remove it + assert_eq!(img.opcodes.list3.len(), usize::from(*tag != d), "{tag:#x} {value:?}"); + assert_eq!(s.distortion.status == SonyLensStatus::NoTable, *tag == d); + } + } + } + + #[test] + fn counts_and_offsets_beyond_the_data_are_reported_without_reading_them() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let e = entry_at(&good, order, DISTORTION.table.0); + let len = good.len() as u32; + for (field, value, error) in [ + // counts: the TIFF reader drops an entry whose values would pass the end of the file + (4, 0x7fff_ffff, OutsideData), + (4, u32::MAX, OutsideData), + (4, 0, Count(0)), + (4, 16, Count(16)), + // value offsets past the end, straddling it, at it + (8, u32::MAX, OutsideData), + (8, len - 10, OutsideData), + (8, len, OutsideData), + ] { + let mut file = good.clone(); + file[e + field..e + field + 4].copy_from_slice(&word(order, value)); + let img = decode(&file); + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(error), Valid, None), "{field} {value} {order:?}"); + assert!(img.opcodes.list3.is_empty()); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + assert_eq!(s.shading.table.value, Some(SHADING_T)); + } + } + } + + #[test] + fn a_table_past_the_tiff_readers_entry_limit_is_not_applied() { + // The TIFF reader that decodes the image reads 8,192 entries of a directory; with 8,200 filler entries ahead + // of them the image never sees the lens fields, so they must not become a correction (main applies none). + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.size, arw.sony_crop, arw.default_crop, arw.sr2) = ((30, 20), None, None, None); + let reference = decode(&arw.build()); + assert_eq!(reference.opcodes.list3.len(), 1, "control: the same file without filler is corrected"); + arw.plain = (0..8200u16).map(|i| (0x5000 + i, Value::Byte(vec![0]))).chain(plain(&TELE, 0)).collect(); + let img = decode(&arw.build()); + assert!(img.opcodes.list3.is_empty(), "{order:?}: a table the TIFF reader never read was applied"); + // the directory scan still finds and reports it + let s = sony(&img); + assert_eq!(prov(&s.distortion.table), (false, None, Rejected(PastReaderLimits), Absent, None)); + assert_eq!(s.distortion.status, SonyLensStatus::NoTable); + } + } + + #[test] + fn a_repeated_tag_keeps_the_copy_the_tiff_reader_keeps() { + let crop = Rect::new(2, 2, 30, 20); + let warp = |table: &[i16; 16]| -> Vec<Opcode> { distortion("ILCE-7RM4A", table, Rect::new(0, 0, 32, 24), crop).into_iter().collect() }; + for order in [Little, Big] { + // a second distortion table (WIDE) right after the first (TELE), under a placeholder tag renamed to 0x7037 + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.push((0x7040, sshort(16, &WIDE))); + let good = arw.build(); + let mut tag = Vec::new(); + order.put_u16(&mut tag, DISTORTION.table.0); + let mut twice = good.clone(); + let e = entry_at(&twice, order, 0x7040); + twice[e..e + 2].copy_from_slice(&tag); + // both valid: the first one, as the TIFF reader keeps it and main applies it + let img = decode(&twice); + assert_eq!(img.opcodes.list3, warp(&TELE), "{order:?}: both copies valid"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + // reported as a diagnostic, which changes nothing else + let f = &sony(&img).distortion.table; + assert_eq!((f.raw_ifd_repeated, f.sr2_repeated, f.raw_ifd, f.copies_agree), (true, false, Valid, Some(true))); + assert_eq!(sony(&img).distortion.status, SonyLensStatus::Applied); + assert!(!sony(&decode(&good)).distortion.table.raw_ifd_repeated); + // the first one's values outside the file: the TIFF reader drops it and keeps the second + let mut dropped = twice.clone(); + let first = entry_at(&dropped, order, DISTORTION.table.0); + dropped[first + 8..first + 12].copy_from_slice(&word(order, u32::MAX)); + let img = decode(&dropped); + assert_eq!(img.opcodes.list3, warp(&WIDE), "{order:?}: first copy unreadable"); + assert_eq!(sony(&img).distortion.table.value, Some(WIDE)); + assert!(sony(&img).distortion.table.raw_ifd_repeated); + // the first one malformed (the setting's SHORT entry renamed): the TIFF reader keeps it, nothing applies + let mut short = good.clone(); + let e = entry_at(&short, order, DISTORTION.setting.0); + short[e..e + 2].copy_from_slice(&tag); + let img = decode(&short); + assert!(img.opcodes.list3.is_empty(), "{order:?}: first copy malformed"); + assert_eq!(sony(&img).distortion.table.raw_ifd, Rejected(FieldType(SHORT))); + } + } + + #[test] + fn sr2_copies_fill_in_only_missing_raw_ifd_fields_and_are_never_applied() { + let (raw, sr2) = (Some(SonySource::RawIfd), Some(SonySource::Sr2SubIfd)); + for order in [Little, Big] { + // nothing in the raw IFD: every field from the SR2SubIFD, decoded, not applied even on the validated + // model (only the raw IFD's table was validated) + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(provenance(s), [(true, sr2, Absent, Valid, None); 6]); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(SHADING_T), Some(ca_curves()), Some(TELE)) + ); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 0, meaning: SonySettingMeaning::Off })); + assert_eq!(s.distortion.status, SonyLensStatus::NotValidatedMode); + assert!(img.opcodes.list3.is_empty()); + // only the distortion table in the raw IFD: that one is used and applied, the rest comes from the SR2SubIFD + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.retain(|(tag, _)| *tag == DISTORTION.table.0); + let img = decode(&arw.build()); + let s = sony(&img); + let p = provenance(s); + assert_eq!(p[5], (true, raw, Valid, Valid, Some(true))); + assert!(p[..5].iter().all(|f| *f == (true, sr2, Absent, Valid, None)), "{p:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::Applied, 1)); + // the raw IFD's tag numbers inside the SR2SubIFD are not its tables + let mut arw = Arw::new("ILCE-7CR", order); + arw.plain.clear(); + let plain_tags = [SHADING.setting.0, SHADING.table.0, CA.setting.0, CA.table.0, DISTORTION.setting.0, DISTORTION.table.0]; + arw.sr2 = Some(encrypted(&TELE, 0).into_iter().zip(plain_tags).map(|((_, kind, v), tag)| (tag, kind, v)).collect()); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + } + } + + #[test] + fn disagreeing_copies_are_reported_and_the_raw_ifd_wins() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sr2 = Some(encrypted(&WIDE, 17)); + let img = decode(&arw.build()); + let s = sony(&img); + let raw = Some(SonySource::RawIfd); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.distortion.setting), (true, raw, Valid, Valid, Some(false))); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + assert_eq!(s.distortion.table.value, Some(TELE)); + assert_eq!(s.distortion.setting.value.map(|v| v.value), Some(0)); + // the validated conversion is unchanged: the raw IFD's table + let reference: Vec<Opcode> = distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 32, 24), Rect::new(2, 2, 30, 20)).into_iter().collect(); + assert_eq!(img.opcodes.list3, reference); + } + } + + #[test] + fn unreadable_or_hostile_sr2_subifds_give_no_copies() { + let raw = Some(SonySource::RawIfd); + for order in [Little, Big] { + // stored with another key: the block decrypts to noise, no copy is found, nothing falls back on it + let mut arw = Arw::new("ILCE-7CR", order); + arw.stored_key = [1, 2, 3, 4]; + let img = decode(&arw.build()); + let s = sony(&img); + assert!(provenance(s).iter().all(|&(value, source, plain, sr2, agree)| value + && source == raw + && plain == Valid + && sr2 != Valid + && agree.is_none())); + arw.plain.clear(); + let img = decode(&arw.build()); + assert!(img.lens_metadata.sony.as_ref().is_none_or(|s| provenance(s).iter().all(|f| !f.0 && f.3 != Valid))); + // no SR2SubIFD; DNGPrivateData pointing past the file or into the image + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2 = None; + let s = sony(&decode(&arw.build())).clone(); + assert!(!s.sr2_read); + assert_eq!(provenance(&s), [(true, raw, Valid, Absent, None); 6]); + let good = Arw::new("ILCE-7CR", order).build(); + let at = Tiff::parse(&good).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + for pointer in [u32::MAX, good.len() as u32, 8] { + let mut file = good.clone(); + file[at..at + 4].copy_from_slice(&word(order, pointer)); + let img = decode(&file); + assert!(sony(&img).distortion.table.sr2 != Valid, "{pointer}"); + assert_eq!(sony(&img).distortion.table.value, Some(TELE)); + } + // hostile entries in a well-encrypted block (the distortion table is its sixth entry): another type, huge + // count, a value offset before the block or past it + let sixth = 2 + 12 * 5; + let put = move |at: usize, bytes: Vec<u8>| move |b: &mut [u8]| b[at..at + bytes.len()].copy_from_slice(&bytes); + let mut short = Vec::new(); + order.put_u16(&mut short, SHORT); + for (patch, error) in [ + (Box::new(put(sixth + 2, short.clone())) as Box<dyn Fn(&mut [u8])>, FieldType(SHORT)), + (Box::new(put(sixth + 4, word(order, u32::MAX))), Count(u64::from(u32::MAX))), + (Box::new(put(sixth + 4, word(order, 1))), Count(1)), + (Box::new(put(sixth + 8, word(order, 0))), OutsideData), + (Box::new(put(sixth + 8, word(order, u32::MAX))), OutsideData), + ] { + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(patch); + let s = sony(&decode(&arw.build())).clone(); + assert_eq!(prov(&s.distortion.table), (true, raw, Valid, Rejected(error), None), "{error:?}"); + assert_eq!(prov(&s.shading.table), (true, raw, Valid, Valid, Some(true))); + } + // an entry count past the end of the block: the scan stops where the block does + let mut count = Vec::new(); + order.put_u16(&mut count, u16::MAX); + let mut arw = Arw::new("ILCE-7CR", order); + arw.sr2_patch = Some(Box::new(put(0, count))); + assert_eq!(provenance(sony(&decode(&arw.build()))), [(true, raw, Valid, Valid, Some(true)); 6]); + } + } + + #[test] + fn framing_and_coding_evidence_gate_the_validated_distortion() { + use SonyLensStatus::*; + let r = Rect::new; + for order in [Little, Big] { + let run = |arw: &Arw| { + let img = decode(&arw.build()); + let s = sony(&img).clone(); + (s, img) + }; + // Sony's crop at x = 0 and the default crop at x = 2 (the A7R IVA's 0 versus 32): the image keeps Sony's + // crop, the table is centred on and normalised to the default crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.sony_crop = Some([0, 2, 30, 20]); + let (s, img) = run(&arw); + let g = &s.geometry; + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(r(0, 2, 30, 20)), Some(r(2, 2, 30, 20)), r(0, 2, 30, 20), r(2, 2, 30, 20)) + ); + assert_eq!((img.crop, s.distortion.status), (r(0, 2, 30, 20), Applied)); + let Some(Opcode::WarpRectilinear { center, .. }) = img.opcodes.list3.first() else { panic!("warp") }; + assert!((center[0] - 16.5 / 31.0).abs() < 1e-12 && (center[1] - 11.5 / 23.0).abs() < 1e-12, "{center:?}"); + // agreeing crops (the A7CR's case), in a frame padded beyond them (lossless L): the storage is recorded, + // the reference stays the crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.size = (40, 28); + let (s, _) = run(&arw); + assert_eq!((s.geometry.storage_width, s.geometry.storage_height, s.geometry.reference), (40, 28, r(2, 2, 30, 20))); + assert_eq!(s.distortion.status, Applied); + // without a default crop the reference is the image crop + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.default_crop = None; + let (s, _) = run(&arw); + assert_eq!((s.geometry.default_crop, s.geometry.reference, s.distortion.status), (None, r(2, 2, 30, 20), Applied)); + // non-3:2 framing: an aspect crop in Sony's tags, in the default crop, or a 4:3 frame without crop tags + for (sony_crop, default_crop) in + [(Some([2, 2, 30, 16]), Some([2, 2, 30, 20])), (Some([2, 2, 30, 20]), Some([2, 2, 30, 16])), (None, None)] + { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (sony_crop, default_crop); + let (s, img) = run(&arw); + assert!(!s.geometry.native_3_2, "{sony_crop:?} {default_crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (NotValidatedMode, 0), "{sony_crop:?} {default_crop:?}"); + } + // neutral, out-of-range and folding tables in the validated scope; models outside it + for (model, table, status) in [ + ("ILCE-7RM4A", [0; 16], Neutral), + ("ILCE-7RM4A", [i16::MIN; 16], Rejected), + ("ILCE-7RM4A", [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], Rejected), + ("ILCE-7RM4", TELE, NotValidatedModel), + ("ILCE-7CR", [0; 16], NotValidatedModel), + ("", TELE, NotValidatedModel), + ] { + let mut arw = Arw::new(model, order); + arw.plain = plain(&table, 1); + let (s, img) = run(&arw); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (status, 0), "{model} {table:?}"); + assert_eq!(s.distortion.table.value, Some(table)); + } + } + } + + #[test] + fn out_of_range_crop_tags_are_recorded_and_never_applied() { + for order in [Little, Big] { + // past the frame, enormous, empty, partly outside + for crop in [[99999, 0, 30, 20], [u32::MAX; 4], [2, 2, 0, 0], [30, 2, 30, 20]] { + let mut arw = Arw::new("ILCE-7RM4A", order); + (arw.sony_crop, arw.default_crop) = (Some(crop), Some(crop)); + let img = decode(&arw.build()); + let s = sony(&img); + let [x, y, w, h] = crop.map(|v| v as usize); + assert_eq!((s.geometry.sony_crop, s.geometry.default_crop), (Some(Rect::new(x, y, w, h)), Some(Rect::new(x, y, w, h))), "{crop:?}"); + assert!(!s.geometry.native_3_2, "{crop:?}"); + assert_eq!((s.distortion.status, img.opcodes.list3.len()), (SonyLensStatus::NotValidatedMode, 0), "{crop:?}"); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + } + + #[test] + fn exif_orientation_does_not_change_the_record() { + // the tables and the warp are in sensor coordinates; the engine orients the warp (`engine::files::embedded_lens`) + let reference = decode(&Arw::new("ILCE-7RM4A", Little).build()); + for orientation in 1..=8u16 { + let mut arw = Arw::new("ILCE-7RM4A", Little); + arw.plain.push((t::ORIENTATION, Value::Short(vec![orientation]))); + let img = decode(&arw.build()); + assert_eq!(img.orientation, crate::Orientation::from_exif(orientation)); + assert_eq!((&img.lens_metadata, &img.opcodes), (&reference.lens_metadata, &reference.opcodes), "{orientation}"); + } + } + + #[test] + fn linear_ycbcr_files_are_decoded_but_not_corrected() { + // a downsized lossless (M/S) coding, as in arw.rs's test, from the validated model with 3:2 crop tags + for order in [Little, Big] { + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7RM4A".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![15, 15, 15])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::YCBCR])); + raw.set(t::COMPRESSION, Value::Short(vec![7])); + raw.set(0x7010, Value::Short(vec![0; 4])); + raw.set(0x74c7, Value::Long(vec![0, 0])); + raw.set(0x74c8, Value::Long(vec![3, 2])); + for (tag, value) in plain(&TELE, 17) { + raw.set(tag, value); + } + raw.set_image(ImageData::Tiles { tile_width: 4, tile_height: 4, tiles: vec![crate::ljpeg::tests::fixture_420()] }); + let img = decode(&TiffWriter::new(order, false).write(&[raw]).unwrap()); + let s = sony(&img); + assert!(s.geometry.linear_ycbcr && s.geometry.native_3_2); + assert_eq!((s.geometry.compression, s.distortion.status, img.opcodes.list3.len()), (7, SonyLensStatus::NotValidatedMode, 0)); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 17, meaning: SonySettingMeaning::ForcedAuto })); + assert_eq!(s.distortion.table.value, Some(TELE)); + } + } + + #[test] + fn files_without_lens_fields_have_no_record() { + for order in [Little, Big] { + let mut arw = Arw::new("ILCE-7RM4A", order); + arw.plain.clear(); + arw.sr2 = None; + let img = decode(&arw.build()); + assert_eq!((img.lens_metadata.clone(), img.opcodes.list3.len()), (LensMetadata::default(), 0)); + // an SR2SubIFD holding other tags only + arw.sr2 = Some(vec![(0x7310, SHORT, vec![512; 4])]); + assert_eq!(decode(&arw.build()).lens_metadata, LensMetadata::default()); + // one field anywhere is a record + arw.sr2 = Some(vec![(DISTORTION.setting.1, SHORT, vec![255])]); + let img = decode(&arw.build()); + let s = sony(&img); + assert_eq!(s.distortion.setting.value, Some(SonySetting { value: 255, meaning: SonySettingMeaning::Unavailable })); + assert_eq!( + (s.distortion.status, s.shading.status, s.chromatic_aberration.status), + (SonyLensStatus::NoTable, SonyLensStatus::NoTable, SonyLensStatus::NoTable) + ); + } + } + + #[test] + fn corrupt_files_never_panic() { + for order in [Little, Big] { + let good = Arw::new("ILCE-7RM4A", order).build(); + let tiff = Tiff::parse(&good).unwrap(); + let strip = tiff.ifds[0].image().unwrap().chunks(good.len() as u64)[0]; + let image = strip.offset as usize..(strip.offset + strip.len) as usize; + let check = |file: &[u8]| { + let header = super::super::arw::decode(file, Mode::Header); + let full = super::super::arw::decode(file, Mode::Full); + if let (Ok(h), Ok(f)) = (header, full) { + assert_eq!((h.lens_metadata, h.opcodes), (f.lens_metadata, f.opcodes)); + } + }; + for len in 0..good.len() { + check(&good[..len]); + } + for at in (0..good.len()).filter(|at| !image.contains(at)) { + for v in [0, 0xff, good[at] ^ 0x80, good[at] ^ 0x01] { + let mut file = good.clone(); + file[at] = v; + check(&file); + } + } + } + } #[test] fn matches_independent_sony_reference_geometry() { // On/off feature correspondences from Imaging Edge 4.1 at 600 mm, independently fitted. - let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &table(&TELE), AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes, center } = distortion("ILCE-7RM4A", &TELE, AREA, AREA).unwrap() else { panic!("warp") }; assert_eq!(center, [0.5, 0.5]); let reference = [0.9760159044, 0.0254891644, -0.0005984764, -0.0010120638]; for i in 0..=100 { @@ -151,8 +1169,8 @@ mod tests { fn barrel_framing_and_offset_crop_keep_the_same_geometry() { let crop = Rect::new(32, 20, 9504, 6336); let active = Rect::new(0, 0, 9600, 6376); - let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &table(&WIDE), AREA, AREA).unwrap() else { panic!("warp") }; - let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &table(&WIDE), active, crop).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: a, .. } = distortion("ILCE-7RM4A", &WIDE, AREA, AREA).unwrap() else { panic!("warp") }; + let Opcode::WarpRectilinear { planes: b, center } = distortion("ILCE-7RM4A", &WIDE, active, crop).unwrap() else { panic!("warp") }; assert!(a[0][0] > 1.02 && a[0][0] < 1.03, "barrel correction expands the middle"); assert!((center[0] * 9599.0 - 4783.5).abs() < 1e-10); assert!((center[1] * 6375.0 - 3187.5).abs() < 1e-10); @@ -162,37 +1180,28 @@ mod tests { } } + /// The conversion's own refusals; missing and malformed layouts are refused by the decoder before it (see + /// `malformed_raw_ifd_tables_are_refused_and_not_replaced_by_the_sr2_copy`). #[test] - fn refuses_missing_unknown_malformed_or_unusable_tables() { - for bad in [ - Ifd::default(), - table(&[]), - table(&TELE[..15]), - table(&[0; 16]), - table(&[i16::MIN; 16]), - table(&[0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), - ifd(Value::SShort(vec![15; 17])), - ifd(Value::Short(vec![16; 17])), - ifd(Value::Double(vec![f64::NAN; 17])), - ] { + fn refuses_unusable_tables() { + for bad in [[0; 16], [i16::MIN; 16], [0, 4096, -4096, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]] { assert!(distortion("ILCE-7RM4A", &bad, AREA, AREA).is_none()); } - assert!(distortion("ILCE-7RM4A", &table(&TELE), Rect::new(0, 0, 1, 1), AREA).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(0, 0, 9504, 5346)).is_none()); - assert!(distortion("ILCE-7RM4A", &table(&TELE), AREA, Rect::new(99999, 0, 10, 10)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, Rect::new(0, 0, 1, 1), AREA).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(0, 0, 9504, 5346)).is_none()); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, Rect::new(99999, 0, 10, 10)).is_none()); } + #[test] fn refuses_unvalidated_models_even_with_a_valid_table() { - let valid = table(&TELE); - assert!(distortion("ILCE-7RM4A", &valid, AREA, AREA).is_some()); - for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown"] { - assert!(distortion(model, &valid, AREA, AREA).is_none(), "{model}"); + assert!(distortion("ILCE-7RM4A", &TELE, AREA, AREA).is_some()); + for model in ["", "ILCE-7M3", "ILCE-7M4", "ILCE-7RM4", "ILCE-7RM5", "ILCE-9M2", "DSC-RX100M3", "ILCE-7RM4A unknown", "ILCE-7CR"] { + assert!(distortion(model, &TELE, AREA, AREA).is_none(), "{model}"); } } #[test] fn header_and_full_decode_preserve_correction_through_dng() { - use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, tags as t}; for (model, expected) in [("ILCE-7RM4A", 1), ("ILCE-7RM4", 0), ("ILCE-7M3", 0), ("", 0)] { for (height, expected) in [(20, expected), (16, 0)] { for order in [ByteOrder::Little, ByteOrder::Big] { @@ -211,7 +1220,7 @@ mod tests { raw.set(t::DEFAULT_CROP_SIZE, Value::Long(vec![30, 20])); // An aspect crop must stay excluded even when DefaultCrop still describes 3:2. raw.set(0x74c8, Value::Long(vec![30, height])); - raw.set(DISTORTION, Value::SShort(std::iter::once(16).chain(TELE).collect())); + raw.set(DISTORTION.table.0, Value::SShort(std::iter::once(16).chain(TELE).collect())); // Zero pixels have the same byte representation in both orders. raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); let file = TiffWriter { order, ..Default::default() }.write(&[raw]).unwrap(); diff --git a/crates/raw/src/vendor/arw_lens_rm6.rs b/crates/raw/src/vendor/arw_lens_rm6.rs new file mode 100644 index 000000000..cc3a23e61 --- /dev/null +++ b/crates/raw/src/vendor/arw_lens_rm6.rs @@ -0,0 +1,538 @@ +//! Sony ARW lens corrections from the tables in the raw IFD, as the equivalent DNG `OpcodeList3` opcodes, so they +//! reach the same "profile corrections" (`optics.lens_profile`) as a DNG's embedded warp. +//! +//! These are the ILCE-7RM6 conventions, measured against the camera JPEG. [`super::arw_lens`] holds the ILCE-7RM4A +//! conventions, measured against Sony's Imaging Edge exports (unit 16384, the table indexed at the output radius). +//! Each is selected by camera model; no single unit fits every lens under either. See both docs: +//! docs/sony-ilce7rm6-lens-corrections.md (→ Two conventions) and docs/sony-lens-corrections.md. +//! +//! Sony records three count-prefixed signed 16-bit tables in the raw IFD, each with an in-camera on/off tag (ExifTool +//! Sony tag names): distortion [`DISTORTION_PARAMS`] (flag [`DISTORTION_FLAG`]), vignetting [`VIGNETTING_PARAMS`] +//! ([`VIGNETTING_FLAG`]) and lateral chromatic aberration [`CA_PARAMS`] ([`CA_FLAG`]). The first value is the knot +//! count: 16 / 16 / 32 in full-frame files of the ILCE-7RM6, 11 / 11 / 22 in its APS-C crop mode, the rest padding; +//! the CA table holds red's knots, then blue's. +//! +//! Their meaning was measured by registering blocks of the camera JPEG in each file on LightCraft's uncorrected render +//! (and raw data, for CA) over 54 ILCE-7RM6 photos with four lenses, 41 of them shot with in-camera distortion +//! correction on; nothing else (clean room). The conventions, with their worst residuals at a 1440 px long edge: +//! - knot `i` sits at sensor radius `i · s` from the default-crop centre, `s` = the body's full-frame default-crop +//! half diagonal / 15 (the same pixel spacing in APS-C mode: the knots are fixed on the sensor); linear in between, +//! the last segment extended beyond the last knot. +//! - distortion: a corrected radius `r` (px) reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive: +//! pincushion), and the camera then zooms (output radius = `z` · corrected radius) so the output fills the default +//! crop and reads only inside it: the largest `S(ρ/z)/ρ` over the crop border radii `ρ` is 1. 0.596 px over the 34 +//! measurable corrected photos (budget 0.75 px; corners and edge midpoints 0.570 px). +//! - `WarpRectilinear`'s 4-term odd polynomial fits that map within 2.35·10⁻⁴ of the DNG radius (0.220 px) on every +//! photo, at its 65 fit samples. A table it fits worse than 0.25 px at the samples the crop shows is refused (the +//! photo stays uncorrected rather than wrongly warped). Between samples the error is somewhat larger: 0.071 against +//! 0.049 px for the full-frame 16–35 mm example in the tests. +//! - vignetting: read but never applied. The camera JPEGs keep the uncorrected render's radial falloff (median +//! corner/centre ratio 0.996 over 39 photos) and no radial gain model fits the tables. +//! - lateral CA: red's (blue's) source radius is `g · (1 + c(g) / 3.36·10⁶)` with `g` the green source radius and `c` +//! the red (blue) half on the same knots. The unit is **provisional**: the measurement is borderline (0.399 px +//! worst against a 0.3 px budget; per-photo best units scatter from 1.5·10⁶ to 7.2·10⁶, about ±30 % scene bias), +//! but it cuts the rms raw-CFA CA over 46 photos from 0.079 to 0.052 px. The table is parsed and [`warp_opcode`] +//! converts it to the warp's R and B planes, but they are **not emitted by default**: [`lens_opcodes`] passes +//! `lateral_ca: false`, because applied after demosaic they made the render's CA worse (numbers there). To turn +//! them on in code, pass `true` there. When on, a CA value beyond [`CA_MAX_RELATIVE`] or a colour plane that fails +//! the fit gate above drops both colour planes, and G alone is applied to all three channels. +//! +//! The correction is produced whatever the in-camera flag says. Only bodies listed in [`FULL_FRAME_CROP`] get it: the +//! unit and the knot spacing are verified on the ILCE-7RM6 alone. Another body is added by repeating the measurement +//! on its photos shot with distortion correction on and listing its full-frame default crop. The method and the +//! numbers: docs/sony-ilce7rm6-lens-corrections.md. + +use super::fit_radial_warp; +use crate::opcodes::max_radius; +use crate::{Opcode, Rect}; +use lightcraft_tiff::Ifd; +use std::ops::RangeInclusive; + +pub(crate) const DISTORTION_PARAMS: u16 = 0x7037; +pub(crate) const VIGNETTING_PARAMS: u16 = 0x7032; +pub(crate) const CA_PARAMS: u16 = 0x7035; +pub(crate) const DISTORTION_FLAG: u16 = 0x7036; +pub(crate) const VIGNETTING_FLAG: u16 = 0x7031; +pub(crate) const CA_FLAG: u16 = 0x7034; + +/// The bodies whose tables are verified (Exif `Model`), with their full-frame default crop (width, height) in px. +const FULL_FRAME_CROP: &[(&str, (f64, f64))] = &[("ILCE-7RM6", (9984.0, 6656.0))]; +/// The distortion table's value of 1 (relative radius); a global least-squares fit over the 34 photos gives 15000.7. +const DISTORTION_UNIT: f64 = 15000.0; +/// The CA table's value of 1 (relative to the green source radius). Provisional: the median best unit of the 20 +/// cleanest photos; per-photo units scatter from 1.5·10⁶ to 7.2·10⁶ (the measurement's scene bias is about ±30 %). +const CA_UNIT: f64 = 3.36e6; +/// Largest plausible CA value relative to the green source radius (|c| / [`CA_UNIT`]): about 12 px at the full-frame +/// corner; the largest value in the 54 measured photos' tables is 1724 (FE 24–70 mm), 1724 / 3.36·10⁶ ≈ 5.1·10⁻⁴. +/// A table beyond it is refused. +const CA_MAX_RELATIVE: f64 = 2e-3; +/// Crop border radii scanned by the frame-filling zoom. +const ZOOM_SCAN: usize = 4096; +/// Largest polynomial fit error accepted at the fit samples the crop shows, px at a 1440 px long edge. +const FIT_ERROR_PX_AT_1440: f64 = 0.25; + +/// A file's lens tables: the first `count` values of each (CA: red, blue); `None` when absent or malformed. +/// `vignetting` and `flags` are parsed for the tests and the docs only: don't build an opcode from them (the camera +/// applies no vignetting correction to its JPEGs, and the flags don't decide anything). +#[derive(Debug)] +// vignetting and the flags never become an opcode +#[cfg_attr(not(test), allow(dead_code))] +pub(crate) struct LensTables { + pub distortion: Option<Vec<f64>>, + pub vignetting: Option<Vec<f64>>, + pub ca: Option<(Vec<f64>, Vec<f64>)>, + /// In-camera on/off of distortion, vignetting and CA correction, as recorded. + pub flags: [Option<i64>; 3], +} + +/// The values of a count-prefixed integer table whose count is in `counts` and fits the array. +fn table(raw: &Ifd, tag: u16, counts: RangeInclusive<usize>) -> Option<Vec<f64>> { + let v = raw.value(tag)?; + let count = usize::try_from(v.get_i64(0)?).ok().filter(|n| counts.contains(n) && *n < v.count())?; + (1..=count).map(|i| v.get_i64(i).map(|x| x as f64)).collect() +} + +/// The lens tables of the raw IFD `raw`. +pub(crate) fn read_tables(raw: &Ifd) -> LensTables { + let ca = table(raw, CA_PARAMS, 4..=32).filter(|v| v.len().is_multiple_of(2)).map(|v| { + let (red, blue) = v.split_at(v.len() / 2); + (red.to_vec(), blue.to_vec()) + }); + LensTables { + distortion: table(raw, DISTORTION_PARAMS, 2..=16), + vignetting: table(raw, VIGNETTING_PARAMS, 2..=16), + ca, + flags: [raw.i64(DISTORTION_FLAG), raw.i64(VIGNETTING_FLAG), raw.i64(CA_FLAG)], + } +} + +/// The knot spacing (px) of a verified body: its full-frame default-crop half diagonal / 15. +fn knot_spacing(model: &str) -> Option<f64> { + let model = model.trim(); + FULL_FRAME_CROP.iter().find(|(m, _)| *m == model).map(|&(_, (w, h))| w.hypot(h) / 30.0) +} + +/// The knot table at radius `r` px: knot `i` at `i · spacing`, linear in between, the last segment extended beyond +/// the last knot. NaN with fewer than two knots. +fn interpolate(knots: &[f64], spacing: f64, r: f64) -> f64 { + let t = r / spacing; + // `as` saturates, so a huge `t` lands on the last segment; NaN fails `t > 0` + let i = if t > 0.0 { (t as usize).min(knots.len().saturating_sub(2)) } else { 0 }; + match (knots.get(i), knots.get(i + 1)) { + (Some(a), Some(b)) => a + (b - a) * (t - i as f64), + _ => f64::NAN, + } +} + +/// The camera's frame-filling zoom `z` for the source radius map `source` and a crop of half size `x × y`: the +/// largest `source(ρ/z)/ρ` over the border radii `ρ` in [min(x, y), hypot(x, y)] is 1, so the output crop reads +/// only inside the source crop and touches it (at the corner for pincushion, the short-edge midpoint for barrel). +/// Bisection over [0.5, 2]; `None` when that range holds no solution or a ratio is not finite. +fn fill_zoom(source: &dyn Fn(f64) -> f64, x: f64, y: f64) -> Option<f64> { + let (near, far) = (x.min(y), x.hypot(y)); + let overshoots = |z: f64| -> Option<bool> { + let mut worst = f64::NEG_INFINITY; + for k in 0..=ZOOM_SCAN { + let rb = near + k as f64 * (far - near) / ZOOM_SCAN as f64; + let ratio = source(rb / z) / rb; + if !ratio.is_finite() { + return None; + } + worst = worst.max(ratio); + } + Some(worst > 1.0) + }; + let (mut lo, mut hi) = (0.5, 2.0); + if !overshoots(lo)? || overshoots(hi)? { + return None; + } + for _ in 0..100 { + let z = 0.5 * (lo + hi); + if overshoots(z)? { lo = z } else { hi = z } + } + Some(0.5 * (lo + hi)) +} + +/// The distortion and CA tables as a `WarpRectilinear` relative to the active area `active` (centred on the default +/// crop `crop`, as the camera corrects), with knots `knot_spacing` px apart: planes R, G, B with `lateral_ca`, or G +/// alone (applied to all three) without it, or when the CA table is absent, implausible or a colour plane's fit +/// fails. `None` when the distortion table is absent or +/// all zero (CA alone is not applied: it is relative to green), the zoom or the G fit fails. A fit fails when it is +/// worse than 0.25 px at a 1440 px long edge at one of the fit samples within the crop (a padded active area, as in +/// lossless files, reaches radii that are never shown). +pub(crate) fn warp_opcode(t: &LensTables, knot_spacing: f64, active: Rect, crop: Rect, lateral_ca: bool) -> Option<Opcode> { + let v = t.distortion.as_deref().filter(|v| v.iter().any(|&x| x != 0.0))?; + let (aw, ah) = (active.width as f64, active.height as f64); + if aw < 2.0 || ah < 2.0 || crop.width == 0 || crop.height == 0 { + return None; + } + let (cw, ch) = (crop.width as f64, crop.height as f64); + let source = |r: f64| r * (1.0 + interpolate(v, knot_spacing, r) / DISTORTION_UNIT); + let z = fill_zoom(&source, cw / 2.0, ch / 2.0)?; + // DNG's normalisation: pixel-index units, radius 1 = the farthest active-area corner + let (cx, cy) = (crop.x as f64 + (cw - 1.0) / 2.0, crop.y as f64 + (ch - 1.0) / 2.0); + let m = max_radius(cx, cy, aw - 1.0, ah - 1.0); + let max_fit_error = FIT_ERROR_PX_AT_1440 * (cw.max(ch) / 1440.0) / m; + let shown = (cw / 2.0).hypot(ch / 2.0) / m; + // output radius (px) → green source radius + let green = |r: f64| source(r / z); + let plane = |map: &dyn Fn(f64) -> f64| { + let [k0, k1, k2, k3] = fit_radial_warp(&|u| map(u * m) / m, max_fit_error, shown)?; + Some([k0, k1, k2, k3, 0.0, 0.0]) + }; + let g = plane(&green)?; + // red and blue: relative to the green source radius, at that radius + let colour = |c: &[f64]| { + plane(&|r| { + let s = green(r); + s * (1.0 + interpolate(c, knot_spacing, s) / CA_UNIT) + }) + }; + // a NaN fails the comparison: refused too + let plausible = |c: &[f64]| c.iter().all(|v| v.abs() <= CA_MAX_RELATIVE * CA_UNIT); + let rgb = t.ca.as_ref().filter(|(red, blue)| lateral_ca && plausible(red) && plausible(blue)); + let rgb = rgb.and_then(|(red, blue)| Some(vec![colour(red)?, g, colour(blue)?])); + Some(Opcode::WarpRectilinear { planes: rgb.unwrap_or_else(|| vec![g]), center: [cx / (aw - 1.0), cy / (ah - 1.0)] }) +} + +/// The `OpcodeList3` lens corrections of a Sony raw IFD for the body `model` (Exif `Model`); empty for a body whose +/// tables are not verified. +/// +/// Distortion only: the lateral CA planes are off (`lateral_ca: false`). Our demosaic already hides about 70 % of the +/// lens CA (the render shows 0.29 × the CA measured on the raw CFA), while the warp, which runs after demosaic, moves +/// R and B by 0.84 × the table. Over the 43 verified photos with enough corner detail, the render's corner R/G and B/G +/// CA went from rms 0.032 / max 0.125 px (off) to 0.052 / 0.219 px (on) at a 1440 px long edge; only the 16–35 mm +/// improved. Follow-up: warp the CFA colour planes before demosaic, then turn the planes on here. +pub(crate) fn lens_opcodes(raw: &Ifd, model: &str, active: Rect, crop: Rect) -> Vec<Opcode> { + let Some(spacing) = knot_spacing(model) else { return Vec::new() }; + warp_opcode(&read_tables(raw), spacing, active, crop, false).into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{Entry, Value}; + + /// The FF 16–35 mm example (corpus `arw-sony-ilce7rm6-ff-land-lossless.arw`, docs/sony-ilce7rm6-lens-corrections.md). + const FF_DISTORTION: [i16; 17] = [16, 0, 2, 7, 16, 28, 43, 61, 83, 109, 139, 175, 217, 267, 325, 395, 479]; + const FF_VIGNETTING: [i16; 17] = [16, 0, 80, 336, 672, 1120, 1648, 2240, 2880, 3600, 4336, 5104, 5872, 6640, 7392, 8160, 8976]; + const FF_CA: [i16; 33] = [ + 32, 242, 234, 224, 212, 196, 176, 154, 132, 108, 86, 66, 46, 30, 16, 8, 4, -276, -258, -238, -212, -184, -148, -114, -74, -40, -6, 20, 42, + 56, 60, 54, 32, + ]; + /// The CA table of corpus `arw6-sony-ilce7rm6-ff-land-hq.arw` (the same scene; same distortion table). + const FF_HQ_CA: [i16; 33] = [ + 32, 242, 234, 222, 208, 192, 172, 152, 128, 106, 82, 62, 42, 26, 12, 4, 2, -270, -254, -234, -208, -180, -146, -112, -72, -38, -4, 22, 44, + 58, 64, 56, 36, + ]; + /// The APS-C example (corpus `arw6-sony-ilce7rm6-apsc-land-hq.arw`): counts 11 / 11 / 22, then padding. + const APSC_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -512, -583, -583, -583, -583, -583, -583]; + const APSC_VIGNETTING: [i16; 17] = [11, 0, 48, 208, 496, 880, 1360, 1952, 2624, 3568, 4832, 6176, 0, 0, 0, 0, 0]; + const APSC_CA: [i16; 33] = [ + 22, 790, 764, 734, 702, 666, 624, 582, 544, 510, 484, 470, -432, -404, -372, -330, -286, -230, -174, -114, -58, -6, 34, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// The APS-C lossless corpus file `arw-sony-ilce7rm6-apsc-land-lossless.arw` (same scene; its tables differ slightly). + const APSC_LOSSLESS_DISTORTION: [i16; 17] = [11, 0, -10, -36, -78, -134, -202, -277, -356, -435, -511, -583, -583, -583, -583, -583, -583]; + const APSC_LOSSLESS_CA: [i16; 33] = [ + 22, 792, 766, 738, 706, 670, 630, 588, 548, 514, 488, 476, -436, -408, -376, -334, -290, -234, -178, -118, -62, -12, 30, 0, 0, 0, 0, 0, 0, 0, + 0, 0, 0, + ]; + /// ARW6 geometry (the decoder's raw image size and default crop). + const FF_ACTIVE: Rect = Rect { x: 0, y: 0, width: 10016, height: 6672 }; + const FF_CROP: Rect = Rect { x: 12, y: 8, width: 9984, height: 6656 }; + const APSC_ACTIVE: Rect = Rect { x: 0, y: 0, width: 6592, height: 4372 }; + const APSC_CROP: Rect = Rect { x: 16, y: 10, width: 6528, height: 4352 }; + /// The G planes of the examples on their ARW6 geometry, and the R and B planes with each file's CA table + /// (docs/sony-ilce7rm6-lens-corrections.md). + const FF_G: [f64; 4] = [0.9714495823, 0.022508032, 0.0006090085237, 0.005683284548]; + const FF_HQ_R: [f64; 4] = [0.9715153129, 0.02236266369, 0.0007060224765, 0.005666341563]; + const FF_HQ_B: [f64; 4] = [0.97137946, 0.02277016389, 0.0003830082563, 0.005727820598]; + const APSC_G: [f64; 4] = [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468]; + const APSC_R: [f64; 4] = [1.016503991, -0.06206460153, 0.0278447625, -0.005644118171]; + const APSC_B: [f64; 4] = [1.016155702, -0.06162477627, 0.02753315326, -0.005554915901]; + + fn knots() -> f64 { + knot_spacing("ILCE-7RM6").unwrap() + } + + fn ifd(tags: Vec<(u16, Value)>) -> Ifd { + let mut entries: Vec<Entry> = tags.into_iter().map(|(tag, value)| Entry { tag, value, offset: 0 }).collect(); + entries.sort_by_key(|e| e.tag); + Ifd { entries, ..Default::default() } + } + + fn sony_lens_ifd(distortion: &[i16], vignetting: &[i16], ca: &[i16], flags: [u16; 3]) -> Ifd { + ifd(vec![ + (DISTORTION_PARAMS, Value::SShort(distortion.to_vec())), + (VIGNETTING_PARAMS, Value::SShort(vignetting.to_vec())), + (CA_PARAMS, Value::SShort(ca.to_vec())), + (DISTORTION_FLAG, Value::Short(vec![flags[0]])), + (VIGNETTING_FLAG, Value::Short(vec![flags[1]])), + (CA_FLAG, Value::Short(vec![flags[2]])), + ]) + } + + fn tables_with_distortion(v: &[f64]) -> LensTables { + LensTables { distortion: Some(v.to_vec()), vignetting: None, ca: None, flags: [None; 3] } + } + + fn values(table: &[i16]) -> Vec<f64> { + table[1..=table[0] as usize].iter().map(|&v| v as f64).collect() + } + + /// The warp's planes and centre against the recipe's numbers (docs/sony-ilce7rm6-lens-corrections.md); returns the planes. + fn assert_warp(op: Option<Opcode>, center: [f64; 2], k: &[[f64; 4]]) -> Vec<[f64; 6]> { + let Some(Opcode::WarpRectilinear { planes, center: c }) = op else { panic!("no warp: {op:?}") }; + assert_eq!(planes.len(), k.len()); + assert!((c[0] - center[0]).abs() < 1e-9 && (c[1] - center[1]).abs() < 1e-9, "centre {c:?}, want {center:?}"); + for (p, k) in planes.iter().zip(k) { + for (i, want) in k.iter().enumerate() { + assert!((p[i] - want).abs() < 1e-6, "k{i} {}, want {want} ({p:?})", p[i]); + } + assert_eq!((p[4], p[5]), (0.0, 0.0), "radial only"); + } + planes + } + + #[test] + fn reads_the_three_tables_and_flags() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[0], d[15]), (16, 0.0, 479.0)); + let v = t.vignetting.unwrap(); + assert_eq!((v.len(), v[1], v[15]), (16, 80.0, 8976.0)); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[0], red[15]), (16, 242.0, 4.0)); + assert_eq!((blue.len(), blue[0], blue[15]), (16, -276.0, 32.0)); + assert_eq!(t.flags, [Some(0), Some(2), Some(1)]); + } + + #[test] + fn apsc_tables_use_only_the_first_count_values() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + let d = t.distortion.unwrap(); + assert_eq!((d.len(), d[10]), (11, -583.0)); + assert_eq!(t.vignetting.unwrap().len(), 11); + let (red, blue) = t.ca.unwrap(); + assert_eq!((red.len(), red[10]), (11, 470.0)); + assert_eq!((blue.len(), blue[0], blue[10]), (11, -432.0, 34.0)); + } + + #[test] + fn missing_or_malformed_tables_yield_no_opcode() { + let mut short = FF_DISTORTION.to_vec(); + short.pop(); + let mut count_17 = FF_DISTORTION.to_vec(); + count_17[0] = 17; + count_17.push(500); + let mut too_large = FF_DISTORTION.to_vec(); + too_large[0] = 600; + for (what, table) in [ + ("count 15 with 3 values", vec![15, 0, 2, 7]), + ("count 17", count_17), + ("empty", vec![]), + ("count 16 with 15 values", short), + ("count 1", vec![1, 5]), + ("count 0", vec![0, 5, 6]), + ("negative count", vec![-16, 0, 2, 7]), + ("count larger than the array", too_large), + ] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(table))]); + let t = read_tables(&ifd); + assert!(t.distortion.is_none(), "{what}"); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{what}"); + } + // not an integer array, or absent + for value in [Value::Ascii("16 0 2 7".into()), Value::Double(vec![2.0, 0.0, 1.0])] { + assert!(read_tables(&ifd(vec![(DISTORTION_PARAMS, value)])).distortion.is_none()); + } + let none = read_tables(&ifd(vec![])); + assert!(none.distortion.is_none() && none.vignetting.is_none() && none.ca.is_none() && none.flags == [None; 3]); + // the CA table needs an even count of 4..=32 + for ca in [vec![3, 1, 2, 3], vec![34; 35], vec![2, 1, 2]] { + assert!(read_tables(&ifd(vec![(CA_PARAMS, Value::SShort(ca))])).ca.is_none()); + } + } + + #[test] + fn hostile_tables_are_rejected() { + let folded = [0.0, 2000.0, 1000.0, 3000.0, 2000.0, 4000.0, 3000.0, 5000.0, 4000.0, 6000.0, 5000.0, 7000.0, 6000.0, 8000.0, 7000.0, 9000.0]; + for (what, table) in [ + ("huge", vec![(i64::MAX / 4) as f64; 16]), + ("most negative", vec![i64::MIN as f64; 16]), + ("zig-zag", folded.to_vec()), + ("all zero", vec![0.0; 16]), + ("one knot", vec![100.0]), + ("no knots", vec![]), + ("not finite", vec![0.0, f64::NAN, 20.0]), + ("infinite", vec![0.0, f64::INFINITY, 20.0]), + ] { + assert!(warp_opcode(&tables_with_distortion(&table), knots(), FF_ACTIVE, FF_CROP, false).is_none(), "{what}"); + } + // the same through the IFD, as 64-bit values + for v in [i64::MAX / 4, i64::MIN, i64::MAX] { + let mut table = vec![v; 17]; + table[0] = 16; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SLong8(table))]); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty(), "{v}"); + } + // degenerate geometry + let ff = tables_with_distortion(&values(&FF_DISTORTION)); + for (what, knots, active, crop) in [ + ("empty crop", knots(), FF_ACTIVE, Rect::new(12, 8, 0, 6656)), + ("1 × 1 active area", knots(), Rect::new(0, 0, 1, 1), Rect::new(0, 0, 1, 1)), + ("knot spacing NaN", f64::NAN, FF_ACTIVE, FF_CROP), + ("knot spacing 0", 0.0, FF_ACTIVE, FF_CROP), + ] { + assert!(warp_opcode(&ff, knots, active, crop, false).is_none(), "{what}"); + } + } + + #[test] + fn ff_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&FF_DISTORTION)); + assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, false), [0.4996005991, 0.5], &[FF_G]); + } + + #[test] + fn apsc_example_table_becomes_the_measured_warp() { + let t = tables_with_distortion(&values(&APSC_DISTORTION)); + let op = warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, false); + assert_warp(op, [0.4975724473, 0.5], &[APSC_G]); + } + + #[test] + fn ca_table_shifts_red_and_blue_planes() { + let t = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [0, 2, 1])); + let planes = assert_warp(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true), [0.4996005991, 0.5], &[FF_HQ_R, FF_G, FF_HQ_B]); + assert_ne!(planes[0][0], planes[1][0]); + // G is the distortion plane alone + let g = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert!(matches!(g, Some(Opcode::WarpRectilinear { planes: ref p, .. }) if p == &[planes[1]]), "{g:?}"); + } + + #[test] + fn apsc_ca_planes_match_the_measured_warp() { + let t = read_tables(&sony_lens_ifd(&APSC_DISTORTION, &APSC_VIGNETTING, &APSC_CA, [0, 2, 1])); + assert_warp(warp_opcode(&t, knots(), APSC_ACTIVE, APSC_CROP, true), [0.4975724473, 0.5], &[APSC_R, APSC_G, APSC_B]); + } + + #[test] + fn ca_without_distortion_is_not_applied() { + let ifd = ifd(vec![(CA_PARAMS, Value::SShort(FF_HQ_CA.to_vec()))]); + let t = read_tables(&ifd); + assert!(t.ca.is_some() && t.distortion.is_none()); + assert!(warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true).is_none()); + assert!(lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP).is_empty()); + // an all-zero distortion table counts as absent + let zero = LensTables { ca: t.ca, ..tables_with_distortion(&[0.0; 16]) }; + assert!(warp_opcode(&zero, knots(), FF_ACTIVE, FF_CROP, true).is_none()); + } + + #[test] + fn rejected_ca_keeps_the_single_green_plane() { + let green = assert_warp( + warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false), + [0.4996005991, 0.5], + &[FF_G], + ); + let ok = values(&FF_HQ_CA); + let huge = vec![(i64::MAX / 4) as f64; 16]; + for (what, red, blue) in [ + ("huge red", huge.clone(), ok[16..].to_vec()), + ("huge blue", ok[..16].to_vec(), huge), + ("most negative blue", ok[..16].to_vec(), vec![i64::MIN as f64; 16]), + ("not finite red", vec![0.0, f64::NAN, 20.0], ok[16..].to_vec()), + ("one knot", vec![100.0], vec![100.0]), + ] { + let t = LensTables { ca: Some((red, blue)), ..tables_with_distortion(&values(&FF_DISTORTION)) }; + let Some(Opcode::WarpRectilinear { planes, .. }) = warp_opcode(&t, knots(), FF_ACTIVE, FF_CROP, true) else { panic!("{what}: no warp") }; + assert_eq!(planes, green, "{what}"); + } + // through the IFD: an odd count, or values that overflow the fit + let mut odd = FF_HQ_CA.to_vec(); + odd[0] = 31; + let mut huge_ifd = vec![i64::MAX / 4; 33]; + huge_ifd[0] = 32; + // a constant 32767 (the SShort maximum) fits as well as G does and would shift red ~59 px at the full-res + // corner: refused as implausible (bound 2·10⁻³ relative = 6720) + let mut max = vec![32767; 33]; + max[0] = 32; + for (what, ca) in [("odd count", Value::SShort(odd)), ("huge", Value::SLong8(huge_ifd)), ("constant 32767", Value::SShort(max))] { + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, ca)]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes == &green), "{what}: {op:?}"); + } + // values up to the bound are kept + let mut at_bound = vec![6720; 33]; + at_bound[0] = 32; + let ifd = ifd(vec![(DISTORTION_PARAMS, Value::SShort(FF_DISTORTION.to_vec())), (CA_PARAMS, Value::SShort(at_bound))]); + let op = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(op, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{op:?}"); + } + + /// The default: a valid CA table is read and converts (above), but `lens_opcodes` emits the G plane alone. + #[test] + fn lens_opcodes_emit_the_green_plane_only() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_HQ_CA, [1, 2, 1]); + let with_ca = warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, true); + assert!(matches!(with_ca, Some(Opcode::WarpRectilinear { ref planes, .. }) if planes.len() == 3), "{with_ca:?}"); + let ops = lens_opcodes(&ifd, "ILCE-7RM6", FF_ACTIVE, FF_CROP); + let [Opcode::WarpRectilinear { planes, .. }] = ops.as_slice() else { panic!("{ops:?}") }; + assert_eq!(planes.len(), 1); + let green = warp_opcode(&tables_with_distortion(&values(&FF_DISTORTION)), knots(), FF_ACTIVE, FF_CROP, false); + assert_eq!(ops.first(), green.as_ref()); + } + + /// The corpus lossless files' own tables on their decoded geometry (padded active areas; the corpus test checks + /// the G plane from the files): R, G, B with the CA planes enabled. + #[test] + fn lossless_examples_ca_planes_match_the_measured_warp() { + let ff = read_tables(&sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1])); + let ff_active = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + assert_warp( + warp_opcode(&ff, knots(), ff_active, FF_CROP, true), + [0.488670768629749, 0.4653969582810102], + &[ + [0.971770511193381, 0.023073568897462152, 0.00879158563716919, 0.0030468782543633132], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + [0.9716312798157171, 0.02356676356656341, 0.00830169937204417, 0.003182927930635947], + ], + ); + let apsc = read_tables(&sony_lens_ifd(&APSC_LOSSLESS_DISTORTION, &APSC_VIGNETTING, &APSC_LOSSLESS_CA, [0, 2, 1])); + let apsc_active = Rect { x: 0, y: 0, width: 6656, height: 4608 }; + assert_warp( + warp_opcode(&apsc, knots(), apsc_active, APSC_CROP, true), + [0.49278737791134486, 0.4743868026915563], + &[ + [1.0165959888027158, -0.0690974265041942, 0.036328960724338874, -0.009486844437228997], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + [1.0162457267369314, -0.0686225599869027, 0.035974238433727206, -0.009384155126102897], + ], + ); + } + + /// The lossless files' padded active area (10240 × 7168) puts the farthest active-area corner (DNG radius 1) well + /// beyond the crop's (u 0.925): the gate looks only at the radii the crop shows. The FF example × 1.5 fits within + /// 0.12 px at 1440 at the samples there, 0.27 px at all 65. + #[test] + fn padded_active_area_gates_only_the_displayed_radii() { + let strong: Vec<f64> = values(&FF_DISTORTION).iter().map(|v| v * 1.5).collect(); + let padded = Rect { x: 0, y: 0, width: 10240, height: 7168 }; + let op = warp_opcode(&tables_with_distortion(&strong), knots(), padded, FF_CROP, false); + assert!(matches!(op, Some(Opcode::WarpRectilinear { .. })), "{op:?}"); + } + + #[test] + fn unknown_model_gets_no_opcodes() { + let ifd = sony_lens_ifd(&FF_DISTORTION, &FF_VIGNETTING, &FF_CA, [0, 2, 1]); + assert!(lens_opcodes(&ifd, "ILCE-7M4", FF_ACTIVE, FF_CROP).is_empty()); + assert!(lens_opcodes(&ifd, "", FF_ACTIVE, FF_CROP).is_empty()); + // the camera's in-camera flag (0 = off here) doesn't matter: the table is applied + let ops = lens_opcodes(&ifd, " ILCE-7RM6 ", FF_ACTIVE, FF_CROP); + assert_eq!(ops, warp_opcode(&read_tables(&ifd), knots(), FF_ACTIVE, FF_CROP, false).into_iter().collect::<Vec<_>>()); + assert_eq!(ops.len(), 1); + } +} diff --git a/crates/raw/src/vendor/cr2.rs b/crates/raw/src/vendor/cr2.rs index 74d93534d..3fe173e09 100644 --- a/crates/raw/src/vendor/cr2.rs +++ b/crates/raw/src/vendor/cr2.rs @@ -249,8 +249,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr2/sraw.rs b/crates/raw/src/vendor/cr2/sraw.rs index 68b406238..6a37d627e 100644 --- a/crates/raw/src/vendor/cr2/sraw.rs +++ b/crates/raw/src/vendor/cr2/sraw.rs @@ -391,8 +391,10 @@ pub(super) fn decode(bytes: &[u8], tiff: &Tiff, raw: &Ifd, mode: Mode) -> Result // the camera's white balance is already applied, as in Sony's linear YCbCr wb_multipliers: Some([1.0; 3]), linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/cr3.rs b/crates/raw/src/vendor/cr3.rs index 7c93e3bac..3df33424d 100644 --- a/crates/raw/src/vendor/cr3.rs +++ b/crates/raw/src/vendor/cr3.rs @@ -253,8 +253,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: levels.wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; image.validate_for(mode)?; Ok(image) diff --git a/crates/raw/src/vendor/crx.rs b/crates/raw/src/vendor/crx.rs index 0321ac042..853efdffe 100644 --- a/crates/raw/src/vendor/crx.rs +++ b/crates/raw/src/vendor/crx.rs @@ -30,6 +30,12 @@ const MAX_CRX_SAMPLES: usize = 200_000_000; const MAX_CRX_EDGE: u32 = 65_536; /// Unary QP-map prefix length that announces an 8-bit escaped symbol. const QP_ESCAPE_ZEROS: u32 = 23; +/// Highest uniform (per-subband) quantization value with a measured step. +const MAX_UNIFORM_QUANT: u8 = 44; +/// Highest adaptive quantization value (QP − 124) seen in real files: QP 175 on the EOS R6 Mark II. +/// See [`quant_step`]. +const MAX_ADAPTIVE_QUANT: u8 = 51; +const MAX_QP: i32 = 124 + MAX_ADAPTIVE_QUANT as i32; const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; fn corrupt(reason: &str) -> RawError { @@ -207,7 +213,7 @@ fn validate_planes(tiles: &[Tile], config: &Cr3Compression) -> Result<()> { if config.levels != 0 { match band.quant { Quantization::Uniform(value) => { - quant_step(value)?; + quant_step(value, MAX_UNIFORM_QUANT)?; } Quantization::Adaptive { gain, .. } => { if index < 4 && gain != 0 { @@ -342,12 +348,14 @@ fn tile_qp(sample: &[u8], tile: &Tile) -> Result<Option<QpMap>> { .transpose() } -fn quant_step(value: u8) -> Result<i32> { +fn quant_step(value: u8, max: u8) -> Result<i32> { // Measured independently with an external decoder after modifying only the // quantValue field: all forty values 4..43 follow this integer sequence. Value 44 (QP 168) // continues it (step 102); it was checked against a measured step of about 100 on an EOS R50 - // C-RAW file. Nothing above 44 has been seen, so it stays rejected. - if !(4..=44).contains(&value) { + // C-RAW file. Uniform subband values stop there (`MAX_UNIFORM_QUANT`). Adaptive QP maps reach + // QP 175 (value 51) on the EOS R6 Mark II; those steps (114..228) continue the same sequence and + // were checked against the camera's own JPEGs (docs/cr3.md). Anything higher is still rejected. + if !(4..=max).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX quantization value {value}"))); } let scale = [40i32, 45, 51, 57, 64, 72].get(usize::from(value % 6)).copied().ok_or_else(|| corrupt("quantization table index"))?; @@ -403,7 +411,7 @@ fn wavelet_plane(sample: &[u8], plane: &Plane, width: usize, height: usize, edge }; for (position, value) in data.iter_mut().enumerate() { let step = match band.quant { - Quantization::Uniform(value) => quant_step(value)?, + Quantization::Uniform(value) => quant_step(value, MAX_UNIFORM_QUANT)?, Quantization::Adaptive { base, gain } => { adaptive_step(qp.ok_or_else(|| corrupt("missing QP map"))?, i, position % w, position / w, base, gain)? } @@ -443,7 +451,7 @@ fn adaptive_step(map: &QpMap, band: usize, x: usize, y: usize, base: u32, gain: // QP rows are averaged before looking up the integer step. R8 field // mutations distinguish this order from averaging dequantization steps. let quant = u8::try_from(value - 124).map_err(|_| corrupt("QP value outside quantization table"))?; - u64::from(base) + u64::from(gain) * quant_step(quant)? as u64 / 8 + u64::from(base) + u64::from(gain) * quant_step(quant, MAX_ADAPTIVE_QUANT)? as u64 / 8 }; i32::try_from(step.max(1)).map_err(|_| corrupt("adaptive quantization step outside precision")) } @@ -775,7 +783,7 @@ fn qp_map(src: &[u8], width: usize, height: usize) -> Result<QpMap> { // The map opens with its first symbol as a 32-bit literal; every later one is Rice coded. let symbol = if x == 0 && y == 0 { (bits.take(16)? << 16) | bits.take(16)? } else { bits.qp_rice(k)? }; let value = median(left, above, corner).checked_add(signed(symbol)).ok_or_else(|| corrupt("QP prediction overflow"))?; - if !(131..=168).contains(&value) { + if !(131..=MAX_QP).contains(&value) { return Err(RawError::Unsupported(format!("Canon CRX adaptive QP value {value}"))); } *current.get_mut(x).ok_or_else(|| corrupt("QP outside row"))? = value; @@ -990,10 +998,19 @@ mod tests { #[test] fn measured_quantization_and_allocation_limits() { for (quant, expected) in [(4, 1), (9, 1), (10, 2), (16, 4), (22, 8), (26, 12), (32, 25), (36, 40), (41, 72), (42, 80), (43, 90), (44, 102)] { - assert_eq!(quant_step(quant).unwrap(), expected); + assert_eq!(quant_step(quant, MAX_UNIFORM_QUANT).unwrap(), expected); } - assert!(matches!(quant_step(3), Err(RawError::Unsupported(_)))); - assert!(matches!(quant_step(45), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(3, MAX_UNIFORM_QUANT), Err(RawError::Unsupported(_)))); + assert!(matches!(quant_step(45, MAX_UNIFORM_QUANT), Err(RawError::Unsupported(_)))); + // Adaptive QP 169..=175 (EOS R6 Mark II C-RAW) continue the sequence; QP 176 does not decode. + for (quant, expected) in [(45, 114), (46, 128), (47, 144), (48, 160), (49, 180), (50, 204), (51, 228)] { + assert_eq!(quant_step(quant, MAX_ADAPTIVE_QUANT).unwrap(), expected); + } + assert!(matches!(quant_step(52, MAX_ADAPTIVE_QUANT), Err(RawError::Unsupported(_)))); + let high = QpMap { width: 1, height: 2, data: vec![172, 175] }; + // floor((172+175)/2) = 173 → value 49, step 180; gain 8/8 plus base 1. + assert_eq!(adaptive_step(&high, 4, 0, 0, 1, 8).unwrap(), 181); + assert_eq!(adaptive_step(&high, 7, 0, 1, 0, 8).unwrap(), 228); for (width, height) in [(20_000, 20_000), (65_538, 2), (2, 65_538)] { let mut config = coding(); config.width = width; @@ -1032,9 +1049,18 @@ mod tests { } #[test] - fn qp_168_is_accepted_and_169_is_not() { - assert_eq!(qp_map(&[0, 0, 1, 0x50, 0x94], 1, 4).unwrap().data, [168; 4]); - assert!(matches!(qp_map(&[0, 0, 1, 0x52, 0x94], 1, 4), Err(RawError::Unsupported(_)))); + fn qp_limit_is_accepted_and_one_above_is_not() { + // The first symbol is a 32-bit literal: signed(2·QP) = QP from a prediction of 0. QP 172 and 175 + // occur in EOS R6 Mark II maps (3 of 40 sample files failed with them before). + let map = |qp: u32| { + let symbol = 2 * qp; + let mut src = vec![0, 0, (symbol >> 8) as u8, symbol as u8]; + src.push(0x94); + qp_map(&src, 1, 4) + }; + assert_eq!(map(168).unwrap().data, [168; 4]); + assert_eq!(map(MAX_QP as u32).unwrap().data, [MAX_QP; 4]); + assert!(matches!(map(MAX_QP as u32 + 1), Err(RawError::Unsupported(_)))); } #[test] diff --git a/crates/raw/src/vendor/erf.rs b/crates/raw/src/vendor/erf.rs new file mode 100644 index 000000000..b9861ef38 --- /dev/null +++ b/crates/raw/src/vendor/erf.rs @@ -0,0 +1,199 @@ +//! Epson ERF (R-D1, R-D1s, R-D1x): a big-endian TIFF whose `SubIFDs` child is a 12-bit CFA image in 16-byte blocks. +//! +//! Sources: TIFF 6.0 (container, `CFARepeatPatternDim` / `CFAPattern`) and our own black-box analysis of the three +//! CC0 ERF samples on raw.pixls.us (one per body); every number below was measured on them. +//! +//! - IFD0 is a 160 x 120 RGB thumbnail; its `SubIFDs` child is the raw: 3040 x 2024, 12 bits, `Compression` 32769, +//! photometric CFA, one strip of exactly `width * 8 / 5` bytes per row. Byte 15 of every 16 is zero in all rows: +//! a row is 16-byte blocks, each 15 bytes of ten 12-bit samples, most significant bits first (`b0 << 4 | b1 >> 4`, +//! `(b1 & 15) << 8 | b2`), then a pad byte. Read the other way (least significant first, as the Olympus blocks are) +//! the frame is noise: the mean step between same-colour neighbours is 150..199 codes against 11..18. +//! - `CFAPattern` `00 01 01 02`: RGGB. +//! - The maker note (`EPSON\0\x01\0`, then a big-endian IFD with offsets from the file start) holds four black levels +//! as `u32` in tag `0x0401` (61..65; in Bayer-cell order), above which the darkest 0.01% of samples sit; `0x020b` x +//! `0x020c` is the picture size, 3008 x 2000. Registered against the 640 x 424 preview JPEG (correlation of the +//! green channel over every even offset), that picture starts at column 20, row 12 in all three files. +//! - White: the saturation plateau (4095 on the clipped R-D1x frame), else 4095. +//! - No maker-note tag tracks the as-shot white balance across the three files, so it is measured from the +//! preview's neutral pixels ([`crate::wb_estimate`]); on the R-D1s sample (54,000 neutral preview pixels) that +//! gives red 2.68 and blue 1.31. +//! - The preview is the maker note's tag `0x0280`, a JPEG whose first byte is stored as `ee` (found by the +//! structural scan in `preview::scan_for_jpeg`). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// Epson's private compression code for the 16-byte block layout. +const BLOCKS16: u16 = 32769; +/// Maker-note tags. +const MN_BLACK: u16 = 0x0401; +const MN_WIDTH: u16 = 0x020b; +const MN_HEIGHT: u16 = 0x020c; +/// Where the picture starts in the sensor frame (measured, see the module notes). +const CROP_ORIGIN: (usize, usize) = (20, 12); + +/// The raw IFD of an Epson ERF: CFA, 12 bits, compression 32769, one strip of 16-byte blocks of ten samples. +pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> { + let make = tiff.find(t::MAKE).and_then(|e| e.value.as_str())?; + if !make.to_ascii_uppercase().contains("EPSON") { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + let Ok(info) = i.image() else { return false }; + let (w, h) = (info.width as u64, info.height as u64); + i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA) + && i.u16(t::COMPRESSION) == Some(BLOCKS16) + && i.u16(t::BITS_PER_SAMPLE) == Some(12) + && w > 0 + && w % 10 == 0 + && matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 8 / 5).checked_mul(h)) + }) +} + +/// The maker-note IFD (`EPSON\0\x01\0` then an IFD in the file's byte order, offsets from the file start). +fn maker_note(bytes: &[u8], tiff: &Tiff) -> Option<Ifd> { + let e = tiff.exif()?.get(t::MAKER_NOTE)?; + let start = usize::try_from(e.offset).ok()?; + if !bytes.get(start..)?.starts_with(b"EPSON\0") { + return None; + } + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + lightcraft_tiff::parse_ifd_at(bytes, e.offset.checked_add(8)?, tiff.order, 0, false, &opts).ok().map(|(i, _)| i) +} + +/// Unpack a strip of 16-byte blocks (15 bytes of ten MSB-first 12-bit samples, one pad byte). +fn unpack(src: &[u8], out: &mut [u16]) { + let (blocks, _) = src.as_chunks::<16>(); + let (pixels, _) = out.as_chunks_mut::<10>(); + for (b, px) in blocks.iter().zip(pixels) { + crate::unpack::unpack_msb(&b[..15], 12, px); + } +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let tiff = Tiff::parse(bytes)?; + let ifd0 = tiff.ifds.first().ok_or_else(|| RawError::Corrupt("Erf TIFF without IFDs".into()))?; + let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Erf raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Erf raw without a 2x2 CFAPattern".into())), + }; + let mn = maker_note(bytes, &tiff); + let black = match mn.as_ref().and_then(|m| m.u64s(MN_BLACK)).as_deref() { + Some(v @ [_, _, _, _]) if v.iter().all(|&b| b < 4095) => { + BlackLevel { repeat_rows: 2, repeat_cols: 2, values: v.iter().map(|&b| b as f32).collect(), delta_h: vec![], delta_v: vec![] } + } + _ => BlackLevel::uniform(0.0), + }; + let size = mn.as_ref().and_then(|m| Some((m.u32(MN_WIDTH)? as usize, m.u32(MN_HEIGHT)? as usize))); + let crop = match size { + Some((cw, ch)) if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h => Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch), + _ => Rect::new(0, 0, w, h), + }; + // the white balance is measured from the samples, so the header probe reads them too + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Erf strip outside file".into()))?; + let mut d = vec![0u16; n]; + unpack(src, &mut d); + let white = super::white_from_data(&d, 12); + let wb = crate::wb_estimate::as_shot(bytes, &crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white }); + let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) }; + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let img = RawImage { + format: RawFormat::Erf, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(ifd0.u16(t::ORIENTATION).unwrap_or(1)), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value}; + + /// Pack ten-sample groups into 16-byte blocks, MSB first, zero pad. + fn pack(px: &[u16]) -> Vec<u8> { + let mut out = Vec::new(); + for g in px.chunks(10) { + for p in g.chunks(2) { + let (a, b) = (p[0], p[1]); + out.extend_from_slice(&[(a >> 4) as u8, ((a & 15) << 4) as u8 | (b >> 8) as u8, b as u8]); + } + out.push(0); + } + out + } + + fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![BLOCKS16])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("SEIKO EPSON CORP.".into())); + ifd0.set(t::MODEL, Value::Ascii("R-D1".into())); + ifd0.set(t::ORIENTATION, Value::Short(vec![1])); + ifd0.add_sub_ifd(raw); + TiffWriter::new(lightcraft_tiff::ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn decodes_the_block_layout() { + let (w, h) = (20u32, 6u32); + let px: Vec<u16> = (0..w * h).map(|i| (i * 37 % 4096) as u16).collect(); + let f = sample(w, h, &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Erf)); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + assert_eq!(r.cfa.as_ref().map(Cfa::name).as_deref(), Some("RGGB")); + assert!(r.wb_multipliers.is_some()); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + + #[test] + fn malformed_files_never_panic() { + let px: Vec<u16> = (0..200).map(|i| (i * 13 % 4096) as u16).collect(); + let f = sample(20, 10, &px); + for n in (0..f.len()).step_by(3) { + let _ = crate::decode(&f[..n]); + } + for i in 0..f.len() { + for v in [0u8, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + } +} diff --git a/crates/raw/src/vendor/hasselblad.rs b/crates/raw/src/vendor/hasselblad.rs new file mode 100644 index 000000000..5f3342f9f --- /dev/null +++ b/crates/raw/src/vendor/hasselblad.rs @@ -0,0 +1,295 @@ +//! Hasselblad 3FR and FFF raws ([`crate::RawFormat::CfaTiff`]). +//! +//! Sources: TIFF 6.0 (container), the DNG Specification (the tags these files borrow) and our own black-box +//! analysis of a CC0 H4D-40 FFF from raw.pixls.us (931, "Hasselblad - H4D-40 - 16bit (4:3).fff"); every number +//! below was measured on it. +//! +//! - Both are TIFFs without a DNG version tag that describe their raw image with DNG's tags: a full-resolution +//! CFA sub-IFD with `BlackLevel`, `WhiteLevel`, `DefaultCropOrigin` / `DefaultCropSize`, and `AsShotNeutral`. +//! [`crate::dng::decode_as`] reads all of that; this module adds what that reader does not know about. +//! - The raw image is uncompressed (X1D, X1D II, X2D, CFV 100C) or Hasselblad's lossless JPEG with predictor +//! selection value 8 ([`crate::ljpeg::decode_hasselblad`]); the H4D-40 FFF's 7410 x 5586 strip is the latter. +//! - No `CFAPattern`. The layout is RGGB from the sensor origin: averaged over the 6 x 6 sensor blocks behind each +//! pixel of the file's 1217 x 913 RGB IFD0 image (the default crop is exactly six times its size), the sites at +//! (0, 0) correlate 0.976 with its red and 0.726 with its blue, the sites at (1, 1) 0.896 with blue and 0.847 +//! with red, and the two green sites' diagonal differences are half those of the other pair. +//! - An FFF is a 3FR converted on the desktop. It has no colour matrix of its own: the camera's matrix stays in +//! the original 3FR header, which the FFF carries whole as a little-endian TIFF in the private IFD0 tag +//! `0xC51B` (4096 bytes in the sample: an IFD0 with Make, Model, `ColorMatrix1` and `AsShotNeutral`, a +//! `SubIFDs` pair for the raw and preview images, whose data offsets point into the old 3FR and are not read). +//! [`decode`] takes the matrices from there when the outer file has none. The white balance stays the outer +//! file's, which is the one the converter last saved. +//! - No JPEG preview. IFD0 is an uncompressed 8-bit RGB image (1217 x 913 in the H4D-40 FFF, IFD1 pointing at the +//! same strip; 218 x 218 in the CFV 3FR, under the file's own `Orientation`); the FFF also has a 16-bit RGB +//! copy in a `SubIFDs` child, with a `TransferFunction` whose meaning is not known, so it is not used. +//! [`rgb_preview`] hands the largest 8-bit one on as a small baseline TIFF. +use crate::dng; +use crate::{ColorData, Mode, RawFormat, RawImage, Result}; +use lightcraft_tiff::image::{Layout, chunk_bytes}; +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, ParseOptions, Tiff, TiffWriter, Value, tags as t}; + +/// Private IFD0 tag holding the original 3FR header (a complete TIFF) inside an FFF. +pub(crate) const ORIGINAL_HEADER: u16 = 0xc51b; + +/// Upper bound on the embedded header's size: the sample's is 4 KiB; a larger block is not a header. +const MAX_HEADER: usize = 1 << 20; + +/// Decode a 3FR / FFF (or another TIFF raw in the DNG layout, see [`dng::is_plain_cfa_tiff`]). +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let mut img = dng::decode_as(bytes, mode, RawFormat::CfaTiff)?; + if !crate::color::has_matrix(&img.color) + && let Some(header) = original_header_color(bytes) + { + adopt_matrices(&mut img.color, &header); + } + Ok(img) +} + +/// The colour tags of the original 3FR header an FFF embeds in [`ORIGINAL_HEADER`], when it has a colour matrix. +fn original_header_color(bytes: &[u8]) -> Option<ColorData> { + let outer = Tiff::parse(bytes).ok()?; + let block = outer.ifds.first()?.bytes(ORIGINAL_HEADER)?; + if block.len() > MAX_HEADER { + return None; + } + let opts = ParseOptions { max_ifds: 16, max_depth: 2, ..Default::default() }; + let header = Tiff::parse_with(block, &opts).ok()?; + let ifd0 = header.ifds.first()?; + let raw = dng::raw_ifd(&header).unwrap_or(ifd0); + let color = dng::color_data(ifd0, raw, header.order); + crate::color::has_matrix(&color).then_some(color) +} + +/// Upper bound on a preview's pixel bytes (8-bit RGB): 64 MP, far above the 1.1 MP of the largest one seen. +const MAX_PREVIEW_BYTES: usize = (64 << 20) * 3; + +/// The file's largest uncompressed 8-bit RGB image (chunky, in strips), as a little-endian baseline TIFF holding +/// just those pixels, which `lightcraft_codecs::decode` reads. No `Orientation` tag is written: the viewer +/// applies the raw file's, which is the one that stands over this image in IFD0. +pub(crate) fn rgb_preview(tiff: &Tiff, bytes: &[u8]) -> Option<Vec<u8>> { + let info = tiff + .all_ifds() + .into_iter() + .filter_map(|i| i.image().ok()) + .filter(|i| { + i.photometric == t::photometric::RGB + && i.compression == t::compression::NONE + && i.samples_per_pixel == 3 + && i.bits_per_sample.iter().all(|&b| b == 8) + && i.planar == 1 + && i.sample_format == 1 + && matches!(i.layout, Layout::Strips { .. }) + && i.width > 0 + && i.height > 0 + }) + .max_by_key(|i| u64::from(i.width) * u64::from(i.height))?; + let (w, h) = (info.width as usize, info.height as usize); + let size = w.checked_mul(h)?.checked_mul(3)?; + if size > MAX_PREVIEW_BYTES { + return None; + } + let mut pixels = Vec::with_capacity(size); + for chunk in info.chunks(bytes.len() as u64) { + pixels.extend_from_slice(chunk_bytes(bytes, &chunk)?); + if pixels.len() >= size { + break; + } + } + // a short last strip is a truncated file: no preview rather than a picture with a black band + if pixels.len() < size { + return None; + } + pixels.truncate(size); + let mut ifd = IfdBuilder::new(); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![info.width])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![info.height])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::RGB])); + ifd.set(t::COMPRESSION, Value::Short(vec![t::compression::NONE])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: info.height, strips: vec![pixels] }); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd]).ok() +} + +/// Take the camera matrices (and the illuminants they belong to) of `from`; the rest of `color` stays. +fn adopt_matrices(color: &mut ColorData, from: &ColorData) { + color.illuminant = from.illuminant; + color.color_matrix = from.color_matrix; + color.forward_matrix = from.forward_matrix; + color.camera_calibration = from.camera_calibration; +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{RawData, RawError, decode as decode_file, probe, probe_info}; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + + const MATRIX: [(i32, i32); 9] = + [(686, 1000), (-196, 1000), (-32, 1000), (-437, 1000), (1228, 1000), (235, 1000), (-165, 1000), (317, 1000), (675, 1000)]; + + fn rgb_ifd() -> IfdBuilder { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![4])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![3])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![8, 8, 8])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![1])); + ifd.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![(0..36).collect()] }); + ifd + } + + /// The camera's 3FR header as an FFF embeds it: a little-endian TIFF whose IFD0 has the colour matrix. + fn original_header(matrix: bool) -> Vec<u8> { + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D".into())); + if matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(MATRIX.to_vec())); + } + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(43, 100), (1, 1), (79, 100)])); + TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap() + } + + /// An FFF in miniature: big-endian, IFD0 an RGB image with `AsShotNeutral` and the embedded header `header`, + /// a 16-bit uncompressed CFA sub-IFD of 8 x 6 samples, no colour matrix of its own unless `own_matrix`. + fn fff(header: Option<Vec<u8>>, own_matrix: bool) -> Vec<u8> { + fff_with(header, own_matrix, vec![]) + } + + /// [`fff`] with more `SubIFDs` children of IFD0 (other preview images). + fn fff_with(header: Option<Vec<u8>>, own_matrix: bool, extra: Vec<IfdBuilder>) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![8])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![6])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::BLACK_LEVEL, Value::Rational(vec![(256, 1)])); + raw.set(t::WHITE_LEVEL, Value::Short(vec![65535])); + raw.set_image(ImageData::Strips { rows_per_strip: 6, strips: vec![(0..96u8).collect()] }); + let mut ifd0 = rgb_ifd(); + ifd0.set(t::MAKE, Value::Ascii("Hasselblad".into())); + ifd0.set(t::MODEL, Value::Ascii("Hasselblad H4D-40".into())); + ifd0.set(t::AS_SHOT_NEUTRAL, Value::Rational(vec![(2, 5), (1, 1), (4, 5)])); + if own_matrix { + ifd0.set(t::COLOR_MATRIX_1, Value::SRational(vec![(1, 1), (0, 1), (0, 1), (0, 1), (1, 1), (0, 1), (0, 1), (0, 1), (1, 1)])); + } + if let Some(h) = header { + ifd0.set(ORIGINAL_HEADER, Value::Undefined(h)); + } + ifd0.add_sub_ifd(raw); + for e in extra { + ifd0.add_sub_ifd(e); + } + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + fn matrix_row0(c: &ColorData) -> Option<[f64; 3]> { + c.color_matrix[0].map(|m| m.0[0]) + } + + /// The FFF's own tags give the samples and white balance; the colour matrix comes from the 3FR header it + /// embeds, so the image is not developed with the "camera RGB ≈ sRGB" fallback. + #[test] + fn fff_takes_the_colour_matrix_from_the_embedded_3fr_header() { + let bytes = fff(Some(original_header(true)), false); + assert_eq!(probe(&bytes), Some(RawFormat::CfaTiff)); + let r = decode_file(&bytes).unwrap(); + assert_eq!(r.data, RawData::U16((0..48u16).map(|i| u16::from_be_bytes([2 * i as u8, 2 * i as u8 + 1])).collect())); + assert_eq!(r.cfa.as_ref().unwrap().name(), "RGGB"); + assert_eq!(matrix_row0(&r.color), Some([0.686, -0.196, -0.032])); + assert_eq!(r.color.as_shot_neutral, Some([0.4, 1.0, 0.8]), "the outer file's white balance"); + assert!(!crate::color::camera_transform_of(&r.color, lightcraft_color::D65).matrix_is_fallback); + // the import probe says the same + assert_eq!(matrix_row0(&probe_info(&bytes).unwrap().color), Some([0.686, -0.196, -0.032])); + } + + /// A matrix of the file's own wins; without one anywhere (no header, or a header without a matrix) the + /// file still decodes, with the fallback colour. + #[test] + fn own_matrix_wins_and_a_missing_one_is_not_an_error() { + let own = decode_file(&fff(Some(original_header(true)), true)).unwrap(); + assert_eq!(matrix_row0(&own.color), Some([1.0, 0.0, 0.0])); + for header in [None, Some(original_header(false)), Some(b"not a tiff".to_vec())] { + let r = decode_file(&fff(header, false)).unwrap(); + assert!(!crate::color::has_matrix(&r.color)); + } + } + + /// A truncated or damaged file never panics: an error, or a decode. + #[test] + fn damaged_files_do_not_panic() { + let bytes = fff(Some(original_header(true)), false); + for n in (0..bytes.len()).step_by(7) { + let _ = decode_file(&bytes[..n]); + let _ = probe_info(&bytes[..n]); + let _ = crate::embedded_preview(&bytes[..n]); + } + let mut header = original_header(true); + for i in (8..header.len()).step_by(3) { + header[i] ^= 0x5a; + match decode_file(&fff(Some(header.clone()), false)) { + Ok(_) | Err(RawError::Corrupt(_) | RawError::Tiff(_) | RawError::Unsupported(_) | RawError::Limit(_) | RawError::NotRaw) => {} + } + } + } + + /// `(width, height, pixels, has an Orientation tag)` of a wrapped preview. + fn wrapped(tiff: &[u8]) -> (u32, u32, Vec<u8>, bool) { + let t = Tiff::parse(tiff).unwrap(); + let ifd = &t.ifds[0]; + let info = ifd.image().unwrap(); + let chunks = info.chunks(tiff.len() as u64); + let pixels = chunks.iter().flat_map(|c| chunk_bytes(tiff, c).unwrap().to_vec()).collect(); + (info.width, info.height, pixels, ifd.contains(t::ORIENTATION)) + } + + /// No JPEG in the file: the 8-bit RGB IFD0 comes out as a baseline TIFF of the same pixels, without an + /// orientation of its own (the raw file's applies). + #[test] + fn rgb_ifd0_is_the_embedded_preview() { + let bytes = fff(Some(original_header(true)), false); + let preview = crate::embedded_preview(&bytes).expect("a preview"); + assert_eq!(wrapped(&preview), (4, 3, (0..36).collect(), false)); + assert_eq!(crate::embedded_preview_covering(&bytes, 2000), Some(preview)); + } + + /// The largest 8-bit RGB image wins; a bigger 16-bit one (the FFF's copy with a `TransferFunction`) is not + /// used, and nor is a planar or compressed one. + #[test] + fn only_8_bit_chunky_uncompressed_rgb_is_taken() { + let image = |w: u32, h: u32, bits: u16, planar: u16, compression: u16| { + let mut ifd = IfdBuilder::new(); + ifd.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![1])); + ifd.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + ifd.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + ifd.set(t::BITS_PER_SAMPLE, Value::Short(vec![bits; 3])); + ifd.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![3])); + ifd.set(t::PHOTOMETRIC, Value::Short(vec![2])); + ifd.set(t::COMPRESSION, Value::Short(vec![compression])); + ifd.set(t::PLANAR_CONFIGURATION, Value::Short(vec![planar])); + let n = (w * h * 3 * u32::from(bits / 8)) as usize; + ifd.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![vec![7; n]] }); + ifd + }; + let bytes = fff_with(None, false, vec![image(16, 12, 16, 1, 1), image(12, 9, 8, 2, 1), image(12, 9, 8, 1, 8)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()).0, 4, "IFD0's 4 x 3"); + let bytes = fff_with(None, false, vec![image(6, 6, 8, 1, 1)]); + assert_eq!(wrapped(&crate::embedded_preview(&bytes).unwrap()), (6, 6, vec![7; 108], false)); + } + + /// A preview strip cut short by a truncated file gives no preview, not a partial picture. + #[test] + fn short_preview_strip_gives_none() { + let mut ifd0 = rgb_ifd(); + ifd0.set_image(ImageData::Strips { rows_per_strip: 3, strips: vec![vec![1; 30]] }); + let tiff = TiffWriter::new(ByteOrder::Little, false).write(&[ifd0]).unwrap(); + assert_eq!(rgb_preview(&Tiff::parse(&tiff).unwrap(), &tiff), None); + } +} diff --git a/crates/raw/src/vendor/kodak.rs b/crates/raw/src/vendor/kodak.rs index f7f82cff8..1e972873e 100644 --- a/crates/raw/src/vendor/kodak.rs +++ b/crates/raw/src/vendor/kodak.rs @@ -115,8 +115,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/mef.rs b/crates/raw/src/vendor/mef.rs new file mode 100644 index 000000000..207bc8871 --- /dev/null +++ b/crates/raw/src/vendor/mef.rs @@ -0,0 +1,187 @@ +//! Mamiya MEF (ZD): a big-endian TIFF whose first `SubIFDs` child is an uncompressed 12-bit packed CFA image. +//! +//! Sources: TIFF 6.0 (container, MSB-first bit packing, `CFAPattern`) and our own black-box analysis of the one CC0 +//! MEF sample on raw.pixls.us (Mamiya ZD); every number below was measured on it. +//! +//! - IFD0 is a 144 x 192 RGB thumbnail with three `SubIFDs`: the raw (4016 x 5344, 12 bits, `Compression` 1, +//! photometric CFA, one strip of exactly 1.5 bytes per pixel, two samples in three bytes most significant bits +//! first, as TIFF packs them), a 992 x 1328 and a 240 x 320 RGB preview, all uncompressed and 8 bits. No JPEG. +//! - `CFAPattern` `00 01 01 02`: RGGB; the two green sites agree (means 254 and 256 against 235 and 165). +//! - No masked border and no stored black level: the darkest 0.01% of each site's samples sit at 3..6 and the +//! minimum is 0, so black is 0. White: the saturation plateau, else 4095. +//! - The 992 x 1328 preview shows a 3968 x 5312 window (4 sensor pixels per preview pixel) that starts at column 24, +//! row 12: that offset gives the best correlation of the preview's luminance with the box-filtered mosaic (0.965, +//! against 0.83 for the full frame). It is the default crop. +//! - Orientation is the raw IFD's own `Orientation` (6 on the sample, a portrait shot). +//! - No white-balance tag was found (the maker note holds an ICC-like profile), so the as-shot gains are measured +//! from that preview's neutral pixels ([`crate::wb_estimate`]). +use crate::{BlackLevel, Cfa, ColorData, Mode, OpcodeLists, RawData, RawError, RawFormat, RawImage, Rect, Result}; +use lightcraft_geom::Orientation; +use lightcraft_tiff::image::chunk_bytes; +use lightcraft_tiff::{Ifd, Tiff, tags as t}; + +/// Where the preview's window starts in the sensor frame, and how many sensor pixels one preview pixel covers. +const CROP_ORIGIN: (usize, usize) = (24, 12); +const PREVIEW_SCALE: usize = 4; + +fn is_mamiya(tiff: &Tiff) -> bool { + tiff.find(t::MAKE).and_then(|e| e.value.as_str()).is_some_and(|m| m.to_ascii_uppercase().starts_with("MAMIYA")) +} + +/// The raw IFD of a Mamiya MEF: CFA, 12 bits, uncompressed, one strip of 1.5 bytes per pixel. +pub(crate) fn raw_ifd(tiff: &Tiff, len: usize) -> Option<&Ifd> { + if !is_mamiya(tiff) { + return None; + } + tiff.all_ifds().into_iter().find(|i| { + let Ok(info) = i.image() else { return false }; + let (w, h) = (info.width as u64, info.height as u64); + i.u16(t::PHOTOMETRIC) == Some(t::photometric::CFA) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::BITS_PER_SAMPLE) == Some(12) + && w > 0 + && w % 2 == 0 + && matches!(info.chunks(len as u64).as_slice(), [c] if Some(c.len) == (w * 3 / 2).checked_mul(h)) + }) +} + +/// The largest uncompressed 8-bit RGB sub-IFD (the preview): its pixels, width and height. +fn rgb_preview<'a>(tiff: &Tiff, bytes: &'a [u8]) -> Option<(&'a [u8], usize, usize)> { + tiff.all_ifds() + .into_iter() + .filter_map(|i| { + let info = i.image().ok()?; + let ok = i.u16(t::PHOTOMETRIC) == Some(2) + && i.u16(t::COMPRESSION).unwrap_or(1) == 1 + && i.u16(t::SAMPLES_PER_PIXEL) == Some(3) + && i.u64s(t::BITS_PER_SAMPLE).is_some_and(|b| b.iter().all(|&v| v == 8)); + let (w, h) = (info.width as usize, info.height as usize); + let chunks = info.chunks(bytes.len() as u64); + let [c] = chunks.as_slice() else { return None }; + let px = chunk_bytes(bytes, c)?; + (ok && px.len() >= w.checked_mul(h)?.checked_mul(3)?).then_some((px, w, h)) + }) + .max_by_key(|(_, w, h)| w * h) +} + +pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { + let tiff = Tiff::parse(bytes)?; + let raw = raw_ifd(&tiff, bytes.len()).ok_or_else(|| RawError::Unsupported("Mef raw image not found".into()))?; + let info = raw.image()?; + let (w, h) = (info.width as usize, info.height as usize); + let n = w.checked_mul(h).filter(|n| *n > 0 && *n <= crate::MAX_SAMPLES).ok_or(RawError::Limit("image too large"))?; + let cfa = match (raw.u64s(t::CFA_REPEAT_PATTERN_DIM).as_deref(), raw.bytes(t::CFA_PATTERN_EP)) { + (Some([2, 2]), Some(p)) if p.len() == 4 && p.iter().all(|&c| c <= 2) => Cfa { width: 2, height: 2, pattern: p.to_vec() }, + _ => return Err(RawError::Unsupported("Mef raw without a 2x2 CFAPattern".into())), + }; + let preview = rgb_preview(&tiff, bytes); + let crop = match preview { + Some((_, pw, ph)) => { + let (cw, ch) = (pw.saturating_mul(PREVIEW_SCALE), ph.saturating_mul(PREVIEW_SCALE)); + if cw > 0 && ch > 0 && CROP_ORIGIN.0 + cw <= w && CROP_ORIGIN.1 + ch <= h { + Rect::new(CROP_ORIGIN.0, CROP_ORIGIN.1, cw, ch) + } else { + Rect::new(0, 0, w, h) + } + } + None => Rect::new(0, 0, w, h), + }; + // the white balance is measured from the samples, so the header probe reads them too + let chunks = info.chunks(bytes.len() as u64); + let src = chunks.first().and_then(|c| chunk_bytes(bytes, c)).ok_or_else(|| RawError::Corrupt("Mef strip outside file".into()))?; + let mut d = vec![0u16; n]; + crate::unpack::unpack_msb(src, 12, &mut d); + let white = super::white_from_data(&d, 12); + let black = BlackLevel::uniform(0.0); + let frame = crate::wb_estimate::Frame { data: &d, width: w, crop, cfa: &cfa, black: &black, white }; + let wb = match preview { + Some((px, pw, ph)) => crate::wb_estimate::as_shot_rgb(px, pw, ph, &frame), + None => crate::wb_estimate::as_shot(bytes, &frame), + }; + let data = if mode == Mode::Full { RawData::U16(d) } else { RawData::U16(Vec::new()) }; + let mut metadata = lightcraft_meta::from_tiff(&tiff); + metadata.width = Some(crop.width as u32); + metadata.height = Some(crop.height as u32); + let orientation = raw.u16(t::ORIENTATION).or_else(|| tiff.ifds.first().and_then(|i| i.u16(t::ORIENTATION))).unwrap_or(1); + let img = RawImage { + format: RawFormat::Mef, + width: w, + height: h, + cpp: 1, + data, + cfa: Some(cfa), + bits: 12, + black, + white: vec![white], + active_area: Rect::new(0, 0, w, h), + crop, + orientation: Orientation::from_exif(orientation), + color: ColorData::default(), + wb_multipliers: wb, + linearized: false, + opcodes: OpcodeLists::default(), + metadata, + lens_metadata: Default::default(), + pixel_aspect: 1.0, + }; + img.validate_for(mode)?; + Ok(img) +} + +#[cfg(test)] +mod tests { + use super::*; + use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, TiffWriter, Value}; + + fn pack(px: &[u16]) -> Vec<u8> { + px.chunks(2).flat_map(|p| [(p[0] >> 4) as u8, ((p[0] & 15) << 4) as u8 | (p[1] >> 8) as u8, p[1] as u8]).collect() + } + + fn sample(w: u32, h: u32, px: &[u16]) -> Vec<u8> { + let mut raw = IfdBuilder::new(); + raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![h])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::ORIENTATION, Value::Short(vec![6])); + raw.set(t::CFA_REPEAT_PATTERN_DIM, Value::Short(vec![2, 2])); + raw.set(t::CFA_PATTERN_EP, Value::Byte(vec![0, 1, 1, 2])); + raw.set_image(ImageData::Strips { rows_per_strip: h, strips: vec![pack(px)] }); + let mut ifd0 = IfdBuilder::new(); + ifd0.set(t::MAKE, Value::Ascii("Mamiya-OP Co.,Ltd.".into())); + ifd0.set(t::MODEL, Value::Ascii("MAMIYA ZD".into())); + ifd0.add_sub_ifd(raw); + TiffWriter::new(ByteOrder::Big, false).write(&[ifd0]).unwrap() + } + + #[test] + fn decodes_packed_12_bit() { + let px: Vec<u16> = (0..16 * 6).map(|i| (i * 41 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + assert_eq!(crate::probe(&f), Some(RawFormat::Mef)); + let r = crate::decode(&f).unwrap(); + assert_eq!(r.data, RawData::U16(px)); + assert_eq!(r.orientation, Orientation::from_exif(6)); + assert_eq!(r.crop, Rect::new(0, 0, 16, 6)); + assert_eq!(crate::probe_info(&f).unwrap(), r.info()); + } + + #[test] + fn malformed_files_never_panic() { + let px: Vec<u16> = (0..16 * 6).map(|i| (i * 7 % 4096) as u16).collect(); + let f = sample(16, 6, &px); + for n in 0..f.len() { + let _ = crate::decode(&f[..n]); + } + for i in 0..f.len() { + for v in [0u8, 0xff] { + let mut g = f.clone(); + g[i] = v; + let _ = crate::decode(&g); + let _ = crate::probe_info(&g); + } + } + } +} diff --git a/crates/raw/src/vendor/mod.rs b/crates/raw/src/vendor/mod.rs index 52b3cf541..ef9616e09 100644 --- a/crates/raw/src/vendor/mod.rs +++ b/crates/raw/src/vendor/mod.rs @@ -6,11 +6,15 @@ mod arw6_curve; #[cfg(test)] pub(crate) mod arw6_testenc; mod arw_lens; +pub(crate) mod arw_lens_rm6; pub mod cr2; pub mod cr3; pub mod crx; pub(crate) mod crx_wavelet; +pub mod erf; +pub(crate) mod hasselblad; pub mod kodak; +pub mod mef; pub mod nef; mod nef_lens; pub mod nefc; @@ -112,6 +116,114 @@ pub(crate) fn white_from_data(data: &[u16], bits: u32) -> f32 { } } +/// Output radii sampled to fit a radial warp. +const DISTORTION_SAMPLES: usize = 64; + +/// Least-squares radial warp from a camera's forward distortion map. +/// +/// `corrected` maps a source radius to the output radius it lands at; it must rise monotonically up to `t_max`, the +/// output radius of the image corners. Radii use the caller's unit; pass `WarpRectilinear`'s normalisation (distance +/// from the centre to the **farthest** corner = 1) so the result is directly a `WarpRectilinear` plane. The map is +/// scanned at 4096 points up to a source radius of `3 * t_max`: it must reach `t_max` within that range and rise at +/// every scan point (monotonicity is checked only there), otherwise `None`; a NaN at a scan point is rejected the +/// same way, other non-finite values by the fit. The map is inverted by bisection and +/// `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` is fitted over output radii `u·t_max`, `u` in [0, 1] ([`fit_radial_warp`]): +/// the returned `[k0, k1, k2, k3]` give the source radius in the caller's unit as a function of `u`. `None` too when +/// the worst fit error exceeds `max_fit_error`. +pub(crate) fn warp_from_forward_map(corrected: &dyn Fn(f64) -> f64, t_max: f64, max_fit_error: f64) -> Option<[f64; 4]> { + if !(t_max.is_finite() && t_max > 0.0) { + return None; + } + let mut top = None; + let mut prev = 0.0; + for i in 1..=4096 { + let r = 3.0 * t_max * i as f64 / 4096.0; + let v = corrected(r); + if v.is_nan() || v <= prev { + return None; + } + prev = v; + if v >= t_max { + top = Some(r); + break; + } + } + let top = top?; + let source = |t: f64| { + let (mut lo, mut hi) = (0.0, top); + for _ in 0..60 { + let m = 0.5 * (lo + hi); + if corrected(m) < t { lo = m } else { hi = m } + } + 0.5 * (lo + hi) + }; + fit_radial_warp(&|u| source(u * t_max), max_fit_error, 1.0) +} + +/// Least-squares `source = u·(k0 + k1u² + k2u⁴ + k3u⁶)` through `source_of_u` sampled at `u = i/64`, i = 0..=64 +/// (`u` = output radius, `WarpRectilinear` units, so the result is directly a plane's `[k0, k1, k2, k3]`). `None` when +/// a sample is not finite, the normal equations are singular, or the worst fit error at the samples up to the first +/// one at or beyond `gate_until` (the radii the image shows; 1 = all) exceeds `max_fit_error`. +pub(crate) fn fit_radial_warp(source_of_u: &dyn Fn(f64) -> f64, max_fit_error: f64, gate_until: f64) -> Option<[f64; 4]> { + let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) + .map(|i| { + let u = i as f64 / DISTORTION_SAMPLES as f64; + (u, source_of_u(u)) + }) + .collect(); + if samples.iter().any(|&(_, v)| !v.is_finite()) { + return None; + } + let basis = |u: f64| { + let u2 = u * u; + [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] + }; + let mut a = [[0.0; 4]; 4]; + let mut rhs = [0.0; 4]; + for &(u, v) in &samples { + let f = basis(u); + for (i, fi) in f.iter().enumerate() { + for (j, fj) in f.iter().enumerate() { + a[i][j] += fi * fj; + } + rhs[i] += fi * v; + } + } + let k = solve4(a, rhs)?; + let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); + // rounded up to a sample, so the band between the last sample inside and `gate_until` itself is checked too; + // capped at 1 (`min` also turns a NaN into 1: every sample is gated) + let n = DISTORTION_SAMPLES as f64; + let gate_until = (gate_until * n).ceil().min(n) / n; + let worst = samples.iter().filter(|&&(u, _)| u <= gate_until).map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); + (worst <= max_fit_error).then_some(k) +} + +/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. +fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { + for c in 0..4 { + let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; + if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { + return None; + } + a.swap(c, p); + b.swap(c, p); + for r in c + 1..4 { + let f = a[r][c] / a[c][c]; + for k in c..4 { + a[r][k] -= f * a[c][k]; + } + b[r] -= f * b[c]; + } + } + let mut x = [0.0; 4]; + for c in (0..4).rev() { + let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); + x[c] = (b[c] - s) / a[c][c]; + } + x.iter().all(|v| v.is_finite()).then_some(x) +} + #[cfg(test)] mod tests { use super::*; @@ -161,4 +273,53 @@ mod tests { let w = white_from_data(&d, 14); assert!((13500.0..=13583.0).contains(&w), "{w}"); } + + #[test] + fn warp_from_forward_map_recovers_identity_and_rejects_folds() { + let k = warp_from_forward_map(&|r| r, 1.0, 1e-9).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9); + let barrel = |r: f64| r * (1.0 + 0.05 * r * r); // mild barrel: source < output radius + let t_max = 0.9; + let k = warp_from_forward_map(&barrel, t_max, 1e-3).unwrap(); + assert!(k[0] < t_max && k[0] > 0.8 && k[1] < 0.0); // source radius at u = 1 is below t_max + for i in 0..=20 { + let u = i as f64 / 20.0; + let s = u * (k[0] + u * u * (k[1] + u * u * (k[2] + u * u * k[3]))); + assert!((barrel(s) - u * t_max).abs() < 1e-5, "u {u}"); + } + assert!(warp_from_forward_map(&|r| if r < 0.5 { r } else { 0.5 - (r - 0.5) }, 1.0, 1e-3).is_none()); // fold, never reaches t_max + // a tiny dip (two scan points wide, 1e-4 deep) that still reaches t_max and fits well: only the monotonicity check rejects it + let dip = |r: f64| { + if r < 0.5 { + r + } else if r < 0.5015 { + 0.5 - 0.05 * (r - 0.5) + } else { + r - 0.001575 + } + }; + assert!(warp_from_forward_map(&dip, 1.0, 1e-2).is_none()); + assert!(warp_from_forward_map(&|_| f64::NAN, 1.0, 1e-3).is_none()); + } + + #[test] + fn fit_radial_warp_identity_and_gate() { + let k = fit_radial_warp(&|u| u, 1e-9, 1.0).unwrap(); + assert!((k[0] - 1.0).abs() < 1e-9 && k[1].abs() < 1e-9 && k[2].abs() < 1e-9 && k[3].abs() < 1e-9, "{k:?}"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::NAN } else { u }, 1.0, 1.0).is_none(), "a NaN sample"); + assert!(fit_radial_warp(&|u| if u > 0.5 { f64::INFINITY } else { u }, 1.0, 1.0).is_none(), "an infinite sample"); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 1.0, 1.0).is_some()); + assert!(fit_radial_warp(&|u: f64| u.sqrt(), 0.0, 1.0).is_none(), "not a polynomial: the gate rejects it"); + // a step at u = 1 alone: worst fit error 0.057 over all samples, 0.006 over u <= 0.9 (the fit is the same) + let step = |u: f64| if u > 0.99 { 1.1 } else { u }; + assert!(fit_radial_warp(&step, 0.01, 1.0).is_none()); + assert_eq!(fit_radial_warp(&step, 0.01, 0.9), fit_radial_warp(&step, 1.0, 1.0), "gated only where shown"); + // the first sample at or beyond `gate_until` is gated too (the band up to it is shown): 0.023 up to + // u = 62/64, 0.032 at 63/64 + assert!(fit_radial_warp(&step, 0.03, 62.0 / 64.0).is_some(), "a sample exactly at the bound"); + assert!(fit_radial_warp(&step, 0.03, 0.98).is_none(), "63/64 is the next sample beyond 0.98"); + // capped at 1, and a NaN gates every sample (fails closed) + assert_eq!(fit_radial_warp(&step, 1.0, f64::INFINITY), fit_radial_warp(&step, 1.0, 1.0)); + assert!(fit_radial_warp(&step, 0.01, f64::NAN).is_none(), "a NaN gate"); + } } diff --git a/crates/raw/src/vendor/nef.rs b/crates/raw/src/vendor/nef.rs index 7d5b24a4f..5076c6df9 100644 --- a/crates/raw/src/vendor/nef.rs +++ b/crates/raw/src/vendor/nef.rs @@ -87,17 +87,104 @@ struct MeasuredBody { /// the masked-column test keeps them and they develop as a pink band on the right. The camera's 4288 × 2848 JPEG /// sits at (10, 10). Green saturates at 3840–3851 while red and blue run on to 4095; one level of 3830 keeps /// clipped highlights white. +/// * **NIKON D7000** and **NIKON D5100** (and the Coolpix A, the same readout): the data is 4992 × 3280, the picture its +/// first 4948 columns, then 36 masked ones at one level (~600, with a black level of 0) and 8 of 0. The masked +/// columns sit above the dark-column trim's threshold, so without this area they show as a coloured bar on the right +/// edge (the top in portrait; issue #976). The camera's 4928 × 3264 JPEG sits at (8, 8): in the D5100's 3 CC0 +/// samples, and in the D7000's one clean one, with the D5100 and Coolpix A as siblings (see below). +/// * **The other bodies** below keep masked or dark lines inside the whole data (a survey of every Nikon raw on raw.pixls.us): +/// each crop is its camera JPEG's frame as `cargo xtask raw-frame` measures it on raw.pixls.us CC0 samples (the +/// D200, D3200 and Nikon 1 V1 also on other publicly posted sample files), with at least 3 clean +/// files agreeing: of the model (bit depths pooled: both read out the same area), or, with at least +/// one of its own, together with models of the same readout (data and JPEG size) that agree on the same frame +/// (*siblings*). The D4's 12-bit mode reads out the 14-bit area measured above; the Df, the same readout, agrees on +/// (8, 6) in 6 files. Bodies whose crop holds only picture beyond the camera frame have no entry (no change). const MEASURED_BODIES: &[MeasuredBody] = &[ MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: Some(15520.0) }, + MeasuredBody { model: "NIKON D4", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, MeasuredBody { model: "NIKON D7100", size: (6036, 4020), bits: 14, crop: (18, 10, 6000, 4000), white: None }, MeasuredBody { model: "NIKON D7100", size: (4812, 3212), bits: 14, crop: (6, 6, 4800, 3200), white: None }, MeasuredBody { model: "NIKON D90", size: (4352, 2868), bits: 12, crop: (10, 10, 4288, 2848), white: Some(3830.0) }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D7000", size: (4992, 3280), bits: 12, crop: (8, 8, 4928, 3264), white: None }, + MeasuredBody { model: "NIKON D5100", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // siblings: D7000 (4), D5100 (3); masked: right 36 + MeasuredBody { model: "COOLPIX A", size: (4992, 3280), bits: 14, crop: (8, 8, 4928, 3264), white: None }, + // 6 files each; masked: left 2, right 2 + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D3S", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 12, crop: (15, 6, 4256, 2832), white: None }, + MeasuredBody { model: "NIKON D700", size: (4288, 2844), bits: 14, crop: (15, 6, 4256, 2832), white: None }, + // 6 and 4 files; masked: right up to 32 + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 12, crop: (15, 10, 4288, 2848), white: None }, + MeasuredBody { model: "NIKON D300S", size: (4352, 2868), bits: 14, crop: (15, 10, 4288, 2848), white: None }, + // D2X (1) and D2Xs (2) as siblings; dark: right 1 + MeasuredBody { model: "NIKON D2Xs", size: (4320, 2868), bits: 12, crop: (16, 10, 4288, 2848), white: None }, + // D3000, D40X, D60, D80: one file each, siblings; masked: right 4, bottom 3 + MeasuredBody { model: "NIKON D3000", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D40X", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D60", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + MeasuredBody { model: "NIKON D80", size: (3904, 2616), bits: 12, crop: (14, 9, 3872, 2592), white: None }, + // 3 files; masked: left 1, right 3 (the D80's data size, framed elsewhere) + MeasuredBody { model: "NIKON D200", size: (3904, 2616), bits: 12, crop: (16, 12, 3872, 2592), white: None }, + // 22 files; masked: right 46 + MeasuredBody { model: "NIKON D3200", size: (6080, 4012), bits: 12, crop: (8, 6, 6016, 4000), white: None }, + // 8 clean of 19, the rest ~0.3 px between pixels on the same frame; dark: bottom 2 + MeasuredBody { model: "NIKON 1 V1", size: (3904, 2606), bits: 12, crop: (12, 6, 3872, 2592), white: None }, + // Best guesses, short of the evidence rule; every frame below keeps the masked lines out by several pixels. + // D3100: 3 of 4 clean files at (24, 6), the 4th at (22, 7); masked: left 6, right 22 + MeasuredBody { model: "NIKON D3100", size: (4672, 3084), bits: 12, crop: (24, 6, 4608, 3072), white: None }, + // D5000: 4 of 5 clean files at (11, 10), the 5th at (11, 12); the D90's sensor, whose own files also align at + // x = 11; masked: right 42 (the same filler as the D90's) + MeasuredBody { model: "NIKON D5000", size: (4352, 2868), bits: 12, crop: (11, 10, 4288, 2848), white: None }, + // D40: 2 of 2 clean files at (16, 9); masked: right 1. The D50 / D70 / D70s share its data size but not its frame + MeasuredBody { model: "NIKON D40", size: (3040, 2014), bits: 12, crop: (16, 9, 3008, 2000), white: None }, + // D50, D70, D70s (one readout): one clean D50 and one clean D70 file at (15, 7); the D70s's 2 files align + // between pixels next to it, at (15.4, 6.9) and (15.6, 6.4); masked: right 1 + MeasuredBody { model: "NIKON D50", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + MeasuredBody { model: "NIKON D70", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + MeasuredBody { model: "NIKON D70s", size: (3040, 2014), bits: 12, crop: (15, 7, 3008, 2000), white: None }, + // 3 files; dark: bottom 4 + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 12, crop: (4, 4, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON D4S", size: (4936, 3288), bits: 14, crop: (4, 4, 4928, 3280), white: None }, + // 4 files; dark: bottom up to 4 + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 12, crop: (8, 8, 6000, 4000), white: None }, + MeasuredBody { model: "NIKON D5600", size: (6016, 4016), bits: 14, crop: (8, 8, 6000, 4000), white: None }, + // 4 files each; masked: right 8 + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D600", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 12, crop: (8, 6, 6016, 4016), white: None }, + MeasuredBody { model: "NIKON D610", size: (6080, 4028), bits: 14, crop: (8, 6, 6016, 4016), white: None }, + // D800: 6 files; D800E: 2, with the D800 as sibling; masked: right 14 (issue #914, the top in portrait) + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 12, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D800E", size: (7424, 4924), bits: 14, crop: (8, 6, 7360, 4912), white: None }, + // 3 files; dark: bottom 1 + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 12, crop: (12, 8, 7360, 4912), white: None }, + MeasuredBody { model: "NIKON D810", size: (7380, 4928), bits: 14, crop: (12, 8, 7360, 4912), white: None }, + // 6 files; masked: left 2, right 42 (the D4's readout) + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 12, crop: (8, 6, 4928, 3280), white: None }, + MeasuredBody { model: "NIKON Df", size: (4992, 3292), bits: 14, crop: (8, 6, 4928, 3280), white: None }, ]; fn measured_body(model: &str, w: usize, h: usize, bits: u32) -> Option<&'static MeasuredBody> { MEASURED_BODIES.iter().find(|b| b.model == model.trim() && b.size == (w, h) && b.bits == bits) } +/// Pixel height over width from the raw IFD's resolution tags: the D1X and D1H write 600 x 300 per inch for a +/// 4028 x 1324 frame, rows twice as tall as the columns are wide. Only ratios from 1.5 to 4 count (a frame +/// squashed 3:1 or 2:1), the rest is rounding or a stray tag. +pub(crate) fn pixel_aspect_of(raw: &Ifd) -> f32 { + match (raw.f64(t::X_RESOLUTION), raw.f64(t::Y_RESOLUTION)) { + (Some(x), Some(y)) if x > 0.0 && y > 0.0 && (1.5..=4.0).contains(&(x / y)) => (x / y).round() as f32, + _ => 1.0, + } +} + fn raw_ifd(tiff: &Tiff) -> Option<&Ifd> { tiff.all_ifds() .into_iter() @@ -191,9 +278,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { Some(&MeasuredBody { crop: (x, y, cw, ch), .. }) if x + cw <= active_w && y + ch <= h => Rect::new(x, y, cw, ch), _ => default_crop(mn.as_ref(), active_w, h), }; + let pixel_aspect = pixel_aspect_of(raw); let mut metadata = lightcraft_meta::from_tiff(&tiff); metadata.width = Some(crop.width as u32); - metadata.height = Some(crop.height as u32); + metadata.height = Some(crate::stretched_height(crop.height, pixel_aspect) as u32); let img = RawImage { format: RawFormat::Nef, width: w, @@ -213,12 +301,14 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect, // Nikon's in-file distortion correction, about the optical centre (which follows the sensor shift) opcodes: OpcodeLists { list3: super::nef_lens::distortion(&model, raw, Rect::new(0, 0, active_w, h), crop).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) @@ -297,6 +387,22 @@ mod tests { /// [`nef_with_note`] in either byte order (file and maker note). #[allow(clippy::too_many_arguments)] fn nef_in(order: ByteOrder, compression: u16, bits: u16, strips: Vec<Vec<u8>>, w: u32, h: u32, rps: u32, note: Option<IfdBuilder>) -> Vec<u8> { + nef_model_in(order, "NIKON TEST", compression, bits, strips, w, h, rps, note) + } + + /// [`nef_in`] from camera `model`. + #[allow(clippy::too_many_arguments)] + fn nef_model_in( + order: ByteOrder, + model: &str, + compression: u16, + bits: u16, + strips: Vec<Vec<u8>>, + w: u32, + h: u32, + rps: u32, + note: Option<IfdBuilder>, + ) -> Vec<u8> { let mut raw = IfdBuilder::new(); raw.set(t::NEW_SUBFILE_TYPE, Value::Long(vec![0])); raw.set(t::IMAGE_WIDTH, Value::Long(vec![w])); @@ -309,7 +415,7 @@ mod tests { raw.set_image(ImageData::Strips { rows_per_strip: rps, strips }); let mut ifd0 = IfdBuilder::new(); ifd0.set(t::MAKE, Value::Ascii("NIKON CORPORATION".into())); - ifd0.set(t::MODEL, Value::Ascii("NIKON TEST".into())); + ifd0.set(t::MODEL, Value::Ascii(model.into())); if let Some(mn) = note { let mut bytes = b"Nikon\0\x02\x10\0\0".to_vec(); bytes.extend(TiffWriter::new(order, false).write(&[mn]).unwrap()); @@ -457,9 +563,76 @@ mod tests { assert_eq!((d4.metadata.width, d4.metadata.height), (Some(4928), Some(3280))); let developed = d4.develop(crate::Method::Bilinear).unwrap(); assert_eq!((developed.width, developed.height), (4928, 3280)); - for other in [decode_as("NIKON D5", 14), decode_as("NIKON D4", 12)] { - assert_eq!(other.crop, Rect::new(0, 0, w, h)); - assert_ne!(other.white, vec![15520.0]); + // the 12-bit mode reads out the same area but keeps the white level from its data + let d4_12 = decode_as("NIKON D4", 12); + assert_eq!(d4_12.crop, Rect::new(8, 6, 4928, 3280)); + assert_ne!(d4_12.white, vec![15520.0]); + let other = decode_as("NIKON D5", 14); + assert_eq!(other.crop, Rect::new(0, 0, w, h)); + assert_ne!(other.white, vec![15520.0]); + } + + /// Issue #976: the D7000 and D5100 record no CropArea, and their 36 masked columns at ~600 were developed as a + /// coloured bar. Their measured area applies to the measured models, data sizes and depths only. + #[test] + fn measured_d7000_and_d5100_image_area() { + let (w, h) = (4992usize, 3280usize); + let decode_as = |model: &str, bits: u16| { + let nef = nef_model_in(ByteOrder::Big, model, 1, bits, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None); + crate::decode(&nef).unwrap() + }; + for (model, bits) in [("NIKON D7000", 14), ("NIKON D7000", 12), ("NIKON D5100", 14)] { + let raw = decode_as(model, bits); + assert_eq!(raw.crop, Rect::new(8, 8, 4928, 3264), "{model} {bits}-bit"); + assert_eq!((raw.metadata.width, raw.metadata.height), (Some(4928), Some(3264))); + let developed = raw.develop(crate::Method::Bilinear).unwrap(); + assert_eq!((developed.width, developed.height), (4928, 3264)); + } + // another model, or the D5100's unmeasured 12-bit mode, keeps the whole data + for (model, bits) in [("NIKON D7100", 14), ("NIKON D5100", 12), ("NIKON D7000 ", 16)] { + assert_eq!(decode_as(model, bits).crop, Rect::new(0, 0, w, h), "{model} {bits}-bit"); + } + } + + /// The D200, D3200 and Nikon 1 V1 keep masked or dark lines in their whole data (the D200 shares the D80's data + /// size but frames it elsewhere), measured on public sample files beyond the CC0 ones; the D3100, D5000, D40, D50, + /// D70 and D70s get best-guess frames (the D40 shares the D50 / D70 / D70s data size but not their frame). + #[test] + fn measured_older_dx_bodies_image_area() { + let decode_as = |model: &str, (w, h): (usize, usize)| { + let nef = nef_model_in(ByteOrder::Big, model, 1, 12, vec![vec![0x10; w * h * 2]], w as u32, h as u32, h as u32, None); + crate::decode(&nef).unwrap() + }; + for (model, size, crop) in [ + ("NIKON D200", (3904, 2616), Rect::new(16, 12, 3872, 2592)), + ("NIKON D80", (3904, 2616), Rect::new(14, 9, 3872, 2592)), + ("NIKON D3200", (6080, 4012), Rect::new(8, 6, 6016, 4000)), + ("NIKON 1 V1", (3904, 2606), Rect::new(12, 6, 3872, 2592)), + ("NIKON D3100", (4672, 3084), Rect::new(24, 6, 4608, 3072)), + ("NIKON D5000", (4352, 2868), Rect::new(11, 10, 4288, 2848)), + ("NIKON D40", (3040, 2014), Rect::new(16, 9, 3008, 2000)), + ("NIKON D50", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ("NIKON D70", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ("NIKON D70s", (3040, 2014), Rect::new(15, 7, 3008, 2000)), + ] { + assert_eq!(decode_as(model, size).crop, crop, "{model}"); + } + // the V1's area on the V2's data size: no entry, the whole data + assert_eq!(decode_as("NIKON 1 V1", (4620, 3084)).crop, Rect::new(0, 0, 4620, 3084)); + } + + /// Every measured entry is a crop inside its data, once per model, data size and depth, with a white level (if + /// any) inside its bit range. + #[test] + fn measured_bodies_are_consistent() { + let mut seen = std::collections::HashSet::new(); + for b in MEASURED_BODIES { + let (x, y, w, h) = b.crop; + assert!(w > 0 && h > 0 && x + w <= b.size.0 && y + h <= b.size.1, "{}: crop outside its data", b.model); + assert!(seen.insert((b.model, b.size, b.bits)), "{} {:?} {}-bit listed twice", b.model, b.size, b.bits); + assert!(matches!(b.bits, 12 | 14), "{}: {}-bit", b.model, b.bits); + assert!(b.white.is_none_or(|v| v > 0.0 && v < (1u32 << b.bits) as f32), "{}: white level", b.model); + assert_eq!(b.model.trim(), b.model, "{}: model names are matched trimmed", b.model); } } diff --git a/crates/raw/src/vendor/orf.rs b/crates/raw/src/vendor/orf.rs index ea7f30eb7..92caeec26 100644 --- a/crates/raw/src/vendor/orf.rs +++ b/crates/raw/src/vendor/orf.rs @@ -285,7 +285,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { let mn = maker_note(bytes, &tiff); let ip = mn.as_ref().and_then(|m| sub_ifd(bytes, m, IMAGE_PROCESSING)); let active = match ip.as_ref().map(|i| CROP.map(|tag| i.u64(tag).map(|v| v as usize))) { - Some([Some(x), Some(y), Some(cw), Some(ch)]) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= h => Rect::new(x, y, cw, ch), + // checked: the tags are file data; `x + cw` on a hostile value would wrap past the bounds + Some([Some(x), Some(y), Some(cw), Some(ch)]) + if cw > 0 && ch > 0 && x.checked_add(cw).is_some_and(|end| end <= w) && y.checked_add(ch).is_some_and(|end| end <= h) => + { + Rect::new(x, y, cw, ch) + } _ => Rect::new(0, 0, w, h), }; let cfa = stated.unwrap_or_else(|| cfa_from_data(samples, w, active)); @@ -329,8 +334,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/pef.rs b/crates/raw/src/vendor/pef.rs index 86db33540..9b7d92bc7 100644 --- a/crates/raw/src/vendor/pef.rs +++ b/crates/raw/src/vendor/pef.rs @@ -143,7 +143,13 @@ pub(crate) fn table(b: &[u8], order: ByteOrder) -> Result<Huffman> { /// Decode a `w × h` Huffman-coded image. pub(crate) fn decode_huffman(src: &[u8], h: &Huffman, w: usize, hgt: usize, bits_per: u32) -> Result<Vec<u16>> { - let mut out = vec![0u16; w * hgt]; + // every sample takes at least one bit: a stream too short for the declared size is refused before its picture + // is allocated, so a few bytes can't ask for gigabytes (issue #1512) + let samples = w.checked_mul(hgt).ok_or(RawError::Limit("image too large"))?; + if (src.len() as u64).saturating_mul(8) < samples as u64 { + return Err(RawError::Corrupt(format!("PEF: {w} x {hgt} samples need more than the {} bytes of data there", src.len()))); + } + let mut out = vec![0u16; samples]; let mut bits = Bits::new(src); let mut vpred = [[0i32; 2]; 2]; let max = (1i32 << bits_per.min(16)) - 1; @@ -243,7 +249,12 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { tiff.exif().and_then(|e| e.get(t::MAKER_NOTE)).and_then(|e| makernote::parse_makernote(bytes, e.offset, e.count() as u64, tiff.order, &make)); let pair = |tag: u16| mn.as_ref().and_then(|m| m.ifd.u64s(tag)).filter(|v| v.len() == 2).map(|v| (v[0] as usize, v[1] as usize)); let tagged = match (pair(CROP_ORIGIN), pair(CROP_SIZE)) { - (Some((x, y)), Some((cw, ch))) if cw > 0 && ch > 0 && x + cw <= w && y + ch <= hgt => Some(Rect::new(x, y, cw, ch)), + // checked: the tags are file data; `x + cw` on a hostile value would wrap past the bounds + (Some((x, y)), Some((cw, ch))) + if cw > 0 && ch > 0 && x.checked_add(cw).is_some_and(|end| end <= w) && y.checked_add(ch).is_some_and(|end| end <= hgt) => + { + Some(Rect::new(x, y, cw, ch)) + } _ => None, }; // without crop tags the image area is found from the samples (dark borders) @@ -324,8 +335,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) @@ -358,6 +371,18 @@ mod tests { (b, codes) } + /// A compressed PEF declaring far more samples than its data could hold (one bit each at least) is refused + /// before its picture is allocated (issue #1512). + #[test] + fn a_stream_too_short_for_its_size_is_refused_before_allocating() { + let (tb, _) = test_table(); + let h = table(&tb, ByteOrder::Big).unwrap(); + let t = std::time::Instant::now(); + let r = decode_huffman(&[0x55; 4096], &h, 30_000, 30_000, 12); + assert!(matches!(r, Err(RawError::Corrupt(ref m)) if m.contains("need more")), "{r:?}"); + assert!(t.elapsed() < std::time::Duration::from_secs(1)); + } + fn encode(px: &[u16], w: usize, codes: &[(u16, u8)]) -> Vec<u8> { let mut bits: Vec<bool> = Vec::new(); let put = |v: u32, n: u8, bits: &mut Vec<bool>| (0..n).rev().for_each(|i| bits.push(v >> i & 1 == 1)); diff --git a/crates/raw/src/vendor/raf.rs b/crates/raw/src/vendor/raf.rs index 7b148929d..42ed29ce8 100644 --- a/crates/raw/src/vendor/raf.rs +++ b/crates/raw/src/vendor/raf.rs @@ -212,8 +212,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: super::raf_lens::corrections(&camera_name(bytes), &ifd, active), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/vendor/rw2.rs b/crates/raw/src/vendor/rw2.rs index b856fbd52..7ebee5916 100644 --- a/crates/raw/src/vendor/rw2.rs +++ b/crates/raw/src/vendor/rw2.rs @@ -145,8 +145,6 @@ const FORMAT8_FIXED: [(u16, &[u16]); 9] = [ const DISTORTION_INFO: u16 = 0x0119; /// Fixed-point one of the distortion coefficients and zoom. const DISTORTION_ONE: f64 = 32768.0; -/// Output radii sampled to fit the inverse mapping. -const DISTORTION_SAMPLES: usize = 64; /// Largest error of the fitted inverse (relative to the half diagonal) accepted: real lenses fit within 1.2e-4. const DISTORTION_MAX_FIT_ERROR: f64 = 1e-3; /// Most strips seen in a file is 3 (the 12008 × 8008 high-resolution mode). @@ -616,8 +614,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists { list3: distortion(ifd0, active).into_iter().collect(), ..Default::default() }, metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) @@ -650,87 +650,12 @@ fn distortion(ifd: &Ifd, active: Rect) -> Option<Opcode> { let r2 = r * r; r * (1.0 + r2 * (k2 + r2 * (k4 + r2 * k6))) }; - // the output corners show corrected radius `t_max`; the map must rise monotonically to it (a fold is no lens) - let t_max = half_diag * zoom / radius; - let mut top = None; - let mut prev = 0.0; - for i in 1..=4096 { - let r = 3.0 * t_max * i as f64 / 4096.0; - let v = corrected(r); - if v.is_nan() || v <= prev { - return None; - } - prev = v; - if v >= t_max { - top = Some(r); - break; - } - } - let top = top?; - let source = |t: f64| { - let (mut lo, mut hi) = (0.0, top); - for _ in 0..60 { - let m = 0.5 * (lo + hi); - if corrected(m) < t { lo = m } else { hi = m } - } - 0.5 * (lo + hi) - }; - // least-squares fit of source radius = u·(kr0 + kr1·u² + kr2·u⁴ + kr3·u⁶) over the output radii u ∈ [0, 1] - let samples: Vec<(f64, f64)> = (0..=DISTORTION_SAMPLES) - .map(|i| { - let u = i as f64 / DISTORTION_SAMPLES as f64; - (u, source(u * t_max) * radius / half_diag) - }) - .collect(); - let basis = |u: f64| { - let u2 = u * u; - [u, u * u2, u * u2 * u2, u * u2 * u2 * u2] - }; - let mut a = [[0.0; 4]; 4]; - let mut rhs = [0.0; 4]; - for &(u, v) in &samples { - let f = basis(u); - for (i, fi) in f.iter().enumerate() { - for (j, fj) in f.iter().enumerate() { - a[i][j] += fi * fj; - } - rhs[i] += fi * v; - } - } - let k = solve4(a, rhs)?; - let fit = |u: f64| basis(u).iter().zip(&k).map(|(f, c)| f * c).sum::<f64>(); - let worst = samples.iter().map(|&(u, v)| (fit(u) - v).abs()).fold(0.0, f64::max); - if worst.is_nan() || worst > DISTORTION_MAX_FIT_ERROR { - return None; - } + // work in DNG-normalised units (corner distance = 1), where the output corners sit at radius `zoom` + let scale = radius / half_diag; + let k = super::warp_from_forward_map(&|s| corrected(s / scale) * scale, zoom, DISTORTION_MAX_FIT_ERROR)?; Some(Opcode::WarpRectilinear { planes: vec![[k[0], k[1], k[2], k[3], 0.0, 0.0]], center: [0.5, 0.5] }) } -/// Solve the 4 × 4 system `a·x = b` (Gaussian elimination with partial pivoting); `None` when singular or not finite. -fn solve4(mut a: [[f64; 4]; 4], mut b: [f64; 4]) -> Option<[f64; 4]> { - for c in 0..4 { - let p = (c..4).max_by(|&i, &j| a[i][c].abs().total_cmp(&a[j][c].abs()))?; - if a[p][c].is_nan() || a[p][c].abs() <= 1e-300 { - return None; - } - a.swap(c, p); - b.swap(c, p); - for r in c + 1..4 { - let f = a[r][c] / a[c][c]; - for k in c..4 { - a[r][k] -= f * a[c][k]; - } - b[r] -= f * b[c]; - } - } - let mut x = [0.0; 4]; - for c in (0..4).rev() { - let s: f64 = (c + 1..4).map(|k| a[c][k] * x[k]).sum(); - x[c] = (b[c] - s) / a[c][c]; - } - x.iter().all(|v| v.is_finite()).then_some(x) -} - #[cfg(test)] mod tests { use super::*; diff --git a/crates/raw/src/vendor/sr2.rs b/crates/raw/src/vendor/sr2.rs index 212533add..10fc15074 100644 --- a/crates/raw/src/vendor/sr2.rs +++ b/crates/raw/src/vendor/sr2.rs @@ -45,7 +45,7 @@ //! of an accident 2⁻⁴⁰⁶⁴). For the DSC-R1's key `63 be 01 3e` the 11932-byte block becomes a well-formed 101-entry //! directory (black `0x7300`, white balance `0x7303`, crop `0x74c3`). -use lightcraft_tiff::{ByteOrder, Ifd, ParseOptions, tags as t}; +use lightcraft_tiff::{ByteOrder, EntrySpan, Ifd, ParseOptions, decode_value, directory_entries, entry_span, tags as t}; /// `SR2Private` tags. const SUBIFD_OFFSET: u16 = 0x7200; @@ -136,11 +136,8 @@ impl SubIfd { /// [`MAX_LEN`]. pub(crate) fn read(bytes: &[u8], ifd0: &Ifd, order: ByteOrder) -> Option<Self> { let at = order.read_u32(ifd0.bytes(t::DNG_PRIVATE_DATA)?, 0)?; - let opts = ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; - let (private, _) = lightcraft_tiff::parse_ifd_at(bytes, u64::from(at), order, 0, false, &opts).ok()?; - let key: [u8; 4] = private.bytes(SUBIFD_KEY)?.try_into().ok()?; - let start = usize::try_from(private.u64(SUBIFD_OFFSET)?).ok()?; - let len = usize::try_from(private.u64(SUBIFD_LENGTH)?).ok()?; + let fields = private_fields(bytes, u64::from(at), order); + let (start, len, key) = (fields.offset?, fields.length?, fields.key?); if len > MAX_LEN { return None; } @@ -165,6 +162,13 @@ impl SubIfd { self.order } + /// The decrypted block (the directory from its entry count on) and the file offset it was stored at, which its + /// value offsets count from: for readers that check each entry's exact type and count themselves + /// (`arw_lens`). + pub(crate) fn block(&self) -> (&[u8], usize) { + (&self.plain, self.start) + } + /// The field type and value bytes of a SHORT or SSHORT entry; `None` when the tag is absent, has another type /// or its values lie outside the block. pub(crate) fn short_bytes(&self, tag: u16) -> Option<(u16, &[u8])> { @@ -193,9 +197,69 @@ impl SubIfd { } } +/// The `SR2Private` fields that locate the block. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +struct PrivateFields { + offset: Option<usize>, + length: Option<usize>, + key: Option<[u8; 4]>, +} + +/// The block's file offset, length and key from the `SR2Private` directory at `at`, exactly as the TIFF reader would +/// give them (`parse_ifd_at` with its default limits, then its `u64` and `bytes` lookups), without decoding the +/// directory. The selection uses the reader's own rules: its directory layout and entry limit +/// ([`directory_entries`]), which entries it can read ([`entry_span`]) and its value budget; each field is its first +/// readable occurrence. Of that entry only the first value is decoded (the key's first five bytes, which settle +/// whether it is four bytes), so a hostile directory can't make this allocate memory sized by its other entries, +/// which could each alias the whole file, before the block's own [`MAX_LEN`] is checked. +fn private_fields(bytes: &[u8], at: u64, order: ByteOrder) -> PrivateFields { + let opts = ParseOptions::default(); + let Ok(dir) = directory_entries(bytes, at, order, false, opts.max_entries) else { return PrivateFields::default() }; + let mut budget = opts.budget_for(bytes.len()); + let (mut offset, mut length, mut key) = (None, None, None); + for p in dir.positions() { + let Some(span) = entry_span(bytes, p, order, false, 0) else { continue }; + if span.len > budget { + continue; + } + budget -= span.len; + match span.tag { + SUBIFD_OFFSET if offset.is_none() => offset = Some(first_number(bytes, &span, order)), + SUBIFD_LENGTH if length.is_none() => length = Some(first_number(bytes, &span, order)), + SUBIFD_KEY if key.is_none() => key = Some(four_bytes(bytes, &span, order)), + _ => {} + } + } + PrivateFields { offset: offset.flatten(), length: length.flatten(), key: key.flatten() } +} + +/// The entry's first value as the reader's `u64` lookup reads it (an unsigned or non-negative integer), as a `usize`. +fn first_number(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<usize> { + if span.count == 0 { + return None; + } + let start = usize::try_from(span.start).ok()?; + let first = bytes.get(start..start.checked_add(span.field_type.size())?)?; + usize::try_from(decode_value(order, span.field_type, first).get_u64(0)?).ok() +} + +/// The entry's bytes as the reader's `bytes` lookup gives them, when they are exactly four. Decoding the first five +/// value bytes settles it: BYTE and UNDEFINED give all their bytes, ASCII its text before the first NUL, which +/// decoding never makes shorter. +fn four_bytes(bytes: &[u8], span: &EntrySpan, order: ByteOrder) -> Option<[u8; 4]> { + let start = usize::try_from(span.start).ok()?; + let head = bytes.get(start..start.checked_add(usize::try_from(span.len).ok()?.min(5))?)?; + <[u8; 4]>::try_from(decode_value(order, span.field_type, head).as_bytes()?).ok() +} + #[cfg(test)] pub(crate) mod tests { use super::*; + /// TIFF field types. + const BYTE: u16 = 1; + const LONG: u16 = 4; + const UNDEFINED: u16 = 7; + const IFD: u16 = 13; use lightcraft_tiff::ByteOrder::{Big, Little}; /// An `SR2SubIFD` block for file offset `start`, encrypted: SHORT/SSHORT entries `(tag, type, values)`, values @@ -345,6 +409,200 @@ pub(crate) mod tests { } } + /// An `SR2Private` directory of `entries` `(tag, type, count, value field)` at file offset 0. + fn private(entries: &[(u16, u16, u32, [u8; 4])], order: ByteOrder) -> Vec<u8> { + let mut out = Vec::new(); + order.put_u16(&mut out, entries.len() as u16); + for &(tag, kind, count, value) in entries { + order.put_u16(&mut out, tag); + order.put_u16(&mut out, kind); + order.put_u32(&mut out, count); + out.extend(value); + } + order.put_u32(&mut out, 0); + out + } + + /// `(offset, length, key)` as one of the readers selects them. + type Selected = (Option<usize>, Option<usize>, Option<[u8; 4]>); + + /// What `SubIfd::read` on main selects: the TIFF reader's parse of the directory with main's options, then the + /// directory's `u64` and `bytes` lookups. + fn reader_selects(bytes: &[u8], at: usize, order: ByteOrder) -> Selected { + let opts = lightcraft_tiff::ParseOptions { max_ifds: 1, max_depth: 0, follow_children: false, ..Default::default() }; + let Ok((private, _)) = lightcraft_tiff::parse_ifd_at(bytes, at as u64, order, 0, false, &opts) else { return (None, None, None) }; + let n = |tag: u16| private.u64(tag).and_then(|v| usize::try_from(v).ok()); + (n(SUBIFD_OFFSET), n(SUBIFD_LENGTH), private.bytes(SUBIFD_KEY).and_then(|b| <[u8; 4]>::try_from(b).ok())) + } + + /// Synthetic `SR2Private` directories at offset 0, with what each tests: filler entries ahead of the fields, and + /// unreadable, unusual or valid first occurrences of each field ahead of a valid second one. + fn differential_cases(order: ByteOrder) -> Vec<(String, Vec<u8>)> { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let h = |a: u16, b: u16| { + let mut out = Vec::new(); + order.put_u16(&mut out, a); + order.put_u16(&mut out, b); + [out[0], out[1], out[2], out[3]] + }; + let valid = [(SUBIFD_OFFSET, LONG, 1, w(4096)), (SUBIFD_LENGTH, LONG, 1, w(64)), (SUBIFD_KEY, UNDEFINED, 4, KEY)]; + let mut cases = Vec::new(); + for fillers in [0usize, 1, 255, 256, 257, 8189, 8190, 8191, 8192, 8193] { + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + entries.extend(valid); + cases.push((format!("{fillers} fillers"), private(&entries, order))); + } + // a first occurrence ahead of the valid one; pointer values point into a 32-byte tail after the directory + let tail_at = 2 + 12 * 4 + 4; + let mut tail: Vec<u8> = (1..=32).collect(); + tail[4..8].copy_from_slice(b"\x11\x22\x33\x44"); + tail[8] = 0; + let numeric: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type 0", 0, 1, w(8)), + ("unknown type 14", 14, 1, w(8)), + ("unknown type 0xffff", 0xffff, 1, w(8)), + ("LONG[2] past the file", LONG, 2, w(u32::MAX)), + ("LONG[2] straddling the end", LONG, 2, w(tail_at as u32 + 28)), + ("LONG[u32::MAX]", LONG, u32::MAX, w(tail_at as u32)), + ("DOUBLE[u32::MAX]", 12, u32::MAX, w(tail_at as u32)), + ("LONG[0]", LONG, 0, w(0)), + ("LONG[2] in the file", LONG, 2, w(tail_at as u32)), + ("SHORT[1]", SHORT, 1, h(9, 0)), + ("SHORT[2] inline", SHORT, 2, h(9, 10)), + ("BYTE[3]", BYTE, 3, [5, 6, 7, 0]), + ("SSHORT -1", SSHORT, 1, h(0xffff, 0)), + ("SSHORT 5", SSHORT, 1, h(5, 0)), + ("SLONG -1", 9, 1, w(u32::MAX)), + ("LONG8 at a pointer", 16, 1, w(tail_at as u32)), + ("RATIONAL", 5, 1, w(tail_at as u32)), + ("ASCII", 2, 4, *b"abc\0"), + ("IFD", IFD, 1, w(4096)), + ]; + for (tag, other) in [(SUBIFD_OFFSET, [valid[1], valid[2]]), (SUBIFD_LENGTH, [valid[0], valid[2]])] { + for (what, kind, count, field) in &numeric { + let valid_one = if tag == SUBIFD_OFFSET { valid[0] } else { valid[1] }; + let mut d = private(&[(tag, *kind, *count, *field), valid_one, other[0], other[1]], order); + d.extend(&tail); + cases.push((format!("{tag:#x}: first {what}"), d)); + } + } + let keys: Vec<(&str, u16, u32, [u8; 4])> = vec![ + ("unknown type", 0, 4, KEY), + ("UNDEFINED[8] past the file", UNDEFINED, 8, w(u32::MAX)), + ("UNDEFINED[5] in the file", UNDEFINED, 5, w(tail_at as u32)), + ("UNDEFINED[3]", UNDEFINED, 3, KEY), + ("UNDEFINED[0]", UNDEFINED, 0, KEY), + ("BYTE[4]", BYTE, 4, [1, 2, 3, 4]), + ("SBYTE[4]", 6, 4, KEY), + ("ASCII[4]", 2, 4, KEY), + ("ASCII[4] with NUL", 2, 4, [0x41, 0x42, 0, 0x43]), + ("ASCII[4] invalid UTF-8", 2, 4, [0xff, 0x41, 0, 0]), + ("ASCII[4] two invalid bytes", 2, 4, [0xc3, 0x28, 0x41, 0]), + ("ASCII[9] at a pointer, NUL at 4", 2, 9, w(tail_at as u32 + 4)), + ("ASCII[8] at a pointer, no NUL", 2, 8, w(tail_at as u32 + 12)), + ("LONG[1]", LONG, 1, KEY), + ]; + for (what, kind, count, field) in &keys { + let mut d = private(&[(SUBIFD_KEY, *kind, *count, *field), valid[0], valid[1], valid[2]], order); + d.extend(&tail); + cases.push((format!("key: first {what}"), d)); + } + // the reader's value budget (8 × the data's length + 1 MiB), used up by entries aliasing the data + for aliases in [23u32, 24] { + let mut entries = vec![(0x7300, UNDEFINED, 1 << 16, w(0)); aliases as usize]; + entries.extend(valid); + let mut d = private(&entries, order); + d.resize(1 << 16, 0); + cases.push((format!("{aliases} entries using the value budget"), d)); + } + // no entries; more entries declared than the data holds + cases.push(("empty directory".into(), private(&[], order))); + let mut long = private(&valid, order); + long[..2].copy_from_slice(&if order == Little { 10u16.to_le_bytes() } else { 10u16.to_be_bytes() }); + long.truncate(2 + 12 * 3); + cases.push(("10 entries declared, 3 present".into(), long)); + cases + } + + #[test] + fn private_fields_match_the_tiff_readers_selection() { + let mut differ = Vec::new(); + for order in [Little, Big] { + for (case, d) in differential_cases(order) { + let f = private_fields(&d, 0, order); + let (ours, reader) = ((f.offset, f.length, f.key), reader_selects(&d, 0, order)); + if ours != reader { + differ.push(format!("{order:?} {case}: {ours:?}, reader {reader:?}")); + } + } + assert_eq!(private_fields(&[], 0, order), PrivateFields::default()); + assert_eq!(private_fields(&private(&[], order), u64::MAX, order), PrivateFields::default()); + } + assert!(differ.is_empty(), "{} cases differ from the TIFF reader:\n{}", differ.len(), differ.join("\n")); + // what the reader (and so this) gives in the cases the review named: the limit is 8,192 entries, a later + // copy stands in for an unreadable first one, and a used-up value budget hides the fields + let found = |d: &[u8]| private_fields(d, 0, Little) == PrivateFields { offset: Some(4096), length: Some(64), key: Some(KEY) }; + let cases = differential_cases(Little); + let case = |name: &str| cases.iter().find(|(c, _)| c == name).map(|(_, d)| d.clone()).unwrap(); + for name in [ + "0 fillers", + "256 fillers", + "8189 fillers", + "0x7200: first LONG[2] past the file", + "0x7201: first unknown type 14", + "key: first UNDEFINED[8] past the file", + "23 entries using the value budget", + ] { + assert!(found(&case(name)), "{name}"); + } + for name in ["8190 fillers", "8192 fillers", "24 entries using the value budget", "0x7200: first ASCII", "key: first UNDEFINED[3]"] { + assert!(!found(&case(name)), "{name}"); + } + } + + /// The case that broke on review: valid entries ahead of the three fields, and an unreadable first offset ahead of + /// a valid one. Main's reader finds the block; so must this one (else the black level, white balance and crop it + /// holds are lost). + #[test] + fn the_block_is_found_behind_filler_and_unreadable_entries() { + for order in [Little, Big] { + for (fillers, unreadable_first) in [(256usize, false), (300, false), (0, true), (256, true)] { + let w = |v: u32| { + let mut b = Vec::new(); + order.put_u32(&mut b, v); + [b[0], b[1], b[2], b[3]] + }; + let n = fillers + 3 + usize::from(unreadable_first); + let start = 2 + 12 * n + 4; + let block = block(start, &[(0x7310, SHORT, &[800; 4])], order); + let mut entries = vec![(0x7300, LONG, 1, w(7)); fillers]; + if unreadable_first { + entries.push((SUBIFD_OFFSET, LONG, 2, w(u32::MAX))); + } + entries.extend([ + (SUBIFD_OFFSET, LONG, 1, w(start as u32)), + (SUBIFD_LENGTH, LONG, 1, w(block.len() as u32)), + (SUBIFD_KEY, UNDEFINED, 4, KEY), + ]); + let mut file = private(&entries, order); + assert_eq!(file.len(), start); + file.extend(&block); + let mut pointer = Vec::new(); + order.put_u32(&mut pointer, 0); + let ifd0 = Ifd { + entries: vec![lightcraft_tiff::Entry { tag: t::DNG_PRIVATE_DATA, value: lightcraft_tiff::Value::Byte(pointer), offset: 0 }], + ..Default::default() + }; + let black = SubIfd::read(&file, &ifd0, order).and_then(|s| s.shorts(0x7310)); + assert_eq!(black, Some(vec![800.0; 4]), "{order:?}: {fillers} fillers, unreadable first offset {unreadable_first}"); + } + } + } + #[test] fn hostile_directories_give_none() { let start = 4096; diff --git a/crates/raw/src/vendor/srw.rs b/crates/raw/src/vendor/srw.rs index f64b451ff..b22faeec0 100644 --- a/crates/raw/src/vendor/srw.rs +++ b/crates/raw/src/vendor/srw.rs @@ -184,8 +184,10 @@ pub(crate) fn decode(bytes: &[u8], mode: Mode) -> Result<RawImage> { color: ColorData::default(), wb_multipliers: wb, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), }; img.validate_for(mode)?; Ok(img) diff --git a/crates/raw/src/wb_estimate.rs b/crates/raw/src/wb_estimate.rs new file mode 100644 index 000000000..17a86a2a5 --- /dev/null +++ b/crates/raw/src/wb_estimate.rs @@ -0,0 +1,159 @@ +//! As-shot white balance for raws that record none we can read: measured from the file's own embedded preview. +//! +//! Sources: own design. The camera rendered its preview with the as-shot white balance, so pixels the preview shows +//! as neutral grey were neutral for that white balance. The mosaic is averaged per colour over the preview's pixel +//! grid (inside the default crop); over the preview pixels whose channels lie within a few codes of each other +//! (not near black, not near clipping, no clipped sensor sample under them) the median green-over-red and +//! green-over-blue ratios are the gains. A colour matrix maps neutral to neutral, so its absence doesn't bias the +//! result. Without enough neutral pixels the mean of the unclipped mosaic is used instead (grey world). +use crate::{BlackLevel, Cfa, Rect}; + +/// Preview pixels whose largest and smallest channel differ by less than this (8-bit codes) count as neutral. +const NEUTRAL_SPREAD: u8 = 12; +/// At least this many neutral preview pixels are needed. +const MIN_NEUTRAL: usize = 64; +/// Previews larger than this (pixels) are not decoded for the estimate. +const MAX_PREVIEW_PIXELS: usize = 1 << 24; + +/// The raw frame the estimate looks at. +pub(crate) struct Frame<'a> { + pub data: &'a [u16], + pub width: usize, + pub crop: Rect, + pub cfa: &'a Cfa, + pub black: &'a BlackLevel, + pub white: f32, +} + +/// Gains `[R/G, 1, B/G]`: from the neutral pixels of the embedded preview, else grey world. `None` for an empty or +/// degenerate frame. +pub(crate) fn as_shot(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { + from_preview(bytes, f).or_else(|| grey_world(f)) +} + +/// Per preview-grid cell means of R, G, B (black subtracted) and whether a clipped sample lies in the cell. +fn cell_means(f: &Frame, pw: usize, ph: usize) -> Option<Vec<([f64; 3], bool)>> { + let c = f.crop; + if pw == 0 || ph == 0 || c.width < pw || c.height < ph || f.width == 0 { + return None; + } + let mut cells = vec![([0f64; 3], [0u32; 3], false); pw.checked_mul(ph)?]; + for y in c.y..c.y.saturating_add(c.height) { + let row = f.data.get(y.checked_mul(f.width)?..y.checked_mul(f.width)?.checked_add(f.width)?)?; + let cy = (y - c.y) * ph / c.height; + for x in c.x..c.x.saturating_add(c.width).min(f.width) { + let v = f32::from(*row.get(x)?); + let cx = (x - c.x) * pw / c.width; + let col = usize::from(f.cfa.color_at(x, y)).min(2); + let cell = cells.get_mut(cy * pw + cx)?; + if v >= f.white * 0.98 { + cell.2 = true; + } + cell.0[col] += f64::from(v - f.black.at(x, y, 0, 1)); + cell.1[col] += 1; + } + } + Some(cells.into_iter().map(|(s, n, clip)| ([0, 1, 2].map(|k| if n[k] > 0 { s[k] / f64::from(n[k]) } else { 0.0 }), clip)).collect()) +} + +fn median(v: &mut [f64]) -> Option<f64> { + if v.is_empty() { + return None; + } + v.sort_by(f64::total_cmp); + v.get(v.len() / 2).copied() +} + +fn from_preview(bytes: &[u8], f: &Frame) -> Option<[f32; 3]> { + let jpeg = crate::preview::embedded_preview(bytes)?; + let (px, pw, ph, ch) = crate::tiffraw::lossy_jpeg(&jpeg, 3).ok()?; + if ch != 3 { + return None; + } + from_rgb(&px, pw, ph, f) +} + +/// Gains from an 8-bit RGB preview `pw` x `ph` (rows of RGB triplets) showing the crop: its neutral pixels, else +/// grey world. For raws whose preview is an uncompressed RGB image rather than a JPEG. +pub(crate) fn as_shot_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + from_rgb(px, pw, ph, f).or_else(|| grey_world(f)) +} + +fn from_rgb(px: &[u8], pw: usize, ph: usize, f: &Frame) -> Option<[f32; 3]> { + if pw.checked_mul(ph)? > MAX_PREVIEW_PIXELS || px.len() < pw * ph * 3 { + return None; + } + // the preview must show the crop: aspect ratios within 3% + let (ra, pa) = (f.crop.width as f64 / f.crop.height.max(1) as f64, pw as f64 / ph as f64); + if (ra / pa - 1.0).abs() > 0.03 { + return None; + } + let cells = cell_means(f, pw, ph)?; + let (mut gr, mut gb) = (Vec::new(), Vec::new()); + for (i, (m, clip)) in cells.iter().enumerate() { + let p = px.get(i * 3..i * 3 + 3)?; + let (lo, hi) = (p.iter().min().copied().unwrap_or(0), p.iter().max().copied().unwrap_or(255)); + if *clip || lo < 30 || hi > 240 || hi - lo >= NEUTRAL_SPREAD || m.iter().any(|v| *v <= 1.0) { + continue; + } + gr.push(m[1] / m[0]); + gb.push(m[1] / m[2]); + } + if gr.len() < MIN_NEUTRAL { + return None; + } + gains(median(&mut gr)?, median(&mut gb)?) +} + +fn gains(gr: f64, gb: f64) -> Option<[f32; 3]> { + let ok = |g: f64| g.is_finite() && (0.1..=10.0).contains(&g); + (ok(gr) && ok(gb)).then_some([gr as f32, 1.0, gb as f32]) +} + +fn grey_world(f: &Frame) -> Option<[f32; 3]> { + let c = f.crop; + let (mut s, mut n) = ([0f64; 3], [0u64; 3]); + for y in c.y..c.y.saturating_add(c.height) { + for x in c.x..c.x.saturating_add(c.width).min(f.width) { + let Some(&v) = f.data.get(y.checked_mul(f.width)?.checked_add(x)?) else { continue }; + let v = f32::from(v); + if v >= f.white * 0.98 { + continue; + } + let k = usize::from(f.cfa.color_at(x, y)).min(2); + s[k] += f64::from((v - f.black.at(x, y, 0, 1)).max(0.0)); + n[k] += 1; + } + } + if n.contains(&0) { + return None; + } + let m = [0, 1, 2].map(|k| s[k] / n[k] as f64); + if m.iter().any(|v| *v <= 0.0) { + return None; + } + gains(m[1] / m[0], m[1] / m[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn grey_world_balances_a_tinted_flat_frame() { + // RGGB, R at 400, G at 800, B at 200 above a black of 100 + let (w, h) = (8, 8); + let d: Vec<u16> = (0..w * h).map(|i| [500, 900, 900, 300][(i / w % 2) * 2 + i % 2]).collect(); + let cfa = Cfa::bayer_static("RGGB"); + let black = BlackLevel::uniform(100.0); + let f = Frame { data: &d, width: w, crop: Rect::new(0, 0, w, h), cfa: &cfa, black: &black, white: 4095.0 }; + assert_eq!(as_shot(b"not a raw", &f), Some([2.0, 1.0, 4.0])); + // degenerate: everything clipped + let d = vec![4095u16; w * h]; + let f = Frame { data: &d, ..f }; + assert_eq!(as_shot(b"", &f), None); + // a crop outside the data never panics + let f = Frame { crop: Rect::new(4, 4, 100, 100), ..f }; + let _ = as_shot(b"", &f); + } +} diff --git a/crates/raw/tests/body_corpus.rs b/crates/raw/tests/body_corpus.rs new file mode 100644 index 000000000..109f567fa --- /dev/null +++ b/crates/raw/tests/body_corpus.rs @@ -0,0 +1,204 @@ +//! One CC0 file per Nikon and Fujifilm body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.77–1.00 on these files, a misread layout or stream gives r near 0). +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn nikon_and_fujifilm_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples) + let cases: &[(&str, (usize, usize), u64)] = &[ + ("dng-nikon-d80.dng", (3900, 2611), 0x0f79_2821_2660_abae), + ("nef-nikon-1-aw1.nef", (4620, 3084), 0xbe33_8eec_3c72_fa8f), + ("nef-nikon-1-j1.nef", (3904, 2606), 0x29e0_7678_537c_7bc8), + ("nef-nikon-1-j2.nef", (3904, 2606), 0xd7b6_ca84_0c3f_1260), + ("nef-nikon-1-j3.nef", (4620, 3084), 0x83de_9d80_3aaa_ccf0), + ("nef-nikon-1-j4.nef", (5248, 3504), 0xf226_1ee3_578c_e841), + ("nef-nikon-1-j5.nef", (5584, 3726), 0x525e_c8ca_df29_13f1), + ("nef-nikon-1-s1.nef", (3948, 2604), 0x0464_ed24_fdd0_772d), + ("nef-nikon-1-v1.nef", (3904, 2606), 0xedfd_ff98_70dd_9e89), + ("nef-nikon-1-v2.nef", (4620, 3084), 0x99af_e262_e1d0_6ccb), + ("nef-nikon-1-v3.nef", (5248, 3504), 0x0951_aa90_ad1f_50ae), + ("nef-nikon-coolpix-a.nef", (4992, 3280), 0x875a_ed86_a3ad_09c6), + ("nef-nikon-d1h.nef", (2012, 1324), 0x27ce_4372_4d64_8bc5), + ("nef-nikon-d200.nef", (3904, 2616), 0xd33f_f00a_a90f_0f78), + ("nef-nikon-d2h.nef", (2496, 1648), 0x0ca6_7d68_03aa_4ae1), + ("nef-nikon-d2hs.nef", (2496, 1648), 0x9f70_b976_d7d0_a5a8), + ("nef-nikon-d2x.nef", (4320, 2868), 0xd5b9_d3b6_1efc_6cb8), + ("nef-nikon-d2xs.nef", (4320, 2868), 0x4f84_d44d_0d9f_c09f), + ("nef-nikon-d3000.nef", (3904, 2616), 0x9311_0df8_1dca_1e6a), + ("nef-nikon-d300.nef", (4352, 2868), 0xc7f3_1049_d8f8_17db), + ("nef-nikon-d300s.nef", (4352, 2868), 0x9c8c_97ba_c369_e6ea), + ("nef-nikon-d3100.nef", (4672, 3084), 0x916d_09e7_01cf_0314), + ("nef-nikon-d3200.nef", (6080, 4012), 0xd267_603b_2d71_6fe0), + ("nef-nikon-d3300.nef", (6016, 4016), 0x93ea_fe7f_2bba_3f5c), + ("nef-nikon-d3400.nef", (6016, 4016), 0xa55c_8f4a_662f_f9af), + ("nef-nikon-d3500.nef", (6016, 4016), 0xb1ee_bbd1_346a_3d92), + ("nef-nikon-d3.nef", (4288, 2844), 0x722b_6aaa_4347_1793), + ("nef-nikon-d3s.nef", (4288, 2844), 0x2703_65d8_5629_1ea0), + ("nef-nikon-d3x.nef", (6080, 4044), 0x52c3_e70b_7199_951e), + ("nef-nikon-d40.nef", (3040, 2014), 0x8536_6f21_fbb0_5eb9), + ("nef-nikon-d40x.nef", (3904, 2616), 0xffe9_2bb5_14ca_534f), + ("nef-nikon-d4.nef", (4992, 3292), 0xd8c0_c164_e77e_3d65), + ("nef-nikon-d4s.nef", (4936, 3288), 0xfb04_e701_dab5_189e), + ("nef-nikon-d5000.nef", (4352, 2868), 0xb8f6_8efb_0254_6fbc), + ("nef-nikon-d500.nef", (5600, 3728), 0x9482_f4ef_7856_ad4e), + ("nef-nikon-d50.nef", (3040, 2014), 0xe23b_8f5e_4b96_b285), + ("nef-nikon-d5200.nef", (6036, 4020), 0xd148_2f59_a727_a1e2), + ("nef-nikon-d5300.nef", (6016, 4016), 0xb141_e60b_754f_0ffc), + ("nef-nikon-d5500.nef", (6016, 4016), 0x2ce0_0d79_4dac_db43), + ("nef-nikon-d5600.nef", (6016, 4016), 0x2a20_0cfc_45f1_5db0), + ("nef-nikon-d5.nef", (5584, 3728), 0xfc01_902d_7732_c5ba), + ("nef-nikon-d600.nef", (6080, 4028), 0x9936_16eb_56f3_7729), + ("nef-nikon-d60.nef", (3904, 2616), 0x314f_043e_4a1e_0428), + ("nef-nikon-d610.nef", (6080, 4028), 0x5948_ec20_f2c9_f1ff), + ("nef-nikon-d700.nef", (4288, 2844), 0x507c_19fc_f74a_ce38), + ("nef-nikon-d70.nef", (3040, 2014), 0xb148_5e4c_0948_bcf7), + ("nef-nikon-d70s.nef", (3040, 2014), 0xcd2e_6f87_1fbe_8c5e), + ("nef-nikon-d7100.nef", (6036, 4020), 0x0cbd_07b1_c8d0_5524), + ("nef-nikon-d7200.nef", (6016, 4016), 0xe0a0_2fd9_5e26_c48b), + ("nef-nikon-d750.nef", (6032, 4032), 0xc229_3104_e9c0_8a7f), + ("nef-nikon-d780.nef", (6064, 4040), 0x9394_1205_66c4_0394), + ("nef-nikon-d800e.nef", (7424, 4924), 0x2ebe_8c60_c0fd_0c98), + ("nef-nikon-d800.nef", (7424, 4924), 0x205d_3756_412d_9312), + ("nef-nikon-d810.nef", (7380, 4928), 0x7065_5115_76ac_80d5), + ("nef-nikon-d850.nef", (6216, 4136), 0x7735_4e78_daec_87f0), + ("nef-nikon-d90.nef", (4352, 2868), 0x648c_5bdf_7287_4b88), + ("nef-nikon-df.nef", (4992, 3292), 0x563f_1897_96a9_31df), + ("nef-nikon-z-30.nef", (5600, 3728), 0x949f_f3eb_d96e_2444), + ("nef-nikon-z50-2.nef", (5600, 3728), 0xb896_6b21_ac27_ad07), + ("nef-nikon-z-50.nef", (5600, 3728), 0x3409_8168_87db_9bee), + ("nef-nikon-z-5.nef", (6040, 4032), 0x214b_cc7b_5954_7ad2), + ("nef-nikon-z-6-2.nef", (6064, 4040), 0xd40b_c647_373d_c2f8), + ("nef-nikon-z-7-2.nef", (8288, 5520), 0xde38_97ec_16cc_c71e), + ("nef-nikon-z-9.nef", (8280, 5520), 0x7be1_80fd_f125_bc18), + ("nef-nikon-z-fc.nef", (5600, 3728), 0xa65e_5bf4_6937_3625), + ("nrw-nikon-coolpix-a1000.nrw", (4608, 3456), 0x2ab8_7617_000c_0a7d), + ("nrw-nikon-coolpix-p1000.nrw", (4624, 3470), 0xb512_6adb_9e72_6cae), + ("nrw-nikon-coolpix-p1100.nrw", (4624, 3470), 0x7262_1601_b15b_4bba), + ("nrw-nikon-coolpix-p950.nrw", (4624, 3470), 0xdc59_8f4d_a414_edfb), + ("raf-fujifilm-finepix-f550exr.raf", (3328, 2464), 0x4247_157c_9d70_b359), + ("raf-fujifilm-finepix-f770exr.raf", (3328, 2464), 0x6df4_5810_a581_1e9b), + ("raf-fujifilm-finepix-hs30exr.raf", (3328, 2464), 0x5d4c_2901_70fa_83c3), + ("raf-fujifilm-finepix-hs33exr.raf", (3328, 2464), 0x7172_f98c_0034_0dde), + ("raf-fujifilm-finepix-hs50exr.raf", (3328, 2464), 0xad53_aa23_e481_4fcc), + ("raf-fujifilm-finepix-s1.raf", (4624, 3470), 0xde37_e148_27f2_4305), + ("raf-fujifilm-finepix-sl1000.raf", (4624, 3470), 0xcbb1_b0f1_9dce_bdbc), + ("raf-fujifilm-finepix-x100.raf", (4448, 2870), 0x8c7b_bdea_47c3_01bb), + ("raf-fujifilm-finepix-x10.raf", (2944, 2144), 0xb1a0_13ee_0889_65ef), + ("raf-fujifilm-gfx100-ii.raf", (11808, 8754), 0x5677_9820_a59b_43d8), + ("raf-fujifilm-gfx100s-ii.raf", (11808, 8754), 0x9bb6_d871_b488_1a2b), + ("raf-fujifilm-gfx-50r.raf", (9216, 6210), 0x2da2_1f02_2188_01e9), + ("raf-fujifilm-gfx50s-ii.raf", (8472, 6210), 0x0657_0932_cc9b_04a9), + ("raf-fujifilm-x100f.raf", (6048, 4038), 0x44d7_06fd_e255_f55c), + ("raf-fujifilm-x100s.raf", (4992, 3296), 0x8bb0_be8f_8e2f_0114), + ("raf-fujifilm-x100t.raf", (4992, 3296), 0x83a0_fc55_ba4a_8a34), + ("raf-fujifilm-x100vi.raf", (7872, 5196), 0xe0bd_5ff9_a791_3b1e), + ("raf-fujifilm-x100v.raf", (6384, 4182), 0x02ee_e2eb_3f29_017e), + ("raf-fujifilm-x20.raf", (4096, 3012), 0xc13a_34e9_bedb_7b65), + ("raf-fujifilm-x30.raf", (4096, 3012), 0x9d28_789e_b37a_d23a), + ("raf-fujifilm-x70.raf", (4992, 3296), 0xb49d_8575_ed65_191c), + ("raf-fujifilm-x-a10.raf", (4912, 3278), 0xb681_5176_24ae_ea80), + ("raf-fujifilm-x-a1.raf", (5120, 3288), 0xbfe5_5bc3_818d_aff0), + ("raf-fujifilm-x-a3.raf", (6016, 4014), 0xab8b_9cf0_25dc_29dd), + ("raf-fujifilm-x-a7.raf", (6016, 4014), 0x3c75_95af_4bdb_5d1e), + ("raf-fujifilm-x-e2.raf", (4992, 3296), 0x18b2_ac1b_a7ec_e687), + ("raf-fujifilm-x-e2s.raf", (4992, 3296), 0x334a_900e_4d2e_6f7a), + ("raf-fujifilm-x-e3.raf", (6160, 4032), 0x9dae_1182_a6ff_f11a), + ("raf-fujifilm-x-e4.raf", (6384, 4182), 0x05ea_0600_153c_af50), + ("raf-fujifilm-xf10.raf", (6016, 4014), 0xc8fb_db43_3419_aee8), + ("raf-fujifilm-xf1.raf", (2944, 2144), 0x9860_bc05_28ca_7b68), + ("raf-fujifilm-x-h1.raf", (6048, 4038), 0x235f_cef5_1ece_f7e0), + ("raf-fujifilm-x-m1.raf", (5120, 3288), 0xe6a2_89ab_2a70_7f59), + ("raf-fujifilm-x-pro1.raf", (5120, 3288), 0xf5eb_1273_c2d4_0d05), + ("raf-fujifilm-x-pro2.raf", (6160, 4032), 0xf268_21e1_9eac_3aaf), + ("raf-fujifilm-x-pro3.raf", (6384, 4182), 0x08d9_6383_c9c5_68da), + ("raf-fujifilm-xq1.raf", (4096, 3012), 0x5e6f_2478_974a_775f), + ("raf-fujifilm-xq2.raf", (4096, 3012), 0x755d_a621_b230_a779), + ("raf-fujifilm-x-s10.raf", (6384, 4182), 0xc987_263e_0cb0_99ee), + ("raf-fujifilm-x-s1.raf", (2944, 2144), 0xfeb2_062d_7766_3ed6), + ("raf-fujifilm-x-s20.raf", (6336, 4182), 0x2b15_357d_7db0_1e0a), + ("raf-fujifilm-x-t100.raf", (6016, 4014), 0xfc92_1c91_2075_1751), + ("raf-fujifilm-x-t10.raf", (4992, 3296), 0x84cf_2c71_7140_74d0), + ("raf-fujifilm-x-t1.raf", (4992, 3296), 0x944d_128e_65c7_6375), + ("raf-fujifilm-x-t200.raf", (6016, 4014), 0xc235_b051_f6e0_8ccc), + ("raf-fujifilm-x-t30-iii.raf", (6336, 4182), 0x635b_00d6_8d24_d41e), + ("raf-fujifilm-x-t30-ii.raf", (6384, 4182), 0x88b7_2d0c_92bc_4d9b), + ("raf-fujifilm-x-t30.raf", (6384, 4182), 0xd589_f85d_366e_6e2a), + ("raf-fujifilm-x-t3.raf", (6384, 4182), 0x8d50_a172_374f_a578), + ]; + let mut seen = 0; + for &(name, sensor, hash) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Nikon / Fujifilm bodies checked on {seen} files"); +} diff --git a/crates/raw/tests/canon_pentax_corpus.rs b/crates/raw/tests/canon_pentax_corpus.rs new file mode 100644 index 000000000..db7da12ed --- /dev/null +++ b/crates/raw/tests/canon_pentax_corpus.rs @@ -0,0 +1,192 @@ +//! One CC0 file per Canon CR2, Pentax and Ricoh body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.69–1.00 on these files, a misread layout or stream gives r near 0). Four files are +//! pinned only: two have no embedded JPEG (GX200, and the PowerShot S2 IS, a DNG written by CHDK), one is monochrome (K-3 Mark III +//! Monochrome), and the GXR's preview is not oriented like its sensor; their renders were inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn canon_pentax_and_ricoh_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("cr2-canon-eos-1000d.cr2", (3948, 2622), 0x7de9_a900_6ae5_ccb6, true), + ("cr2-canon-eos-1d-mark-ii-n.cr2", (3596, 2360), 0xbfbf_beba_d32c_efdf, true), + ("cr2-canon-eos-1d-mark-iii.cr2", (3984, 2622), 0x5fbc_a832_828e_c45b, true), + ("cr2-canon-eos-1d-mark-iv.cr2", (5120, 3318), 0xda97_8f13_a35b_978f, true), + ("cr2-canon-eos-1d-x-mark-ii.cr2", (5568, 3708), 0xf3cb_7865_8c90_f41f, true), + ("cr2-canon-eos-2000d.cr2", (6096, 4051), 0xe687_d928_d4e7_821e, true), + ("cr2-canon-eos-20d.cr2", (3596, 2360), 0x20bf_cfd0_3ec3_7915, true), + ("cr2-canon-eos-30d.cr2", (3596, 2360), 0x5f49_abf5_3c51_2fc9, true), + ("cr2-canon-eos-4000d.cr2", (5344, 3516), 0x4f62_9ac5_37ce_9b8e, true), + ("cr2-canon-eos-400d.cr2", (3948, 2622), 0x3b42_3e9a_e59f_3558, true), + ("cr2-canon-eos-450d.cr2", (4312, 2876), 0x47ad_c3fe_76a6_bd06, true), + ("cr2-canon-eos-500d.cr2", (4832, 3204), 0xbb88_0e01_7f8b_8f36, true), + ("cr2-canon-eos-50d.cr2", (4832, 3228), 0xd4c1_61ba_c2f1_a492, true), + ("cr2-canon-eos-5d-mark-iv.cr2", (6880, 4544), 0xf62e_e9b9_6976_b2cf, true), + ("cr2-canon-eos-5d.cr2", (4476, 2954), 0xf0b6_2f3e_3971_7785, true), + ("cr2-canon-eos-5ds.cr2", (8896, 5920), 0x3e2e_5ff4_4f53_8920, true), + ("cr2-canon-eos-600d.cr2", (5344, 3516), 0xe962_86a6_f187_091e, true), + ("cr2-canon-eos-60d.cr2", (5344, 3516), 0x6b47_1bd4_8f4e_3aca, true), + ("cr2-canon-eos-6d-mark-ii.cr2", (6384, 4224), 0xc9f6_08ad_6cf8_6969, true), + ("cr2-canon-eos-70d.cr2", (5568, 3708), 0x1dba_52ca_716c_8594, true), + ("cr2-canon-eos-750d.cr2", (6096, 4056), 0xa8d2_87ba_7428_bfd6, true), + ("cr2-canon-eos-760d.cr2", (6096, 4056), 0xc9d1_a2a0_4555_77c8, true), + ("cr2-canon-eos-77d.cr2", (6288, 4056), 0x3bf3_dffd_d202_66e8, true), + ("cr2-canon-eos-7d-mark-ii.cr2", (5568, 3708), 0x4544_6618_a169_4540, true), + ("cr2-canon-eos-kiss-digital-n.cr2", (3516, 2328), 0x1cc0_14a6_4e5d_f2a6, true), + ("cr2-canon-eos-kiss-x80.cr2", (5344, 3516), 0x7d12_8887_a0bc_8544, true), + ("cr2-canon-eos-kiss-x9.cr2", (6288, 4056), 0x3a83_f960_1d45_854d, true), + ("cr2-canon-eos-m.cr2", (5280, 3528), 0x845d_bcda_107b_1161, true), + ("cr2-canon-eos-m10.cr2", (5280, 3528), 0x312a_4709_2c94_f887, true), + ("cr2-canon-eos-m100.cr2", (6288, 4056), 0x4bde_865a_2037_f121, true), + ("cr2-canon-eos-m2.cr2", (5280, 3528), 0x2ed7_e8c4_39d7_7f15, true), + ("cr2-canon-eos-m3.cr2", (6096, 4056), 0x958d_834a_3e39_fc1c, true), + ("cr2-canon-eos-m5.cr2", (6288, 4056), 0x39af_0b9f_4570_e9f5, true), + ("cr2-canon-eos-m6.cr2", (6288, 4056), 0xf4c8_36b3_2057_69f3, true), + ("cr2-canon-eos-rebel-sl1.cr2", (5280, 3528), 0xbcea_0c6b_0df2_4b7d, true), + ("cr2-canon-eos-rebel-t3.cr2", (4352, 2874), 0xb51e_0a5b_73ce_9ac3, true), + ("cr2-canon-eos-rebel-t4i.cr2", (5280, 3528), 0xf3e2_4be6_7049_9fac, true), + ("cr2-canon-eos-rebel-t5.cr2", (5344, 3516), 0xbd11_9ab9_f322_6e55, true), + ("cr2-canon-eos-rebel-t5i.cr2", (5280, 3528), 0xfb93_e24f_d77a_6db6, true), + ("cr2-canon-eos-rebel-t7i.cr2", (6288, 4056), 0x7657_7d0a_911d_9c04, true), + ("cr2-canon-powershot-g1-x-mark-ii.cr2", (4480, 3366), 0xd4b6_ced9_a68d_d520, true), + ("cr2-canon-powershot-g1-x-mark-iii.cr2", (6288, 4056), 0x7a65_598c_7df5_ef2a, true), + ("cr2-canon-powershot-g1-x.cr2", (4496, 3366), 0x0d02_3f27_892d_67ae, true), + ("cr2-canon-powershot-g11.cr2", (3744, 2784), 0x785b_5b63_ad8e_b78a, true), + ("cr2-canon-powershot-g12.cr2", (3744, 2784), 0xd5e2_266b_d066_5db0, true), + ("cr2-canon-powershot-g15.cr2", (4160, 3124), 0xf234_7cc5_3ac7_4a42, true), + ("cr2-canon-powershot-g16.cr2", (4192, 3062), 0xba01_a612_ba7c_bffe, true), + ("cr2-canon-powershot-g3-x.cr2", (5632, 3710), 0x8a1e_46e0_3d78_1879, true), + ("cr2-canon-powershot-g5-x.cr2", (5632, 3710), 0xc023_524c_2998_f93d, true), + ("cr2-canon-powershot-g7-x-mark-ii.cr2", (5632, 3710), 0x9d71_9788_df4e_38d8, true), + ("cr2-canon-powershot-g7-x.cr2", (5632, 3710), 0xe47b_2d8a_2566_9a60, true), + ("cr2-canon-powershot-g9-x-mark-ii.cr2", (5632, 3710), 0xfe91_af24_c438_f82a, true), + ("cr2-canon-powershot-g9-x.cr2", (5632, 3710), 0xc91d_c1ca_ee96_3e2a, true), + ("cr2-canon-powershot-g9.cr2", (4104, 3048), 0xee20_d244_64ec_8952, true), + ("cr2-canon-powershot-s100.cr2", (4160, 3124), 0x0f16_9e95_92e6_4275, true), + ("cr2-canon-powershot-s100v.cr2", (4160, 3124), 0x3973_5bf8_19c5_7ffd, true), + ("cr2-canon-powershot-s110.cr2", (4160, 3124), 0x8ef0_f13b_7b64_98fb, true), + ("cr2-canon-powershot-s120.cr2", (4192, 3062), 0x48c5_76b9_6afb_4a60, true), + ("dng-canon-powershot-s2-is.dng", (2672, 1968), 0x1ba0_5636_0928_545f, false), + ("cr2-canon-powershot-s95.cr2", (3744, 2784), 0x85a7_3f89_0720_7856, true), + ("cr2-canon-powershot-sx50-hs.cr2", (4176, 3062), 0x4466_4918_96d5_0162, true), + ("cr2-canon-powershot-sx60-hs.cr2", (4768, 3516), 0xae5f_336d_4abc_f0a1, true), + ("dng-pentax-645d.dng", (7424, 5552), 0xbf13_df20_b6fc_e20c, true), + ("dng-pentax-645z.dng", (8384, 6208), 0x2529_54b5_a64e_1536, true), + ("dng-pentax-k-01.dng", (4960, 3300), 0x57ee_22b2_2cb5_b2cf, true), + ("dng-pentax-k-1-mark-ii.dng", (7392, 4950), 0xf2ed_12fa_9824_7028, true), + ("dng-pentax-k-3-ii.dng", (6080, 4032), 0x921b_d488_3bc3_a6af, true), + ("dng-pentax-k-3-mark-iii-monochrome.dng", (6304, 4224), 0x3fc2_04b3_4c78_8959, false), + ("dng-pentax-k-30.dng", (4960, 3300), 0xe665_6a25_81ec_4895, true), + ("dng-pentax-k-5-ii.dng", (4992, 3284), 0xc6b0_b56b_76ab_74d6, true), + ("dng-pentax-k-5.dng", (4992, 3284), 0xd020_63f6_a9a5_c566, true), + ("dng-pentax-k-50.dng", (4960, 3300), 0x7728_7535_4feb_08e3, true), + ("dng-pentax-k-500.dng", (4960, 3300), 0xbc6c_575a_e188_acbe, true), + ("dng-pentax-k-7.dng", (4736, 3136), 0xd7c8_ec3d_5bbc_0b22, true), + ("dng-pentax-k-s1.dng", (5504, 3664), 0xa987_c182_22c8_8fe3, true), + ("dng-pentax-k-s2.dng", (5504, 3664), 0x056e_63c6_87e4_e504, true), + ("dng-pentax-k-x.dng", (4352, 2868), 0xad8d_0b39_7a77_bfb7, true), + ("dng-pentax-k200d.dng", (3936, 2624), 0xecc6_d5f8_d5e5_6cb3, true), + ("dng-pentax-k20d.dng", (4864, 3136), 0x5bb1_9a98_babd_4bbf, true), + ("dng-pentax-kp.dng", (6112, 4060), 0xa1a6_c4b4_84c5_d21f, true), + ("dng-pentax-mx-1.dng", (4016, 3010), 0xb8e1_011c_4b49_6dc3, true), + ("dng-pentax-q.dng", (4064, 3000), 0xb4e3_dd90_ef7f_874f, true), + ("dng-pentax-q7.dng", (4064, 3000), 0x8b2b_cf5b_a63b_8811, true), + ("dng-ricoh-gr-ii.dng", (4960, 3280), 0x9724_b08b_6259_ba49, true), + ("dng-ricoh-gr-iiix.dng", (6112, 4064), 0x8be6_fa17_2379_4d1a, true), + ("dng-ricoh-gr.dng", (4960, 3280), 0x3e5e_9ce5_a4ac_0dc6, true), + ("dng-ricoh-gx200.dng", (4096, 3014), 0xb7b6_ff86_8f38_3ab2, false), + ("dng-ricoh-gxr.dng", (3672, 2748), 0x6255_34a2_7d07_76aa, false), + ("pef-pentax-ist-d.pef", (3040, 2024), 0x9528_2820_e0c3_6088, true), + ("pef-pentax-ist-dl.pef", (3040, 2024), 0xb1b8_7fec_f109_8f66, true), + ("pef-pentax-ist-ds.pef", (3040, 2024), 0x05ed_70b9_cb1d_a5f8, true), + ("pef-pentax-k-1.pef", (7392, 4950), 0xdf3a_d5b4_a0a4_a1e1, true), + ("pef-pentax-k-3-mark-iii.pef", (6304, 4224), 0x4ea0_a41f_fd71_a0b7, true), + ("pef-pentax-k-70.pef", (6080, 4064), 0xad07_0022_dbfb_4cc6, true), + ("pef-pentax-k-r.pef", (4352, 2868), 0x8638_b64c_a21e_a94d, true), + ("pef-pentax-k100d-super.pef", (3040, 2024), 0xc177_9a26_301a_52a4, true), + ("pef-pentax-k100d.pef", (3040, 2024), 0x2cf8_04e5_757d_1d1c, true), + ("pef-pentax-k110d.pef", (3040, 2024), 0xc273_870f_da44_d3f8, true), + ("pef-pentax-k2000.pef", (3936, 2624), 0xadeb_62eb_f697_35ef, true), + ("pef-pentax-kf.pef", (6080, 4064), 0x0006_b0a3_ad13_45fe, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Canon / Pentax / Ricoh bodies checked on {seen} files"); +} diff --git a/crates/raw/tests/corpus.rs b/crates/raw/tests/corpus.rs index a96165b5e..31e62f7dc 100644 --- a/crates/raw/tests/corpus.rs +++ b/crates/raw/tests/corpus.rs @@ -1,7 +1,8 @@ //! Corpus test over `corpus/raw/**` (git-ignored CC0 samples from raw.pixls.us, fetched with //! `cargo xtask corpus --download`; `LIGHTCRAFT_CORPUS` overrides the corpus root). Skips cleanly when absent. //! -//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report +//! Every file must be recognised, carry an embedded JPEG preview (optional for DNG, generic CFA TIFF, +//! older Panasonic RAW and HEVC-preview CR3), and either decode to a valid image or report //! `Unsupported` for one of the variants we know we don't decode yet. Prints decode times //! (`cargo test -p lightcraft-raw --release --test corpus -- --nocapture`). @@ -25,7 +26,7 @@ const KNOWN_UNSUPPORTED: &[&str] = &[ fn corpus_raw_decodes() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut paths: Vec<PathBuf> = rd.flatten().map(|e| e.path()).filter(|p| p.is_file()).collect(); @@ -41,8 +42,8 @@ fn corpus_raw_decodes() { let t0 = Instant::now(); let preview = embedded_preview(&bytes); let tp = t0.elapsed().as_secs_f64() * 1e3; - // DNG previews are optional (and some carry only an uncompressed RGB thumbnail); vendor raws embed a JPEG, - // except the Panasonic `.RAW` files of 2005–2007 and Canon's explicit HEVC preview tracks. + // DNG and generic CFA TIFF previews can be absent or uncompressed RGB; other vendor raws embed a JPEG, + // except the Panasonic `.RAW` files of 2005–2007, Canon's explicit HEVC preview tracks and Mamiya MEF. if let Some(p) = &preview { let jpeg = p.starts_with(&[0xff, 0xd8]) && p.ends_with(&[0xff, 0xd9]); let jxl = p.starts_with(&[0xff, 0x0a]) || p.starts_with(b"\0\0\0\x0cJXL "); @@ -51,7 +52,11 @@ fn corpus_raw_decodes() { let hevc_preview = fmt == RawFormat::Cr3 && lightcraft_meta::cr3::parse_cr3(&bytes) .is_some_and(|c| c.tracks.iter().any(|t| t.kind == lightcraft_meta::cr3::Cr3TrackKind::Other(*b"HEVC") && t.data.is_some())); - assert!(fmt == RawFormat::Dng || name.starts_with("raw-panasonic-") || hevc_preview, "{name}: no embedded preview"); + // the Mamiya ZD's previews are uncompressed RGB sub-IFDs, not JPEGs + assert!( + matches!(fmt, RawFormat::Dng | RawFormat::CfaTiff | RawFormat::Mef) || name.starts_with("raw-panasonic-") || hevc_preview, + "{name}: no embedded preview" + ); } let preview_kb = preview.as_ref().map_or(0, |p| p.len() / 1024); let t1 = Instant::now(); @@ -159,7 +164,7 @@ fn corpus_cr2_cfa_patterns() { let mut seen = 0; for (name, want) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -189,7 +194,7 @@ fn corpus_cr2_as_shot_white_balance() { "cr2-canon-80d.cr2", ] { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let wb = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")).wb_multipliers.unwrap_or_else(|| panic!("{name}: no as-shot WB")); @@ -206,7 +211,7 @@ fn corpus_nef_compressed_matches_uncompressed() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d5100-lossless.nef")), std::fs::read(dir.join("nef-nikon-d5100-uncompressed.nef"))) else { - eprintln!("skip: D5100 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D5100 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -231,7 +236,7 @@ fn corpus_nef_compressed_matches_uncompressed() { fn corpus_nef_high_efficiency_is_named() { let dir = corpus_root().join("raw"); let (Ok(he), Ok(lossless)) = (std::fs::read(dir.join("nef-nikon-zf-he.nef")), std::fs::read(dir.join("nef-nikon-zf-lossless.nef"))) else { - eprintln!("skip: Z f samples absent"); + lightcraft_testkit::skip!(Corpus, "Z f samples absent"); return; }; for result in [decode(&he).map(|_| ()), probe_info(&he).map(|_| ())] { @@ -248,7 +253,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { let dir = corpus_root().join("raw"); let (Ok(a), Ok(b)) = (std::fs::read(dir.join("nef-nikon-d7500-lossless12.nef")), std::fs::read(dir.join("nef-nikon-d7500-lossless14.nef"))) else { - eprintln!("skip: D7500 NEF pair absent"); + lightcraft_testkit::skip!(Corpus, "D7500 NEF pair absent"); return; }; let (a, b) = (decode(&a).unwrap(), decode(&b).unwrap()); @@ -278,7 +283,7 @@ fn corpus_nef_12_bit_black_level_matches_14_bit() { fn corpus_dngs_carry_profile_looks() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut seen = 0; @@ -327,7 +332,7 @@ fn corpus_dngs_carry_profile_looks() { fn corpus_proraw_sky_matte() { let path = corpus_root().join("raw/dng-apple-iphone12pro-proraw.dng"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let t0 = Instant::now(); @@ -365,7 +370,7 @@ fn corpus_sony_pre2017_colour_metadata() { let mut seen = 0; for (name, black, [r, b], (cw, ch)) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -397,7 +402,7 @@ fn corpus_sony_a7cr_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -464,7 +469,7 @@ fn corpus_sony_a7cm2_codings() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -509,7 +514,7 @@ fn corpus_sony_black_levels_sit_at_the_data_floor() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -567,7 +572,7 @@ fn corpus_sony_sr2_white_balance() { let mut seen = 0; for (name, [r, b]) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -585,7 +590,7 @@ fn corpus_sony_sr2_white_balance() { fn corpus_sony_a900_packed12() { let name = "arw-sony-a900-packed12.arw"; let Ok(bytes) = std::fs::read(corpus_root().join("raw").join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); return; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -607,7 +612,7 @@ fn corpus_sony_16x9_crops() { let bytes = match std::fs::read(dir.join(name)) { Ok(bytes) => bytes, Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; } Err(e) => panic!("{name}: {e}"), @@ -626,6 +631,87 @@ fn corpus_sony_16x9_crops() { eprintln!("Sony 16:9 crops checked on {seen} files"); } +/// Sony ILCE-7RM6: the distortion table in the raw IFD (`0x7037`) becomes a one-plane `WarpRectilinear` in +/// `OpcodeList3` (`vendor::arw_lens_rm6`; the lateral CA table is read but its planes are off by default, and their +/// numbers for these files are checked in `arw_lens_rm6`'s unit tests). Expected: the conversion recipe measured on 54 +/// ILCE-7RM6 photos, evaluated in f64 for each file's tables, decoded active area (the padded lossless tiles) and +/// default crop. All four files carry distortion correction off in camera; the table is applied anyway. +#[test] +fn corpus_sony_lens_tables_become_opcodes() { + let dir = corpus_root().join("raw"); + // (file, active area, default crop x, y, width, height, opcode centre, G-plane k0..k3) + let cases = [ + // not pinned in `cargo xtask corpus` (at most two pins per PR; the ARW 6.0 decoder pins the two Compressed files + // below): checked only where the file is present (RawDB `ILCE-7RM6_FF_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-ff-land-lossless.arw", + (10240, 7168), + (12, 8, 9984, 6656), + [0.488670768629749, 0.4653969582810102], + [0.9717033132764651, 0.023249297636569695, 0.008638979168841135, 0.003091041831374855], + ), + // pinned in `cargo xtask corpus` (RawDB `ILCE-7RM6_APSC_land_RAW.ARW`, CC0) + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + (6656, 4608), + (16, 10, 6528, 4352), + [0.49278737791134486, 0.4743868026915563], + [1.016366854640611, -0.0688824189782517, 0.03615745253291194, -0.009438825010476538], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_FF_land_RAW-C.ARW`, CC0): the same scene as the full-frame + // lossless file, its table one step off at knot 12 (268, not 267), on the decoder's unpadded geometry + ( + "arw6-sony-ilce7rm6-ff-land-c.arw", + (10016, 6672), + (12, 8, 9984, 6656), + [0.49960059910134796, 0.5], + [0.9714600903266707, 0.02234210164294959, 0.001142578769532717, 0.005292120046651031], + ), + // pinned by the ARW 6.0 decoder (RawDB `ILCE-7RM6_APSC_land_RAW-C-HD.ARW`, CC0) + ( + "arw6-sony-ilce7rm6-apsc-land-hq.arw", + (6592, 4372), + (16, 10, 6528, 4352), + [0.4975724472765893, 0.5], + [1.01627551, -0.0618606692, 0.02768349621, -0.005595798468], + ), + ]; + let mut seen = 0; + for (name, (aw, ah), crop, center, k) 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}")); + let (a, c) = (img.active_area, img.crop); + eprintln!("{name}: active area {a:?}, crop {c:?}"); + assert_eq!((a.x, a.y, a.width, a.height), (0, 0, aw, ah), "{name}: active area"); + assert_eq!((c.x, c.y, c.width, c.height), crop, "{name}: default crop"); + assert_eq!(probe_info(&bytes).unwrap().opcodes, img.opcodes, "{name}: the headers-only probe reads the same correction"); + let warps: Vec<_> = img + .opcodes + .list3 + .iter() + .filter_map(|op| match op { + lightcraft_raw::Opcode::WarpRectilinear { planes, center } => Some((planes, center)), + _ => None, + }) + .collect(); + let [(planes, got)] = warps.as_slice() else { panic!("{name}: {:?}", img.opcodes.list3) }; + assert_eq!(planes.len(), 1, "{name}: one plane (G; lateral CA off by default)"); + assert!((got[0] - center[0]).abs() < 1e-6 && (got[1] - center[1]).abs() < 1e-6, "{name}: centre {got:?}, want {center:?}"); + for (i, want) in k.iter().enumerate() { + assert!((planes[0][i] - want).abs() < 1e-6, "{name}: k{i} {}, want {want}", planes[0][i]); + } + seen += 1; + } + eprintln!("Sony lens tables 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}; @@ -727,7 +813,7 @@ fn corpus_panasonic_encodings() { let mut seen = 0; for (name, bits, black, (cw, ch), cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -777,7 +863,7 @@ fn corpus_orf_cfa_patterns() { let mut seen = 0; for (name, cfa) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -812,7 +898,7 @@ fn corpus_samsung_srw() { let mut seen = 0; for (name, bits, active, crop, cfa, whole) in cases { let Ok(bytes) = std::fs::read(dir.join(name)) else { - eprintln!("skip: {name} absent"); + lightcraft_testkit::skip!(Corpus, "{name} absent"); continue; }; let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -941,7 +1027,7 @@ fn corpus_sony_arw6_samples() { fn corpus_raws_keep_their_container_and_are_not_thumbnail_shells() { let dir = corpus_root().join("raw"); let Ok(rd) = std::fs::read_dir(&dir) else { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; }; let mut checked = 0; @@ -1004,7 +1090,7 @@ fn sony_embedded_distortion_is_limited_to_validated_models() { ] { let path = root.join(name); if !path.exists() { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); continue; } let bytes = std::fs::read(&path).unwrap(); @@ -1053,6 +1139,229 @@ fn nikon_in_file_distortion_is_limited_to_validated_bodies() { eprintln!("Nikon distortion boundary: {checked} corpus files checked"); } +/// The Sony samples of the corpus manifest (`RAW_SAMPLES` in `xtask/src/main.rs`) with their pinned SHA-256. +const SONY_SAMPLES: &[(&str, &str)] = &[ + ("arw-sony-a7rm4a-compressed.arw", "690c774f1d7bc1db3fa8c2489762743d8e7586e50b6f65d0b6a61cb467972c67"), + ("arw-sony-a9m2-compressed.arw", "161c2a9da2b5f1e50be6117a0b4da0ce249660b5d7de13568d301c4034716b97"), + ("arw-sony-a7m3-compressed.arw", "250784580ea527442c09004417bb0eead484f2bf3ee8f9121a776ac65bb50d0f"), + ("arw-sony-a7m3-uncompressed.arw", "ece80551abf64949dbe826985a80f1d9b265401ee4ea6b989e1829549616fdcd"), + ("arw-sony-a7m4-14bit.arw", "639a6d4db881f1359e3ea7e1137b314e59417d24e6d4d862b7202111a2c823b2"), + ("arw-sony-a7m4-lossless-l.arw", "851b43c2116c4139104a5036f83ac3b6a148789b2142214dd7192c13972b25b6"), + ("arw-sony-a7m4-lossless-m.arw", "d453005714327addd75bcb99c1c6223173dc92f2a59542d82e6761ef0e0e7571"), + ("arw-sony-a7m4-lossless-s.arw", "cbbd0930c7d8706dff84c68a2004454266e6fd0d8354f5f76a106b5d776e0223"), + ("arw-sony-a7cr-lossless-l.arw", "3e1642f3a1ae7c9f93228c5f09f27e362608e6a4ca1b3e5d8dd9eb8fae576997"), + ("arw-sony-a7cr-lossless-m.arw", "5bcba4acc52a5b902074581b4a2f5fb3c79ec56bd0fdc1746b9e2c0f9d3e9c88"), + ("arw-sony-a7cr-compressed.arw", "f5096e8fbccf0842c8a57763cabd2836608f019eb7fd54f7b22260c2f5bc4da5"), + ("arw-sony-a7cm2-lossless-l.arw", "342d893bd71fd050c273df181e3039d3f12d88947b0662de5de5325c84cc449b"), + ("arw-sony-a7cm2-compressed-m.arw", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e"), + ("arw-sony-a7rm4-14bit-compressed.arw", "e6dafe42643f69ab9d1fd00414b7a1f5104df354bb589201600ac934b29b5e4a"), + ("arw-sony-rx100m3.arw", "cb81392e3a8810231ce341fb45de3e4d1b9e2f87bc47f3c314d084041ccfd138"), + ("arw-sony-rx100.arw", "579a485b5126a25cbd55cbd5dadfa7d09cf021c99cc7d4869f9e56e3f759390b"), + ("arw-sony-a7rm2-12bit-uncompressed.arw", "71e0888396ef52c7e6a990b4e72ebb4d8413a1fc1c35bada449870539cef6e39"), + ("arw-sony-a500.arw", "1407fb596a391df67c15b90026a1a76702a2e7386dbdff2f0815b903ea49cead"), + ("arw-sony-a700.arw", "3159e28892bf0771d01525cd4b6190f9c15bbb19fa2fab6d2515ced0594e8f40"), + ("arw-sony-a33.arw", "1a59856394f10d4fadb40f5ab9c6d1c89f473f4dbcdefd216fbbbbf1ad8d21f9"), + ("arw-sony-a3500-5600k.arw", "04c4fe04425c3e6fff7c8af0f811923c35e750300373357bd54342150c5505d8"), + ("arw-sony-a7s-shade.arw", "7cc338a0abc8fdad32d61006f1f8f412297b93e040c7efba572eb2bd8f8e8be2"), + ("arw-sony-a7sm2-16x9.arw", "46044fb6a9c805f2b4915cd4970316b4d04b0566bcf5287afaad3ef648d7a3db"), + ("arw-sony-a580-16x9.arw", "5b0924d39151239dce19e92e08318f4f62a5a8ac276bd7180ec8463d2cfee709"), + ("arw-sony-a900-packed12.arw", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50"), +]; + +/// SHA-256 of [`SONY_SAMPLES`] as `name sha256` lines sorted by name: anchors the pins independently of the +/// manifest, so editing both together (as a drift or a mistake) still fails `sony_pins_match_the_corpus_manifest`. +/// All 25 pins were checked against the downloaded files when it was set. +const SONY_PINS_SHA256: &str = "4cfc349194278146356b463c59ede500331c6ca5fc50aec4e92d7cd847b17b68"; + +/// A Sony corpus file's bytes, checked against its pinned SHA-256 before anything decodes them; `None` (with a +/// note) when the file is absent, unless `LIGHTCRAFT_CORPUS_REQUIRED=1` asks for every pinned file. A file that +/// differs from its pin fails the test. +fn pinned_sony(root: &Path, name: &str) -> Option<Vec<u8>> { + use sha2::{Digest, Sha256}; + let expected = SONY_SAMPLES.iter().find(|(n, _)| *n == name).unwrap_or_else(|| panic!("{name}: no pinned SHA-256")).1; + let bytes = match std::fs::read(root.join(name)) { + Ok(bytes) => bytes, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + assert!(std::env::var_os("LIGHTCRAFT_CORPUS_REQUIRED").is_none_or(|v| v != "1"), "{name}: absent, and LIGHTCRAFT_CORPUS_REQUIRED=1"); + eprintln!("skip: {name} absent"); + return None; + } + Err(e) => panic!("{name}: {e}"), + }; + let actual: String = Sha256::digest(&bytes).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(actual, expected, "{name}: corpus file differs from its pinned SHA-256"); + Some(bytes) +} + +#[test] +fn sony_pins_match_the_corpus_manifest() { + use sha2::{Digest, Sha256}; + let mut lines: Vec<String> = SONY_SAMPLES.iter().map(|(name, sha)| format!("{name} {sha}\n")).collect(); + lines.sort(); + let digest: String = Sha256::digest(lines.concat()).iter().map(|b| format!("{b:02x}")).collect(); + assert_eq!(digest, SONY_PINS_SHA256, "the pinned Sony SHA-256 table changed: check every changed pin against its file, then update the anchor"); + let manifest = include_str!("../../../xtask/src/main.rs"); + for (name, sha) in SONY_SAMPLES { + let entry = manifest.find(&format!("\"{name}\"")).unwrap_or_else(|| panic!("{name} is not in the manifest")); + assert!(manifest[entry..].split_once("),").is_some_and(|(e, _)| e.contains(sha)), "{name}: pinned SHA-256 differs from the manifest"); + } +} + +#[test] +#[should_panic(expected = "differs from its pinned SHA-256")] +fn a_sony_fixture_that_differs_from_its_pin_fails() { + struct Gone(PathBuf); + impl Drop for Gone { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + let dir = Gone(std::env::temp_dir().join(format!("lightcraft-sony-pin-{}", std::process::id()))); + std::fs::create_dir_all(&dir.0).unwrap(); + std::fs::write(dir.0.join("arw-sony-a7cr-compressed.arw"), b"not the pinned file").unwrap(); + pinned_sony(&dir.0, "arw-sony-a7cr-compressed.arw"); +} + +/// Sony's lens-correction records (`lightcraft_raw::lens_metadata`, `crates/raw/src/vendor/arw_lens.rs`) on the public +/// samples: shading, CA and distortion decoded from the raw IFD with their settings, equal to their `SR2SubIFD` copies, +/// the same from header and full decode, and nothing applied beyond the validated ILCE-7RM4A distortion. Expected +/// tables and settings: what ExifTool, run as a black-box tool, prints for these files. Every file is checked against +/// its pinned SHA-256 before it is decoded; absent files are skipped. +#[test] +fn sony_lens_records_of_the_public_samples() { + use lightcraft_raw::Rect; + use lightcraft_raw::lens_metadata::{SonyCopy, SonyLensStatus, SonySettingMeaning as M, SonySource}; + let root = corpus_root().join("raw"); + // ILCE-7CR (Sigma 24-70mm F2.8 DG DN Art at 49.5 mm): one scene in three codings, identical tables + let a7cr_shading = [0, 64, 128, 224, 320, 480, 672, 896, 1184, 1472, 1824, 2240, 2816, 3552, 4512, 7328]; + let a7cr_ca = [ + [-256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -256, -128], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, -128, -128, -256, -256], + ]; + let a7cr_distortion = [4, 1, 1, 3, 7, 13, 22, 35, 51, 72, 98, 132, 173, 223, 279, 348]; + // ILCE-7RM4A (FE 24-105mm F4 G OSS at 24 mm) + let a7rm4a_shading = [0, 32, 112, 256, 448, 688, 976, 1296, 1712, 2336, 3120, 4080, 5104, 6112, 7024, 7904]; + let a7rm4a_ca = [ + [1408, 1280, 1152, 1024, 1024, 1024, 1024, 1024, 896, 896, 896, 768, 768, 640, 512, 384], + [512, 512, 384, 384, 384, 384, 384, 512, 512, 512, 512, 640, 640, 640, 768, 640], + ]; + let a7rm4a_distortion = [14, 0, -29, -65, -113, -171, -239, -311, -392, -471, -555, -634, -713, -782, -843, -891]; + let a7cr_crop = Rect::new(32, 20, 9504, 6336); + // (file, settings, tables, distortion status, storage, compression, linear YCbCr, Sony crop, default crop) + let cases = [ + ( + "arw-sony-a7cr-compressed.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9600, 6376), + 32767, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-l.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (9728, 6656), + 7, + false, + a7cr_crop, + a7cr_crop, + ), + ( + "arw-sony-a7cr-lossless-m.arw", + [272, 1, 0], + (a7cr_shading, a7cr_ca, a7cr_distortion), + SonyLensStatus::NotValidatedModel, + (6656, 4608), + 7, + true, + Rect::new(20, 12, 6240, 4160), + Rect::new(20, 12, 6240, 4160), + ), + ( + "arw-sony-a7rm4a-compressed.arw", + [257, 1, 17], + (a7rm4a_shading, a7rm4a_ca, a7rm4a_distortion), + SonyLensStatus::Applied, + (9600, 6376), + 32767, + false, + Rect::new(0, 20, 9504, 6336), + Rect::new(32, 20, 9504, 6336), + ), + ]; + let mut checked = 0; + for (name, settings, (shading, ca, distortion), status, (w, h), compression, linear, sony_crop, default_crop) in cases { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let full = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(probe_info(&bytes).unwrap(), full.info(), "{name}"); + let s = full.lens_metadata.sony.as_ref().unwrap_or_else(|| panic!("{name}: no Sony lens record")); + assert!(s.sr2_read, "{name}"); + let got = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.value)); + assert_eq!(got, settings.map(Some), "{name}: settings"); + let meanings = [s.shading.setting.value, s.chromatic_aberration.setting.value, s.distortion.setting.value].map(|v| v.map(|v| v.meaning)); + let forced = if settings[2] == 17 { M::ForcedAuto } else { M::Off }; + assert_eq!(meanings, [Some(M::Auto), Some(M::Auto), Some(forced)], "{name}: setting meanings"); + assert_eq!( + (s.shading.table.value, s.chromatic_aberration.table.value, s.distortion.table.value), + (Some(shading), Some(ca), Some(distortion)), + "{name}" + ); + for (source, raw, sr2, agree) in [ + (s.shading.table.source, s.shading.table.raw_ifd, s.shading.table.sr2, s.shading.table.copies_agree), + ( + s.chromatic_aberration.table.source, + s.chromatic_aberration.table.raw_ifd, + s.chromatic_aberration.table.sr2, + s.chromatic_aberration.table.copies_agree, + ), + (s.distortion.table.source, s.distortion.table.raw_ifd, s.distortion.table.sr2, s.distortion.table.copies_agree), + (s.shading.setting.source, s.shading.setting.raw_ifd, s.shading.setting.sr2, s.shading.setting.copies_agree), + ( + s.chromatic_aberration.setting.source, + s.chromatic_aberration.setting.raw_ifd, + s.chromatic_aberration.setting.sr2, + s.chromatic_aberration.setting.copies_agree, + ), + (s.distortion.setting.source, s.distortion.setting.raw_ifd, s.distortion.setting.sr2, s.distortion.setting.copies_agree), + ] { + assert_eq!((source, raw, sr2, agree), (Some(SonySource::RawIfd), SonyCopy::Valid, SonyCopy::Valid, Some(true)), "{name}: provenance"); + } + assert_eq!( + (s.shading.status, s.chromatic_aberration.status, s.distortion.status), + (SonyLensStatus::NotValidatedComponent, SonyLensStatus::NotValidatedComponent, status), + "{name}" + ); + assert_eq!(full.opcodes.list3.len(), usize::from(status == SonyLensStatus::Applied), "{name}: only applied components are opcodes"); + let g = &s.geometry; + assert_eq!((g.storage_width, g.storage_height, g.compression, g.linear_ycbcr), (w, h, compression, linear), "{name}"); + assert_eq!( + (g.sony_crop, g.default_crop, g.image_crop, g.reference), + (Some(sony_crop), Some(default_crop), sony_crop, default_crop), + "{name}" + ); + assert!(g.native_3_2, "{name}"); + eprintln!("{name}: shading, CA and distortion decoded; distortion {status:?}"); + checked += 1; + } + // every Sony corpus file: only the ILCE-7RM4A's distortion is ever applied, and an applied status is an opcode + for (name, _) in SONY_SAMPLES { + let Some(bytes) = pinned_sony(&root, name) else { continue }; + let info = probe_info(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + let Some(s) = &info.lens_metadata.sony else { continue }; + let applied = s.distortion.status == SonyLensStatus::Applied; + assert_eq!(applied, info.metadata.model.as_deref() == Some("ILCE-7RM4A"), "{name}"); + assert_eq!(info.opcodes.list3.len(), usize::from(applied), "{name}"); + assert!(![s.shading.status, s.chromatic_aberration.status].contains(&SonyLensStatus::Applied), "{name}"); + checked += 1; + } + eprintln!("Sony lens records: {checked} checks on corpus files"); +} + /// Kodak DCS520C (`crates/raw/src/vendor/kodak.rs`): the two-component lossless JPEG strip decodes through the /// file's response curve to a 1736 x 1160 GRBG mosaic with 12-bit white, per-position black levels and the /// white balance of the vendor IFD. Looks for the corpus copy, then the raw.pixls.us folder; skips without either. @@ -1061,7 +1370,7 @@ fn corpus_kodak_dcs520c() { let name = "2573_Kodak - DCS520C - 12bit (3_2).TIF"; let candidates = [corpus_root().join("raw").join("kodak-dcs520c.tif"), Path::new("P:/raw-pixls/Kodak/DCS520C").join(name)]; let Some(bytes) = candidates.iter().find_map(|p| std::fs::read(p).ok()) else { - eprintln!("skip: DCS520C sample absent"); + lightcraft_testkit::skip!(Corpus, "DCS520C sample absent"); return; }; assert_eq!(probe(&bytes), Some(RawFormat::KodakDcs)); @@ -1168,3 +1477,58 @@ fn fuji_embedded_lens_tables_follow_the_validated_models() { } eprintln!("Fuji lens tables: {checked} RAFs, {warped} warps, {vignetted} vignette gains"); } + +/// Epson ERF (`crates/raw/src/vendor/erf.rs`): the 16-byte block strip decodes to a smooth 3040 x 2024 RGGB +/// mosaic with the maker note's black levels, the 3008 x 2000 crop and a white balance measured from the +/// preview's neutral pixels. Skips files that are absent. +#[test] +fn corpus_epson_erf() { + let root = corpus_root().join("raw"); + let mut checked = 0; + for name in ["erf-epson-rd1.erf", "erf-epson-rd1s.erf", "erf-epson-rd1x.erf"] { + let Ok(bytes) = std::fs::read(root.join(name)) else { + eprintln!("skip: {name} absent"); + continue; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Erf), "{name}"); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!((img.width, img.height, img.bits), (3040, 2024, 12), "{name}"); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (20, 12, 3008, 2000), "{name}"); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB"), "{name}"); + assert!(img.black.values.len() == 4 && img.black.values.iter().all(|b| (55.0..=70.0).contains(b)), "{name}: {:?}", img.black.values); + let wb = img.wb_multipliers.unwrap_or_else(|| panic!("{name}: no white balance")); + eprintln!("{name}: wb {wb:?}"); + if name == "erf-epson-rd1s.erf" { + assert!((wb[0] - 2.68).abs() < 0.1 && (wb[2] - 1.31).abs() < 0.1, "{name}: {wb:?}"); + } + // same-colour neighbours differ little: the bit order is right + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let step: f64 = d.chunks(3040).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 3038.0); + assert!(step < 40.0, "{name}: rough {step}"); + assert!(embedded_preview(&bytes).is_some(), "{name}: no preview"); + checked += 1; + } + eprintln!("ERF: {checked} corpus files checked"); +} + +/// Mamiya MEF (`crates/raw/src/vendor/mef.rs`): the uncompressed 12-bit strip decodes to a smooth 4016 x 5344 RGGB +/// mosaic, the crop is the preview's window and the white balance comes from the RGB preview. Skips when absent. +#[test] +fn corpus_mamiya_mef() { + let Ok(bytes) = std::fs::read(corpus_root().join("raw").join("mef-mamiya-zd.mef")) else { + eprintln!("skip: mef-mamiya-zd.mef absent"); + return; + }; + assert_eq!(probe(&bytes), Some(RawFormat::Mef)); + let img = decode(&bytes).unwrap_or_else(|e| panic!("{e}")); + assert_eq!((img.width, img.height, img.bits), (4016, 5344, 12)); + assert_eq!((img.crop.x, img.crop.y, img.crop.width, img.crop.height), (24, 12, 3968, 5312)); + assert_eq!(img.cfa.as_ref().map(|c| c.name()).as_deref(), Some("RGGB")); + assert_eq!(img.orientation, lightcraft_geom::Orientation::from_exif(6)); + let wb = img.wb_multipliers.expect("white balance"); + eprintln!("MEF wb {wb:?}"); + assert!(wb.iter().all(|g| (0.5..=4.0).contains(g)), "{wb:?}"); + let lightcraft_raw::RawData::U16(d) = &img.data else { panic!("float data") }; + let step: f64 = d.chunks(4016).take(400).flat_map(|r| r.windows(3).map(|w| f64::from(w[0].abs_diff(w[2])))).sum::<f64>() / (400.0 * 4014.0); + assert!(step < 40.0, "rough {step}"); +} diff --git a/crates/raw/tests/coverage_sweep_corpus.rs b/crates/raw/tests/coverage_sweep_corpus.rs new file mode 100644 index 000000000..78f384651 --- /dev/null +++ b/crates/raw/tests/coverage_sweep_corpus.rs @@ -0,0 +1,66 @@ +//! Sensor regressions for the public camera-coverage sweep. +//! +//! `docs/raw/coverage-sweep.tsv` records the file URLs, SHA-256 hashes, decoded dimensions, +//! FNV-1a sensor hashes and camera-image comparisons measured on main 25ef65c8. +//! Sensor hashes pin LightCraft's own output, not an independent decoder's output. +//! Hash input is every row-major sample in little-endian order, including masked borders. +//! Fetch the CC0 files with `cargo xtask corpus --download`. Missing files are reported. + +use lightcraft_raw::{RawData, decode, probe_info}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const RECORDS: &str = include_str!("../../../docs/raw/coverage-sweep.tsv"); + +#[test] +fn coverage_sweep_sensor_decodes_are_pinned() { + let corpus = + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")); + let mut checked = 0; + for line in RECORDS.lines().filter(|line| !line.starts_with('#')).skip(1) { + let columns: Vec<_> = line.split('\t').collect(); + assert_eq!(columns.len(), 14, "invalid coverage record: {line}"); + let name = columns[3]; + let path = corpus.join("raw").join(name); + let bytes = match std::fs::read(&path) { + Ok(bytes) => bytes, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => { + eprintln!("skip: {} absent", path.display()); + continue; + } + Err(error) => panic!("{}: {error}", path.display()), + }; + assert_eq!(format!("{:x}", Sha256::digest(&bytes)), columns[5], "{name}: file SHA-256"); + let raw = decode(&bytes).unwrap_or_else(|error| panic!("{name}: {error}")); + raw.validate().unwrap(); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let dimensions: Vec<usize> = columns[6..9].iter().map(|value| value.parse().unwrap()).collect(); + assert_eq!([raw.width, raw.height, raw.cpp], dimensions.as_slice(), "{name}: sensor dimensions"); + let mut hash = 0xcbf2_9ce4_8422_2325_u64; + let mut feed = |byte: u8| hash = (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3); + match &raw.data { + RawData::U16(samples) => { + assert_eq!(columns[9], "u16", "{name}: sample type"); + for sample in samples { + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + RawData::F32(samples) => { + assert_eq!(columns[9], "f32", "{name}: sample type"); + for sample in samples { + assert!(sample.is_finite(), "{name}: non-finite sample"); + for byte in sample.to_le_bytes() { + feed(byte); + } + } + } + } + assert_eq!(format!("{hash:016x}"), columns[10], "{name}: sensor samples changed"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + checked += 1; + eprintln!("checked {name}: {}x{}, {hash:016x}", raw.width, raw.height); + } + eprintln!("camera-coverage sweep: {checked} files checked"); +} diff --git a/crates/raw/tests/cr3_corpus.rs b/crates/raw/tests/cr3_corpus.rs index ab5701cd0..8828192dd 100644 --- a/crates/raw/tests/cr3_corpus.rs +++ b/crates/raw/tests/cr3_corpus.rs @@ -22,7 +22,7 @@ fn check(name: &str, hash: u64, sensor: (usize, usize), developed: (usize, usize fn check_against(name: &str, hash: u64, sensor: (usize, usize), developed: Option<(usize, usize)>, reference: &str) { let path = corpus().join("raw").join(name); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); @@ -118,13 +118,103 @@ fn canon_m50_craw_crop_is_the_shot_aspect() { // which is (12, 12) inside the valid sensor area. let path = corpus().join("raw").join("cr3-canon-m50-craw.cr3"); let Ok(bytes) = std::fs::read(&path) else { - eprintln!("skip: {} absent", path.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", path.display()); return; }; let raw = decode(&bytes).unwrap(); assert_eq!((raw.crop.x, raw.crop.y, raw.crop.width, raw.crop.height), (1012, 12, 4000, 4000)); } +/// Green of the decoded mosaic against the camera's embedded JPEG, on a `gw × gh` grid of the active area +/// (gamma-encoded, Pearson). A misread CRX stream (wrong tile, plane, band or quantiser) leaves a picture that +/// no longer follows the camera's own rendering; lens-corrected compact JPEGs keep it well above 0.6. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("Bayer layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.pattern[(y % 2) * 2 + x % 2] == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +/// One CC0 file per further CR3 body (raw.pixls.us; URLs and SHA-256 in `RAW_SAMPLES`, xtask/src/main.rs). +/// No external decoder was run on these: the sensor hashes pin LightCraft's own decode, every CRX stream +/// decodes, and the mosaic follows the camera's own embedded JPEG (`green_vs_camera_jpeg` on 24 × 16 cells). +/// The renders were also inspected at 100 % for tile, band and quantiser artefacts (none). See docs/cr3.md. +#[test] +fn canon_cr3_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples) + let cases: &[(&str, (usize, usize), u64)] = &[ + ("cr3-canon-m200.cr3", (6288, 4056), 0x4edf_3f62_a6ce_b2ba), + ("cr3-canon-r10.cr3", (6192, 4060), 0xc140_2394_bf17_c2a2), + ("cr3-canon-r50v.cr3", (6192, 4060), 0xd60f_f557_0928_8d27), + ("cr3-canon-r6m3.cr3", (7144, 4760), 0x47a4_cb40_1233_cc80), + ("cr3-canon-powershot-v1-craw.cr3", (5928, 3944), 0xbc54_037c_de0f_472a), + ("cr3-canon-1dxm3-craw.cr3", (5568, 3708), 0x3472_dbe7_a666_1a61), + ("cr3-canon-250d-craw.cr3", (6288, 4056), 0xf4c4_dbf8_1c93_2118), + ("cr3-canon-850d.cr3", (6288, 4056), 0xedac_1b6a_43cf_4204), + ("cr3-canon-90d-craw.cr3", (7128, 4732), 0xfaac_50ca_858e_5563), + ("cr3-canon-m50m2.cr3", (6288, 4056), 0xf36f_717a_9250_ec89), + ("cr3-canon-m6m2-craw.cr3", (7128, 4732), 0x6dc0_4c24_7f9b_6673), + ("cr3-canon-r3.cr3", (6192, 4152), 0x4728_af6f_18b1_cba1), + ("cr3-canon-r50-craw.cr3", (6192, 4060), 0x0354_0075_5fe5_a2a7), + ("cr3-canon-r5-craw.cr3", (8352, 5586), 0xdb64_0502_1fd7_ba92), + ("cr3-canon-r6-craw.cr3", (5568, 3708), 0xd3e0_567e_1ffb_07f4), + ("cr3-canon-r6m2-craw.cr3", (6188, 4120), 0x5b1d_6de8_9562_00d4), + ("cr3-canon-r7.cr3", (7128, 4732), 0xb725_758b_4410_f4af), + ("cr3-canon-r-craw.cr3", (6888, 4546), 0xf9cd_81a3_249f_cfc7), + ("cr3-canon-rp-craw.cr3", (6384, 4224), 0xf48d_f06c_a1d6_7382), + ("cr3-canon-g5xm2-craw.cr3", (5632, 3710), 0xfed8_f701_f231_442f), + ("cr3-canon-g7xm3.cr3", (5632, 3710), 0x902e_be92_2fe7_8046), + ("cr3-canon-sx70.cr3", (5344, 3950), 0x000c_7fe9_fbec_f4c2), + ]; + let mut seen = 0; + for &(name, sensor, hash) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + check_against(name, hash, sensor, None, "the pinned LightCraft decode"); + let raw = decode(&bytes).unwrap(); + let jpeg = lightcraft_raw::embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:34} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("CR3 bodies checked on {seen} files"); +} + #[test] fn canon_rp_lossless_sensor_is_exact() { // raw.pixls.us/getfile.php/4674; SHA256 diff --git a/crates/raw/tests/cr3_variants.rs b/crates/raw/tests/cr3_variants.rs new file mode 100644 index 000000000..566d4169d --- /dev/null +++ b/crates/raw/tests/cr3_variants.rs @@ -0,0 +1,427 @@ +//! The unsupported CRX variant list of `docs/cr3.md`, enforced in CI with synthetic fixtures. +//! +//! Each test wraps a hand-built CRX stream in a minimal but structurally valid CR3 container +//! (`ftyp` + `moov` → `trak` → `stbl` with a `CRAW` entry carrying a `CMP1` descriptor), like the unit +//! fixtures in `vendor/crx.rs` but driven through the public container API. For every unsupported +//! coding variant it asserts the exact `RawError::Unsupported` string, and that header probing +//! (`probe_info`, the import path) and full decoding fail with the *identical* error: when the engine +//! falls back to the embedded preview it shows the reason from either call (`engine/files.rs` +//! `preview_reason`), so the two layers must agree on it. Control fixtures decode successfully, +//! including both edges of each accepted range, so a rejection below can only come from the +//! documented gate. +//! +//! The accepted ranges are the ones in `vendor/crx.rs`: uniform quantization values 4..=44 and +//! adaptive QP values 131..=175. Odd QP-map heights are not a rejected variant any more (the last +//! QP row is used on its own); the unit tests in `vendor/crx.rs` cover them. The deeper +//! `"Canon CRX adaptive low-frequency quantization"` dequantization gate is unreachable through +//! this API: plane validation rejects any gain on the low-frequency bands first. It is defence in +//! depth, not a contract, and has no test here. + +use lightcraft_raw::{RawData, RawError, decode, probe_info}; + +// --- minimal CR3 container ------------------------------------------------- + +fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = (u32::try_from(body.len() + 8).unwrap()).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out +} + +/// A 52-byte `CMP1` descriptor: 4×4 single-tile 14-bit four-plane RGGB lossless, 108 CRX header bytes. +fn cmp1() -> Vec<u8> { + let mut c = vec![0u8; 52]; + c[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + c[2..4].copy_from_slice(&48u16.to_be_bytes()); // base length 48 + the 4-byte header + c[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + for (at, value) in [(8, 4u32), (12, 4), (16, 4), (20, 4)] { + c[at..at + 4].copy_from_slice(&value.to_be_bytes()); // width, height, tile width, tile height + } + c[24] = 14; // bit depth + c[25] = 0x40; // four planes, CFA 0 (RGGB) + c[28..32].copy_from_slice(&108u32.to_be_bytes()); + c +} + +fn set_words(coding: &mut [u8], words: [(usize, u32); 5]) { + for (at, value) in words { + coding[at..at + 4].copy_from_slice(&value.to_be_bytes()); + } +} + +/// 16×16 single-tile three-level 0x100 C-RAW, 540 CRX header bytes. +fn craw_coding() -> Vec<u8> { + let mut c = cmp1(); + set_words(&mut c, [(8, 16), (12, 16), (16, 16), (20, 16), (28, 540)]); + c[26] = 3; // encoding 0, three wavelet levels + c +} + +/// The same image coded as adaptive-quantized 0x200 C-RAW, 872 CRX header bytes. +fn adaptive_coding() -> Vec<u8> { + let mut c = craw_coding(); + c[4..6].copy_from_slice(&0x200u16.to_be_bytes()); + c[28..32].copy_from_slice(&872u32.to_be_bytes()); + c +} + +/// A `CMP1` with the optional median-precision extension (`type 3` burst coding), which `supports()` rejects. +fn cmp1_with_median_precision() -> Vec<u8> { + let mut c = cmp1(); + c[32] = 0x80; // extension present + let mut extension = vec![0u8; 36]; + extension[0..4].copy_from_slice(&36u32.to_be_bytes()); + extension[4] = 0x40; // median precision present + extension[32] = 10; // the precision itself + c.extend(extension); + c +} + +/// Wrap a `CMP1` descriptor and its CRX sample in a one-track CR3 file. The `CRAW` entry's 16-bit +/// dimensions mirror the descriptor; decoding is driven by `CMP1` alone. +fn cr3_file(coding: &[u8], sample: &[u8]) -> Vec<u8> { + let dimension = |at: usize| u32::from_be_bytes(coding[at..at + 4].try_into().unwrap()) as u16; + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&dimension(8).to_be_bytes()); + craw[26..28].copy_from_slice(&dimension(12).to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); // entry count + stsd.extend(box_bytes(b"CRAW", &craw)); + let mut stsz = vec![0u8; 4]; + stsz.extend(u32::try_from(sample.len()).unwrap().to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); // one sample + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); // entry count + co64.extend([0u8; 8]); // sample offset, patched once the layout is known + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + let trak = box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))); + let mut file = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + file.extend(box_bytes(b"moov", &trak)); + let offset = file.len(); + file.extend_from_slice(sample); + let at = file.len() - sample.len() - 8; // the co64 offset field ends the moov + file[at..at + 8].copy_from_slice(&u64::try_from(offset).unwrap().to_be_bytes()); + file +} + +// --- CRX stream construction (the crx.rs fixtures, rebuilt here) ------------ + +fn marker_body(size: u32, flags: u32) -> Vec<u8> { + [size.to_be_bytes(), flags.to_be_bytes()].concat() +} + +fn marker(out: &mut Vec<u8>, code: u16, body: &[u8]) { + out.extend(code.to_be_bytes()); + out.extend(u16::try_from(body.len()).unwrap().to_be_bytes()); + out.extend_from_slice(body); +} + +/// Four 2×2 lossless planes at coefficient 1 (decodes to 8193 everywhere), with overridable +/// marker codes and flag bits so each variant gate can be aimed at precisely one field. +fn lossless_stream(tile: u16, plane: u16, band: u16, plane_flags: u32, band_flags: u32) -> Vec<u8> { + let mut out = Vec::new(); + marker(&mut out, tile, &marker_body(4, 0)); + for p in 0..4u32 { + marker(&mut out, plane, &marker_body(1, (p << 28) | 0x0800_0000 | plane_flags)); + marker(&mut out, band, &marker_body(1, 0x0020_0000 | band_flags)); + } + out.extend([0x1e; 4]); // per plane, one byte: row 0 = run flag 0, Rice 001 (+1), Rice 1 (0); row 1 = full-row run; zero alignment + out +} + +/// Single-tile 16×16 three-level C-RAW with uniform quantization `quant` in every band +/// (quant 4 decodes to 8192 everywhere). +fn craw_stream(quant: u8) -> Vec<u8> { + let mut out = Vec::new(); + marker(&mut out, 0xff01, &marker_body(52, 0)); // four 13-byte planes + for plane in 0..4u32 { + marker(&mut out, 0xff02, &marker_body(13, (plane << 28) | 0x0800_0000)); + for band in 0..10u32 { + marker(&mut out, 0xff03, &marker_body(u32::from(band >= 7) + 1, (band << 28) | (u32::from(quant) << 19))); + } + } + for _ in 0..4 { + out.extend([0x80; 4]); // LL3 and the three 1×1 HF3 bands + out.extend([0xf0; 3]); // three 2×2 all-zero HF2 bands + for _ in 0..3 { + out.extend([0xff, 0xf8]); // three 4×4 all-zero HF1 bands + } + } + out +} + +/// Accepted adaptive-quantization gains: none on the low-frequency bands. +const ADAPTIVE_GAINS: [u32; 10] = [0, 0, 0, 0, 4, 4, 8, 8, 8, 16]; +/// A five-byte QP map for the 1×4 map of a 16×16 image: the 32-bit literal 300 (QP 150 from a +/// prediction of zero), then three more rows at 150. +const ADAPTIVE_QP: [u8; 5] = [0, 0, 1, 0x2c, 0x94]; + +/// `ADAPTIVE_QP` with another first symbol. It is a 32-bit literal, mapped like every Rice-coded +/// symbol: even literals give `literal / 2`, odd ones `-(literal + 1) / 2`. The three zero residuals +/// that follow stay valid for any literal of 6 or more, which leaves the Rice parameter at 2. +fn qp_with_literal(literal: u32) -> [u8; 5] { + let mut qp = ADAPTIVE_QP; + qp[0..4].copy_from_slice(&literal.to_be_bytes()); + qp +} + +/// Single-tile 16×16 adaptive-quantized 0x200 C-RAW (decodes to 8192 everywhere), with the +/// per-band gains, the two reserved header fields and the QP map bytes under test control. +fn adaptive_stream(gains: &[u32; 10], tile_reserved: u16, band_reserved: u16, qp: [u8; 5]) -> Vec<u8> { + let mut out = Vec::new(); + let mut tile = Vec::new(); + tile.extend(60u32.to_be_bytes()); // tile data size + tile.extend(0x4000u32.to_be_bytes()); // index 0, adaptive-tile magic + tile.extend(5u32.to_be_bytes()); // QP map length + tile.extend(3u16.to_be_bytes()); // QP padding + tile.extend(tile_reserved.to_be_bytes()); + marker(&mut out, 0xff11, &tile); + for plane in 0..4u32 { + marker(&mut out, 0xff12, &marker_body(13, (plane << 28) | 0x0800_0000)); + for (band, &gain) in gains.iter().enumerate() { + let mut body = Vec::new(); + body.extend(u32::from(band >= 7).wrapping_add(1).to_be_bytes()); // band size + body.extend(((band as u32) << 28 | gain).to_be_bytes()); + body.extend(u32::from(band < 4).to_be_bytes()); // LL3 base quantization + body.extend([0u8; 2]); // band padding + body.extend(band_reserved.to_be_bytes()); + marker(&mut out, 0xff13, &body); + } + } + out.extend([0u8; 4]); // zero padding between the marker tree and the data area + out.extend(qp); + out.extend([0u8; 3]); // QP padding + for _ in 0..4 { + out.extend([0x80; 4]); + out.extend([0xf0; 3]); + for _ in 0..3 { + out.extend([0xff, 0xf8]); + } + } + out +} + +// --- assertions ------------------------------------------------------------- + +/// `probe_info` (Mode::Header, the import path) and `decode` (Mode::Full) must both reject the +/// file with exactly this `Unsupported` message: same error class, same text. +fn assert_rejected(name: &str, file: &[u8], expected: &str) { + let probed = match probe_info(file) { + Ok(_) => panic!("{name}: probe_info unexpectedly succeeded"), + Err(error) => error, + }; + let full = match decode(file) { + Ok(_) => panic!("{name}: decode unexpectedly succeeded"), + Err(error) => error, + }; + assert_eq!(probed, full, "{name}: probe_info and decode must report the same error"); + assert!(matches!(&probed, RawError::Unsupported(why) if why == expected), "{name}: expected Unsupported(\"{expected}\"), got {probed:?}"); +} + +// --- controls --------------------------------------------------------------- + +#[test] +fn control_lossless_fixture_decodes_through_the_container() { + let file = cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)); + let raw = decode(&file).unwrap(); + assert_eq!((raw.width, raw.height, raw.bits, raw.cpp), (4, 4, 14, 1)); + assert_eq!(raw.data, RawData::U16(vec![8193; 16])); + assert_eq!(probe_info(&file).unwrap(), raw.info()); +} + +#[test] +fn control_craw_and_adaptive_fixtures_decode_through_the_container() { + let craw = cr3_file(&craw_coding(), &craw_stream(4)); + let raw = decode(&craw).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256])); + assert_eq!(probe_info(&craw).unwrap(), raw.info()); + let adaptive = cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)); + let raw = decode(&adaptive).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256])); + assert_eq!(probe_info(&adaptive).unwrap(), raw.info()); +} + +// --- supports() gates ------------------------------------------------------- + +#[test] +fn unknown_version_and_wavelet_level_counts_are_unsupported() { + let mut coding = cmp1(); + coding[4..6].copy_from_slice(&0x300u16.to_be_bytes()); + assert_rejected( + "version 0x300", + &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), + "Canon CRX version 0x300, encoding 0, 4 planes, 0 wavelet levels", + ); + let mut coding = craw_coding(); + coding[26] = 2; // two wavelet levels: neither lossless nor three-level C-RAW + assert_rejected("two wavelet levels", &cr3_file(&coding, &craw_stream(4)), "Canon CRX version 0x100, encoding 0, 4 planes, 2 wavelet levels"); +} + +#[test] +fn bit_depth_outside_8_to_16_is_unsupported() { + // CMP1 parsing only accepts depths 1..=16, so the reachable side of the gate is 1..=7. + let mut coding = cmp1(); + coding[24] = 7; + assert_rejected("7-bit", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CRX 7-bit samples"); +} + +#[test] +fn vertical_craw_tiling_is_unsupported() { + let mut coding = craw_coding(); + coding[20..24].copy_from_slice(&8u32.to_be_bytes()); // two tile rows + assert_rejected("vertical tiles", &cr3_file(&coding, &craw_stream(4)), "Canon CRX C-RAW with vertical tiles"); +} + +#[test] +fn v200_requires_a_single_tile_14_bit_image() { + let mut coding = adaptive_coding(); + coding[24] = 12; + assert_rejected( + "0x200 12-bit", + &cr3_file(&coding, &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)), + "Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image", + ); + let mut coding = adaptive_coding(); + coding[16..20].copy_from_slice(&8u32.to_be_bytes()); // two tile columns + assert_rejected( + "0x200 two tiles", + &cr3_file(&coding, &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP)), + "Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image", + ); +} + +#[test] +fn extended_median_bit_depth_header_is_unsupported() { + assert_rejected( + "median extension", + &cr3_file(&cmp1_with_median_precision(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), + "Canon CRX extended median-bit-depth header", + ); +} + +// --- validate_planes gates -------------------------------------------------- + +#[test] +fn rounded_plane_coding_is_unsupported() { + assert_rejected("rounded plane", &cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 1 << 25, 0)), "Canon CRX plane coding flags"); +} + +#[test] +fn partial_subband_coding_is_unsupported() { + assert_rejected( + "partial subband", + &cr3_file(&cmp1(), &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0x0800_0000)), + "Canon CRX partial subband coding", + ); +} + +#[test] +fn adaptive_gains_on_low_frequency_bands_are_unsupported() { + // Plane validation rejects any gain on bands 0-3 before decoding. The deeper dequantization + // gate behind it ("Canon CRX adaptive low-frequency quantization") is therefore unreachable + // through the public API; this asserts the reachable gate's exact string. + let mut gains = ADAPTIVE_GAINS; + gains[0] = 4; + assert_rejected( + "LL band gain", + &cr3_file(&adaptive_coding(), &adaptive_stream(&gains, 0, 0, ADAPTIVE_QP)), + "Canon CRX adaptive quantization subband parameters", + ); +} + +#[test] +fn uniform_quantization_outside_4_to_44_is_unsupported() { + for quant in [3u8, 45] { + assert_rejected(&format!("quant {quant}"), &cr3_file(&craw_coding(), &craw_stream(quant)), &format!("Canon CRX quantization value {quant}")); + } +} + +#[test] +fn uniform_quantization_edges_of_the_accepted_range_decode() { + // Zero coefficients decode to the mid level whatever the step, so only the gate decides. + for quant in [4u8, 44] { + let file = cr3_file(&craw_coding(), &craw_stream(quant)); + let raw = decode(&file).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256]), "quant {quant}"); + assert_eq!(probe_info(&file).unwrap(), raw.info(), "quant {quant}"); + } +} + +// --- qp_map gate ------------------------------------------------------------ + +#[test] +fn adaptive_qp_outside_131_to_175_is_unsupported() { + // Literal 260 → 130 and 352 → 176 sit just outside the accepted range; literal 5 → −3 is far outside. + for (literal, value) in [(260u32, 130i32), (352, 176), (5, -3)] { + assert_rejected( + &format!("QP {value}"), + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))), + &format!("Canon CRX adaptive QP value {value}"), + ); + } +} + +#[test] +fn adaptive_qp_edges_of_the_accepted_range_decode() { + // Literal 262 → QP 131 and 350 → QP 175 (the EOS R6 Mark II's highest); zero coefficients decode to the mid + // level whatever the step. + for (literal, value) in [(262u32, 131i32), (350, 175)] { + let file = cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 0, qp_with_literal(literal))); + let raw = decode(&file).unwrap(); + assert_eq!(raw.data, RawData::U16(vec![8192; 256]), "QP {value}"); + assert_eq!(probe_info(&file).unwrap(), raw.info(), "QP {value}"); + } +} + +// --- layout() marker gates -------------------------------------------------- + +#[test] +fn ff1x_marker_family_inside_a_0x100_stream_is_unsupported() { + for (name, tile, plane, band) in + [("ff11 tile", 0xff11, 0xff02, 0xff03), ("ff12 plane", 0xff01, 0xff12, 0xff03), ("ff13 band", 0xff01, 0xff02, 0xff13)] + { + assert_rejected(name, &cr3_file(&cmp1(), &lossless_stream(tile, plane, band, 0, 0)), "Canon CRX ff11/ff12/ff13 marker family"); + } +} + +#[test] +fn marker_header_length_mismatch_is_unsupported() { + let mut stream = lossless_stream(0xff01, 0xff02, 0xff03, 0, 0); + stream[2..4].copy_from_slice(&12u16.to_be_bytes()); // tile header claims 12 body bytes, 8 expected + assert_rejected("0x100 tile length", &cr3_file(&cmp1(), &stream), "Canon CRX marker 0xff01 header length 12"); + let mut stream = adaptive_stream(&ADAPTIVE_GAINS, 0, 0, ADAPTIVE_QP); + stream[2..4].copy_from_slice(&8u16.to_be_bytes()); // tile header claims 8 body bytes, 16 expected + assert_rejected("0x200 tile length", &cr3_file(&adaptive_coding(), &stream), "Canon CRX marker 0xff11 header length 8"); +} + +#[test] +fn nonzero_reserved_fields_in_0x200_headers_are_unsupported() { + assert_rejected( + "tile reserved", + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 1, 0, ADAPTIVE_QP)), + "Canon CRX adaptive quantization tile header", + ); + assert_rejected( + "band reserved", + &cr3_file(&adaptive_coding(), &adaptive_stream(&ADAPTIVE_GAINS, 0, 1, ADAPTIVE_QP)), + "Canon CRX adaptive subband reserved field", + ); +} + +// --- vendor/cr3.rs adapter gate --------------------------------------------- + +#[test] +fn non_four_plane_codings_are_rejected_at_the_adapter() { + // CMP1 parsing allows one plane (with any CFA), which the adapter rejects before the CRX layer. + let mut coding = cmp1(); + coding[25] = 0x10; // one plane + assert_rejected("one plane", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CR3 encoding 0 with 1 planes"); +} + +#[test] +fn non_crx_encodings_are_rejected_at_the_adapter() { + let mut coding = cmp1(); + coding[26] = 0x10; // encoding 1, still zero wavelet levels + assert_rejected("encoding 1", &cr3_file(&coding, &lossless_stream(0xff01, 0xff02, 0xff03, 0, 0)), "Canon CR3 encoding 1 with 4 planes"); +} diff --git a/crates/raw/tests/crx_properties.rs b/crates/raw/tests/crx_properties.rs new file mode 100644 index 000000000..32c9f65d3 --- /dev/null +++ b/crates/raw/tests/crx_properties.rs @@ -0,0 +1,1048 @@ +//! Property-based invariants of the Canon CRX decoder, exercised through the public decode path. +//! +//! `vendor/crx` and the 5/3 synthesis in `vendor/crx_wavelet` are `pub(crate)`, so every property here +//! wraps a synthetic CRX stream in a minimal CR3 container (ftyp + moov + a CRAW track with CMP1/CDI1) +//! and calls [`lightcraft_raw::decode`]. The CRX entropy streams are produced by reference encoders in +//! this file that mirror the documented coders (MED prediction, signed mapping, adaptive Rice, the +//! MELCODE zero run, the QP map predictor — sources cited in `crates/raw/src/vendor/crx.rs`). +//! +//! Wavelet roundtrip: the analysis side is this file's exact algebraic inverse of one inverse-lifting +//! line, so the property tests the crate's own synthesis. It is exact by construction because the +//! tile-edge halo high-pass coefficients are free parameters of the encoding — synthesis absorbs any +//! stored value through the neighbouring low-pass coefficient — so random planes of every reachable +//! parity (odd plane sizes via even tile sizes ≡ 2 mod 4) and multi-tile halo configuration must decode +//! back to exactly the encoded sensor samples. +//! +//! Properties: +//! 1. C-RAW wavelet roundtrip is exact across sizes, parities and tile halos. +//! 2. Quantization: every supported uniform quant value (4–44) and adaptive QP value (131–168) +//! dequantizes within the CMP1 bit depth — C-RAW clamps the overshoot, lossless rejects it — and +//! the values just outside those bands (quant 3 and 45, QP 130 and 169) are rejected. +//! 3. Rice/Golomb bounds: adversarial entropy prefixes, in the plane bands and in the QP map, error +//! out, never panic, and stay deterministic. +//! 4. IAD1 geometry: `active_area ⊆ sensor dims` and `crop ⊆ active_area` hold or the file is rejected. +//! 5. Decoding is deterministic across repeated and parallel invocations (rayon plane decode). +//! 6. Odd QP-map heights decode: the last QP row has no partner row and is used alone. +//! +//! The case budget is proptest's default (256 per property); the `PROPTEST_CASES` environment variable +//! overrides it. + +use lightcraft_raw::{RawData, RawError, RawFormat, RawImage, decode, probe, probe_info}; +use proptest::prelude::*; + +// --- reference entropy coders (mirrors of the decoder's documented bitstream grammar) --- + +/// MELCODE run-index table (crx.rs `RUN_EXPONENT`). +const RUN_EXPONENT: [u8; 32] = [0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 9, 10, 11, 12, 13, 14, 15]; + +/// MSB-first bit writer; `into_bytes` zero-pads the final byte (the decoder's `finish` allows ≤ 7 zero tail bits). +#[derive(Default)] +struct BitWriter { + bytes: Vec<u8>, + filled: usize, +} + +impl BitWriter { + fn put(&mut self, value: u32, count: u32) { + for shift in (0..count).rev() { + if self.filled.is_multiple_of(8) { + self.bytes.push(0); + } + if value.checked_shr(shift).unwrap_or(0) & 1 == 1 { + let byte = self.bytes.last_mut().unwrap(); + *byte |= 0x80 >> (self.filled % 8); + } + self.filled += 1; + } + } + + fn into_bytes(self) -> Vec<u8> { + self.bytes + } +} + +/// Adaptive Rice code: ≤ 40-zero prefix, otherwise the 41-zero escape with a 21-bit literal (crx.rs `Bits::rice`). +fn put_rice(bits: &mut BitWriter, k: u32, symbol: u32) { + let zeros = symbol >> k; + if zeros <= 40 { + bits.put(0, zeros); + bits.put(1, 1); + bits.put(symbol & ((1 << k) - 1), k); + } else { + assert!(symbol < 1 << 21, "reference encoder literals are bounded by the plane precision"); + bits.put(0, 41); + bits.put(1, 1); + bits.put(symbol & 0x001f_ffff, 21); + } +} + +/// QP Rice code (crx.rs `qp_rice`), a limited-length code: a quotient below 23 is a plain Rice code, anything +/// larger is 23 zeros, the terminating 1 and the whole symbol in 8 bits. +fn put_qp_rice(bits: &mut BitWriter, k: u32, symbol: u32) { + let zeros = symbol >> k; + if zeros < 23 { + bits.put(0, zeros); + bits.put(1, 1); + bits.put(symbol & ((1 << k) - 1), k); + } else { + assert!(symbol < 1 << 8, "reference QP escapes carry an 8-bit symbol"); + bits.put(0, 23); + bits.put(1, 1); + bits.put(symbol, 8); + } +} + +/// Emits the MELCODE bits whose decode (`crx.rs run`) yields exactly `target` with the same run-index bookkeeping. +fn put_run(bits: &mut BitWriter, index: &mut usize, remaining: usize, target: usize) { + assert!(target >= 1 && target <= remaining); + let mut length = 1usize; + while length < remaining { + let exponent = u32::from(RUN_EXPONENT[*index]); + if target - length >= 1usize << exponent { + bits.put(1, 1); + length += 1usize << exponent; + if length <= remaining { + *index = (*index + 1).min(RUN_EXPONENT.len() - 1); + } + } else { + bits.put(0, 1); + bits.put((target - length) as u32, exponent); + *index = index.saturating_sub(1); + return; + } + } + assert_eq!(length, target, "a run reaching the row end needs no terminator"); +} + +/// Inverse of the decoder's signed mapping (crx.rs `signed`). +fn zig(value: i32) -> u32 { + ((value << 1) ^ (value >> 31)) as u32 +} + +/// MED prediction (crx.rs `median`). +fn median(left: i32, above: i32, corner: i32) -> i32 { + if corner >= left.max(above) { + left.min(above) + } else if corner <= left.min(above) { + left.max(above) + } else { + left + above - corner + } +} + +/// Adaptive Rice parameter update (crx.rs `update_rice`). +fn update_rice(mut k: u32, symbol: u32) -> u32 { + if symbol >= 3 << k { + k += 1; + if symbol >= 3 << k { + k += 1; + } + } else if k > 0 && symbol < 1 << (k - 1) { + k -= 1; + } + k.min(20) +} + +/// Encodes a plane with the lossless coder: a zero run flag wherever the decoder reads one, then MED + Rice. +fn encode_lossless_plane(values: &[i32], width: usize, height: usize, bits: &mut BitWriter) { + let mut k = 0u32; // runs are never emitted, so the MELCODE run index stays unused here + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + for y in 0..height { + let mut left = previous[0]; + for x in 0..width { + let above = previous[x]; + let right = previous.get(x + 1).copied().unwrap_or(above); + if x + 1 < width && left == above && left == right { + bits.put(0, 1); // a run is never required: regular prediction encodes any sample + } + let corner = if x == 0 { above } else { previous[x - 1] }; + let symbol = zig(values[y * width + x] - median(left, above, corner)); + put_rice(bits, k, symbol); + current[x] = values[y * width + x]; + left = values[y * width + x]; + let estimate = if y > 0 && x + 1 < width { (symbol + 2 * right.abs_diff(above)) / 2 } else { symbol }; + k = update_rice(k, estimate); + } + std::mem::swap(&mut current, &mut previous); + } +} + +/// Encodes a high-frequency band: zero-run flags in the all-zero context (forced for zero coefficients), +/// the nonzero-proven mapping after an interruption, and the upper-row Rice context (crx.rs `high_frequency_band`). +fn encode_high_frequency_band(values: &[i32], width: usize, height: usize, bits: &mut BitWriter) { + let (mut k, mut run_index) = (0u32, 0usize); + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + let (mut previous_k, mut current_k) = (vec![0i32; width], vec![0i32; width]); + for y in 0..height { + let (mut x, mut left) = (0usize, 0i32); + while x < width { + let above = previous[x]; + let right = previous.get(x + 1).copied().unwrap_or(above); + let mut nonzero = false; + if x + 1 < width && left == 0 && above == 0 && right == 0 { + if values[y * width + x] == 0 { + bits.put(1, 1); // a zero in this context must take the run path + let mut length = 1usize; + while x + length < width && values[y * width + x + length] == 0 { + length += 1; + } + put_run(bits, &mut run_index, width - x, length); + for slot in &mut current[x..x + length] { + *slot = 0; + } + for slot in &mut current_k[x..x + length] { + *slot = 0; + } + left = 0; + x += length; + if x == width { + continue; + } + nonzero = true; // the interruption proves the next coefficient nonzero + } else { + bits.put(0, 1); + nonzero = true; + } + } + let coded = zig(values[y * width + x]) - u32::from(nonzero); + put_rice(bits, k, coded); + current[x] = values[y * width + x]; + left = values[y * width + x]; + k = update_rice(k, coded); + if y > 0 && x + 1 < width && previous_k[x + 1] as u32 > k + 1 { + k += 1; + } + current_k[x] = k as i32; + x += 1; + } + std::mem::swap(&mut current, &mut previous); + std::mem::swap(&mut current_k, &mut previous_k); + } +} + +/// Encodes an adaptive QP map: the first symbol as a 32-bit literal, then QP Rice codes over the MED +/// predictor (crx.rs `qp_map`). +fn encode_qp_map(values: &[i32], width: usize, height: usize) -> Vec<u8> { + let mut k = 0u32; + let (mut previous, mut current) = (vec![0i32; width], vec![0i32; width]); + let mut bits = BitWriter::default(); + for y in 0..height { + let mut left = previous[0]; + for x in 0..width { + let above = previous[x]; + let corner = if x == 0 { above } else { previous[x - 1] }; + let right = previous.get(x + 1).copied().unwrap_or(above); + let symbol = zig(values[y * width + x] - median(left, above, corner)); + if x == 0 && y == 0 { + bits.put(symbol, 32); + } else { + put_qp_rice(&mut bits, k, symbol); + } + current[x] = values[y * width + x]; + left = values[y * width + x]; + let estimate = if y > 0 && x + 1 < width { (symbol + 2 * right.abs_diff(above)) / 2 } else { symbol }; + k = update_rice(k, estimate); + } + std::mem::swap(&mut current, &mut previous); + } + bits.into_bytes() +} + +// --- exact algebraic inverse of the crate's 5/3 synthesis (the forward transform under test) --- + +struct Band { + width: usize, + height: usize, + data: Vec<i32>, +} + +/// Produces the (low, high) pair whose synthesis (`crx_wavelet::inverse_line`) reproduces `line` exactly. +/// +/// Mirrors the inverse lifting: each odd sample fixes its stored high coefficient, each even sample its +/// stored low coefficient. The `before`/`after` halo coefficients are free parameters (any value works: +/// synthesis absorbs them through the boundary low coefficient), so they are supplied by the caller. +fn forward_lift(line: &[i32], before: bool, after: bool, halo_before: i32, halo_after: i32) -> (Vec<i32>, Vec<i32>) { + let n = line.len(); + let high_count = n / 2 + usize::from(before) + usize::from(after); + let low_count = n.div_ceil(2) + usize::from(after && n.is_multiple_of(2)); + if high_count == 0 { + return (line.to_vec(), Vec::new()); + } + // The right prediction of the final odd sample of an even, right-halo line (`after`: even(i + 1)). + let extension = if after && n.is_multiple_of(2) { Some(2 * (line[n - 1] - halo_after) - line[n - 2]) } else { None }; + let mut high = Vec::with_capacity(high_count); + if before { + high.push(halo_before); + } + for i in 0..n / 2 { + let right = match line.get(2 * i + 2) { + Some(&value) => value, + None => extension.unwrap_or(line[2 * i]), // no right neighbour: synthesis mirrors the last even sample + }; + high.push(line[2 * i + 1] - (line[2 * i] + right).div_euclid(2)); + } + if after { + high.push(halo_after); + } + let mut low = Vec::with_capacity(low_count); + let evens = n.div_ceil(2); + for i in 0..low_count { + let even_value = if i < evens { line[2 * i] } else { extension.unwrap_or_default() }; + let left_index = (i + usize::from(before)).saturating_sub(1).min(high.len() - 1); + let right_index = (i + usize::from(before)).min(high.len() - 1); + low.push(even_value + (high[left_index] + high[right_index] + 2).div_euclid(4)); + } + (low, high) +} + +/// One inverse level, reversed: vertical lifting first (CRX analysis order), then horizontal, with the +/// same tile-edge flags the decoder passes for every level. +fn forward_level(plane: &[i32], width: usize, height: usize, before: bool, after: bool, halo: i32) -> (Band, Band, Band, Band) { + let (low_h, high_h) = (height.div_ceil(2), height / 2); + let mut temporary = vec![0i32; width * height]; + for x in 0..width { + let column: Vec<i32> = (0..height).map(|y| plane[y * width + x]).collect(); + let (lo, hi) = forward_lift(&column, false, false, halo, halo); + for (y, value) in lo.into_iter().chain(hi).enumerate() { + temporary[y * width + x] = value; + } + } + let low_w = width.div_ceil(2) + usize::from(after && width.is_multiple_of(2)); + let high_w = width / 2 + usize::from(before) + usize::from(after); + let band = |w: usize, h: usize| Band { width: w, height: h, data: vec![0i32; w * h] }; + let (mut ll, mut hl, mut lh, mut hh) = (band(low_w, low_h), band(high_w, low_h), band(low_w, high_h), band(high_w, high_h)); + for y in 0..height { + let (lo, hi) = forward_lift(&temporary[y * width..(y + 1) * width], before, after, halo, halo); + let (low_band, high_band, by) = if y < low_h { (&mut ll, &mut hl, y) } else { (&mut lh, &mut hh, y - low_h) }; + low_band.data[by * low_w..(by + 1) * low_w].copy_from_slice(&lo); + high_band.data[by * high_w..(by + 1) * high_w].copy_from_slice(&hi); + } + (ll, hl, lh, hh) +} + +/// Subbands in CRX order: LL3, HL3, LH3, HH3, HL2, LH2, HH2, HL1, LH1, HH1. +fn forward_bands(plane: &[i32], width: usize, height: usize, before: bool, after: bool, halo: i32) -> Vec<Band> { + let (ll1, hl1, lh1, hh1) = forward_level(plane, width, height, before, after, halo); + let (ll2, hl2, lh2, hh2) = forward_level(&ll1.data, ll1.width, ll1.height, before, after, halo); + let (ll3, hl3, lh3, hh3) = forward_level(&ll2.data, ll2.width, ll2.height, before, after, halo); + vec![ll3, hl3, lh3, hh3, hl2, lh2, hh2, hl1, lh1, hh1] +} + +/// Subband shapes the decoder derives (crx.rs `wavelet_plane` / `crx_wavelet::sizes`). +fn band_shapes(plane_width: usize, plane_height: usize, before: bool, after: bool) -> [(usize, usize); 10] { + let mut levels = [(0usize, 0usize, 0usize, 0usize); 3]; // (low_w, high_w, low_h, high_h), finest first + let (mut w, mut h) = (plane_width, plane_height); + for level in &mut levels { + let low_w = w.div_ceil(2) + usize::from(after && w % 2 == 0); + let high_w = w / 2 + usize::from(before) + usize::from(after); + *level = (low_w, high_w, h.div_ceil(2), h / 2); + w = low_w; + h = level.2; + } + let (lw1, hw1, lh1, hh1) = levels[0]; + let (lw2, hw2, lh2, hh2) = levels[1]; + let (lw3, hw3, lh3, hh3) = levels[2]; + [(lw3, lh3), (hw3, lh3), (lw3, hh3), (hw3, hh3), (hw2, lh2), (lw2, hh2), (hw2, hh2), (hw1, lh1), (lw1, hh1), (hw1, hh1)] +} + +fn encode_band(band: &Band, lowpass: bool) -> Vec<u8> { + let mut bits = BitWriter::default(); + if lowpass { + encode_lossless_plane(&band.data, band.width, band.height, &mut bits); + } else { + encode_high_frequency_band(&band.data, band.width, band.height, &mut bits); + } + bits.into_bytes() +} + +/// The measured uniform quantization sequence (crx.rs `quant_step`). +fn quant_step(value: u8) -> i32 { + let scale = [40i32, 45, 51, 57, 64, 72][usize::from(value % 6)]; + (scale << (value / 6)) / 64 +} + +// --- synthetic CR3 container and CRX sample assembly --- + +fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out +} + +fn track_box(entry: &[u8], offset: u64, size: u32) -> Vec<u8> { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend_from_slice(entry); + let mut stsz = vec![0u8; 4]; + stsz.extend(size.to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); + co64.extend(offset.to_be_bytes()); + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))) +} + +/// Wraps a CRAW sample entry and its sample bytes in a minimal CR3 file (no metadata boxes: geometry +/// and levels come from the container and CMP1 alone). +fn cr3_file(entry: &[u8], sample: &[u8]) -> Vec<u8> { + let ftyp = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + // The track length does not depend on the offset value (always a 64-bit field), so it can be + // measured with a placeholder before the real offset is known. + let track_len = track_box(entry, 0, sample.len() as u32).len(); + let offset = (ftyp.len() + 8 + track_len + 7) & !7; + let mut file = ftyp; + file.extend(box_bytes(b"moov", &track_box(entry, offset as u64, sample.len() as u32))); + file.resize(offset, 0); + file.extend_from_slice(sample); + file +} + +fn cmp1(version: u16, width: u32, height: u32, tile_width: u32, tile_height: u32, levels: u8, header_size: u32) -> Vec<u8> { + let mut coding = vec![0u8; 52]; + coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + coding[2..4].copy_from_slice(&48u16.to_be_bytes()); + coding[4..6].copy_from_slice(&version.to_be_bytes()); + coding[8..12].copy_from_slice(&width.to_be_bytes()); + coding[12..16].copy_from_slice(&height.to_be_bytes()); + coding[16..20].copy_from_slice(&tile_width.to_be_bytes()); + coding[20..24].copy_from_slice(&tile_height.to_be_bytes()); + coding[24] = 14; + coding[25] = 0x40; // four planes, CFA pattern 0 (RGGB) + coding[26] = levels; // encoding 0 + coding[28..32].copy_from_slice(&header_size.to_be_bytes()); + coding +} + +fn iad1_bytes(width: u16, height: u16, full: u16, crop: [u16; 4], masked_left: [u16; 4], masked_top: [u16; 4], active: [u16; 4]) -> Vec<u8> { + let mut area = vec![0u8; 48]; + let mut put = |at: usize, value: u16| area[at..at + 2].copy_from_slice(&value.to_be_bytes()); + put(4, width); + put(6, height); + put(10, full); + for (i, value) in crop.iter().enumerate() { + put(16 + 2 * i, *value); + } + for (i, value) in masked_left.iter().enumerate() { + put(24 + 2 * i, *value); + } + if full == 2 { + for (i, value) in masked_top.iter().enumerate() { + put(32 + 2 * i, *value); + } + for (i, value) in active.iter().enumerate() { + put(40 + 2 * i, *value); + } + } + area +} + +fn craw_entry(coding: &[u8], width: u16, height: u16, iad1: Option<&[u8]>) -> Vec<u8> { + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&width.to_be_bytes()); + craw[26..28].copy_from_slice(&height.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + if let Some(area) = iad1 { + craw.extend(box_bytes(b"CDI1", &[vec![0u8; 4], box_bytes(b"IAD1", area)].concat())); + } + box_bytes(b"CRAW", &craw) +} + +fn put_marker(out: &mut Vec<u8>, code: u16, a: u32, b: u32) { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(a.to_be_bytes()); + out.extend(b.to_be_bytes()); +} + +fn put_marker16(out: &mut Vec<u8>, code: u16, words: [u32; 4]) { + out.extend(code.to_be_bytes()); + out.extend(16u16.to_be_bytes()); + for word in words { + out.extend(word.to_be_bytes()); + } +} + +/// Assembles a version 0x100 sample: one ff01 tile header and per plane an ff02 header plus `bands` +/// ff03 headers, then the band bytes. `bands` is 1 (lossless) or 10 (three-level C-RAW). +fn v100_sample(tiles: &[[Vec<Vec<u8>>; 4]], quant: u8) -> (usize, Vec<u8>) { + let mut header = Vec::new(); + let mut data = Vec::new(); + for (index, planes) in tiles.iter().enumerate() { + let tile_size: usize = planes.iter().flatten().map(Vec::len).sum(); + put_marker(&mut header, 0xff01, tile_size as u32, u32::from(index as u16) << 16); + for (p, bands) in planes.iter().enumerate() { + let plane_size: usize = bands.iter().map(Vec::len).sum(); + put_marker(&mut header, 0xff02, plane_size as u32, (p as u32) << 28 | 0x0800_0000); + for (b, bytes) in bands.iter().enumerate() { + put_marker(&mut header, 0xff03, bytes.len() as u32, (b as u32) << 28 | u32::from(quant) << 19); + } + } + for bands in planes { + for bytes in bands { + data.extend_from_slice(bytes); + } + } + } + let header_size = header.len(); + header.extend(data); + (header_size, header) +} + +/// Assembles a version 0x200 single-tile sample: an ff11 header with the adaptive QP stream, ff12/ff13 +/// headers with per-band base and gain, then QP data, its padding and the band bytes. +fn v200_sample(tile: &[Vec<Vec<u8>>; 4], gains: [u32; 10], bases: [u32; 10], qp: &[u8], qp_padding: usize) -> (usize, Vec<u8>) { + let band_bytes: usize = tile.iter().map(|bands| bands.iter().map(Vec::len).sum::<usize>()).sum(); + let tile_size = qp.len() + qp_padding + band_bytes; + let mut header = Vec::new(); + put_marker16(&mut header, 0xff11, [tile_size as u32, 0x4000, qp.len() as u32, u32::from(qp_padding as u16) << 16]); + for (p, bands) in tile.iter().enumerate() { + let plane_size: usize = bands.iter().map(Vec::len).sum(); + put_marker(&mut header, 0xff12, plane_size as u32, (p as u32) << 28 | 0x0800_0000); + for (b, bytes) in bands.iter().enumerate() { + put_marker16(&mut header, 0xff13, [bytes.len() as u32, (b as u32) << 28 | gains[b], bases[b], 0]); + } + } + let header_size = header.len(); + let mut data = header; + data.extend_from_slice(qp); + data.resize(data.len() + qp_padding, 0); + for bands in tile { + for bytes in bands { + data.extend_from_slice(bytes); + } + } + (header_size, data) +} + +// --- fixture builders --- + +/// A synthetic CRX stream plus its container, with the expected sensor samples when exact decoding is asserted. +struct Fixture { + file: Vec<u8>, + expected: Vec<u16>, +} + +fn wrap(coding: &[u8], width: u32, height: u32, sample: &[u8], iad1: Option<&[u8]>) -> Vec<u8> { + cr3_file(&craw_entry(coding, width as u16, height as u16, iad1), sample) +} + +/// Deterministic pseudo-random plane content for fixed regression-style files. +fn lcg_values(seed: u64, count: usize) -> Vec<i32> { + let mut state = seed; + (0..count) + .map(|_| { + state = state.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1_442_695_040_888_963_407); + ((state >> 33) % 16_384) as i32 - 8192 + }) + .collect() +} + +/// Tile geometries with three non-empty wavelet levels: (tile width, tile height, tile count). +/// Plane sizes are half of each; odd plane sizes come from tile sizes ≡ 2 (mod 4). +const GEOMETRIES: [(usize, usize, usize); 8] = + [(16, 16, 1), (18, 16, 1), (16, 18, 1), (18, 18, 2), (24, 16, 2), (32, 18, 2), (24, 24, 1), (48, 16, 3)]; +const MAX_PLANE_VALUES: usize = 3 * 4 * 24 * 12; + +const LOSSLESS_GEOMETRIES: [(usize, usize); 5] = [(4, 4), (8, 4), (6, 8), (12, 6), (8, 8)]; + +/// Version 0x200 sensor sizes; the QP map has one cell per 16×4 pixels, so heights 12 and 20 give odd map heights (3 and 5). +const V200_GEOMETRIES: [(usize, usize); 6] = [(16, 16), (32, 16), (16, 24), (16, 32), (16, 12), (32, 20)]; + +/// Encodes the four planes of one tile into CRX band byte streams. +fn encode_tile_planes( + planes_values: [Vec<i32>; 4], + plane_w: usize, + plane_h: usize, + before: bool, + after: bool, + halo: i32, + levels: u8, +) -> [Vec<Vec<u8>>; 4] { + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for (p, plane_bytes) in encoded.iter_mut().enumerate() { + let values = &planes_values[p]; + if levels == 0 { + let band = Band { width: plane_w, height: plane_h, data: values.clone() }; + *plane_bytes = vec![encode_band(&band, true)]; + } else { + let bands = forward_bands(values, plane_w, plane_h, before, after, halo); + *plane_bytes = bands.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + } + } + encoded +} + +/// Builds a C-RAW file whose planes hold the given random values exactly (uniform quant 4/8/9 has step 1). +fn craw_roundtrip_file((tile_w, tile_h, tile_count): (usize, usize, usize), quant: u8, halo: i32, values: &[i32]) -> Fixture { + let (plane_w, plane_h) = (tile_w / 2, tile_h / 2); + let (width, height) = (tile_w * tile_count, tile_h); + let mut cursor = 0usize; + let mut tiles: Vec<[Vec<Vec<u8>>; 4]> = Vec::new(); + let mut expected = vec![0u16; width * height]; + for tile in 0..tile_count { + let mut planes_values = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + for p in 0..4 { + planes_values[p] = values[cursor..cursor + plane_w * plane_h].to_vec(); + cursor += plane_w * plane_h; + for (i, &value) in planes_values[p].iter().enumerate() { + let x = tile * tile_w + 2 * (i % plane_w) + p % 2; + let y = 2 * (i / plane_w) + p / 2; + expected[y * width + x] = (value + 8192) as u16; // in sensor range: no clamping + } + } + tiles.push(encode_tile_planes(planes_values, plane_w, plane_h, tile > 0, tile + 1 < tile_count, halo, 3)); + } + let (header_size, sample) = v100_sample(&tiles, quant); + let coding = cmp1(0x100, width as u32, height as u32, tile_w as u32, tile_h as u32, 3, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected } +} + +/// Builds a lossless file (one band per plane) whose planes hold exactly the given values. +fn lossless_file((width, height): (usize, usize), values: &[i32]) -> Fixture { + let (plane_w, plane_h) = (width / 2, height / 2); + let mut planes_values = [Vec::new(), Vec::new(), Vec::new(), Vec::new()]; + let mut expected = vec![0u16; width * height]; + for p in 0..4 { + planes_values[p] = values[p * plane_w * plane_h..(p + 1) * plane_w * plane_h].to_vec(); + for (i, &value) in planes_values[p].iter().enumerate() { + let x = 2 * (i % plane_w) + p % 2; + let y = 2 * (i / plane_w) + p / 2; + expected[y * width + x] = (value + 8192) as u16; + } + } + let tiles = [encode_tile_planes(planes_values, plane_w, plane_h, false, false, 0, 0)]; + let (header_size, sample) = v100_sample(&tiles, 4); + let coding = cmp1(0x100, width as u32, height as u32, width as u32, height as u32, 0, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected } +} + +/// Constant-coefficient planes for one tile: LL3 flat at `ll3`, band `mid_band.0` flat at `mid_band.1`, +/// everything else zero. Encoded at the given level count with the given quant/gain/base parameters. +fn constant_band_planes( + plane_w: usize, + plane_h: usize, + levels: u8, + ll3: i32, + mid_band: Option<(usize, i32)>, + quant: u8, + gains: [u32; 10], + bases: [u32; 10], + qp: Option<&[i32]>, + qp_padding: usize, + before: bool, + after: bool, +) -> (usize, Vec<u8>) { + let planes: [Vec<Vec<u8>>; 4] = if levels == 0 { + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for bands in encoded.iter_mut() { + let band = Band { width: plane_w, height: plane_h, data: vec![ll3; plane_w * plane_h] }; + *bands = vec![encode_band(&band, true)]; + } + encoded + } else { + let shapes = band_shapes(plane_w, plane_h, before, after); + let mut encoded: [Vec<Vec<u8>>; 4] = Default::default(); + for bands in encoded.iter_mut() { + let bands_data: Vec<Band> = shapes + .iter() + .enumerate() + .map(|(i, &(w, h))| { + let value = if i == 0 { ll3 } else { mid_band.filter(|&(band, _)| band == i).map_or(0, |(_, value)| value) }; + Band { width: w, height: h, data: vec![value; w * h] } + }) + .collect(); + *bands = bands_data.iter().enumerate().map(|(i, band)| encode_band(band, i == 0)).collect(); + } + encoded + }; + if levels == 0 { + v100_sample(&[planes], quant) + } else if let Some(map) = qp { + let (map_w, map_h) = (plane_w / 8, plane_h / 2); // one QP cell per 16×4 sensor pixels + v200_sample(&planes, gains, bases, &encode_qp_map(map, map_w, map_h), qp_padding) + } else { + v100_sample(&[planes], quant) + } +} + +/// A 16×16 three-level C-RAW file built from constant bands at the given uniform quant value. +fn constant_bands_file(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Fixture { + let (header_size, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + let coding = cmp1(0x100, 16, 16, 16, 16, 3, header_size as u32); + Fixture { file: wrap(&coding, 16, 16, &sample, None), expected: Vec::new() } +} + +/// A version 0x200 file with the given adaptive QP map (one cell per 16×4 sensor pixels) and per-band gains/bases. +fn v200_mapped_file( + width: usize, + height: usize, + map: &[i32], + ll3: i32, + mid_band: Option<(usize, i32)>, + gains: [u32; 10], + bases: [u32; 10], +) -> Fixture { + assert_eq!(map.len(), (width / 16) * (height / 4)); + let (header_size, sample) = constant_band_planes(width / 2, height / 2, 3, ll3, mid_band, 4, gains, bases, Some(map), 3, false, false); + let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); + Fixture { file: wrap(&coding, width as u32, height as u32, &sample, None), expected: Vec::new() } +} + +/// A version 0x200 file with a uniform adaptive QP map and the given per-band gains/bases. +fn v200_constant_bands_file( + width: usize, + height: usize, + qp_value: i32, + ll3: i32, + mid_band: Option<(usize, i32)>, + gains: [u32; 10], + bases: [u32; 10], +) -> Fixture { + v200_mapped_file(width, height, &vec![qp_value; (width / 16) * (height / 4)], ll3, mid_band, gains, bases) +} + +fn sensor_hash(image: &RawImage) -> u64 { + match &image.data { + RawData::U16(samples) => samples + .iter() + .flat_map(|value| value.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |hash, byte| (hash ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)), + RawData::F32(_) => panic!("u16 sensor data expected"), + } +} + +fn quad() -> impl proptest::strategy::Strategy<Value = [u16; 4]> { + (any::<u16>(), any::<u16>(), any::<u16>(), any::<u16>()).prop_map(|(a, b, c, d)| [a, b, c, d]) +} + +proptest! { + /// Property 1: synthesis after the exact-inverse analysis reproduces random planes exactly, across + /// odd plane parities and multi-tile halo configurations, with arbitrary halo values. + #[test] + fn craw_wavelet_roundtrip_is_exact( + geometry in 0usize..GEOMETRIES.len(), + values in proptest::collection::vec(-8192i32..=8191, MAX_PLANE_VALUES), + halo in -4096i32..=4096, + quant in proptest::prop_oneof![Just(4u8), Just(8u8), Just(9u8)], + ) { + let (tile_w, tile_h, tile_count) = GEOMETRIES[geometry]; + let fixture = craw_roundtrip_file(GEOMETRIES[geometry], quant, halo, &values); + assert_eq!(probe(&fixture.file), Some(RawFormat::Cr3)); + let image = decode(&fixture.file).unwrap(); + assert_eq!((image.width, image.height, image.bits), (tile_w * tile_count, tile_h, 14)); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &fixture.expected, "synthesis after analysis must be exact"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); + } + + /// Property 2 (lossless side): in-range planes decode exactly; a sample outside the CMP1 bit depth + /// is corrupt data, never clamped. + #[test] + fn lossless_planes_are_exact_and_out_of_range_values_are_rejected( + geometry in 0usize..LOSSLESS_GEOMETRIES.len(), + values in proptest::collection::vec(-8192i32..=8191, 4 * 6 * 4), + out_of_range in 0u8..=2, + ) { + let (width, height) = LOSSLESS_GEOMETRIES[geometry]; + let mut values = values[..4 * (width / 2) * (height / 2)].to_vec(); + if out_of_range > 0 { + values[0] = if out_of_range == 1 { 9000 } else { -9000 }; + } + let fixture = lossless_file((width, height), &values); + if out_of_range == 0 { + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &fixture.expected); + } else { + assert!(matches!(decode(&fixture.file), Err(RawError::Corrupt(_)))); + } + } + + /// Property 3: pathological Rice/Golomb prefixes inside well-formed frames are errors or clean + /// decodes — never a panic, never a hang, always deterministic. + #[test] + fn adversarial_entropy_never_panics_and_is_deterministic( + kind in 0usize..4, + payload in proptest::collection::vec(any::<u8>(), 0..=600), + slot in any::<usize>(), + ) { + let file = adversarial_frame(kind, &payload, slot); + let first = decode(&file); + assert_eq!(first, decode(&file), "repeated decodes of the same bytes must agree"); + if let Ok(image) = &first { + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&value| value <= 16383), "C-RAW clamps to the CMP1 bit depth"); + } + let _ = probe_info(&file); + } + + /// Property 2 (adaptive side): random QP maps in the supported 131–168 band, including odd map heights, + /// and random per-band base/gain parameters always decode, with every sample inside the 14-bit range. + #[test] + fn adaptive_qp_maps_stay_within_the_bit_depth( + geometry in 0usize..V200_GEOMETRIES.len(), + qp_values in proptest::collection::vec(131i32..=168, 16), + ll3 in -2048i32..=2048, + mid in -16i32..=16, + gain in 0u32..=32, + base in 0u32..=8, + ) { + let (width, height) = V200_GEOMETRIES[geometry]; + let (map_w, map_h) = (width / 16, height / 4); + let mut gains = [0u32; 10]; + let mut bases = [0u32; 10]; + for (i, (g, b)) in gains.iter_mut().zip(bases.iter_mut()).enumerate() { + if i >= 4 { + *g = gain; + } + *b = if i < 4 { base + 1 } else { base }; + } + let (header_size, sample) = constant_band_planes( + width / 2, + height / 2, + 3, + ll3, + Some((4, mid)), + 4, + gains, + bases, + Some(&qp_values[..map_w * map_h]), + 3, + false, + false, + ); + let coding = cmp1(0x200, width as u32, height as u32, width as u32, height as u32, 3, header_size as u32); + let file = wrap(&coding, width as u32, height as u32, &sample, None); + let image = decode(&file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&value| value <= 16383), "adaptive dequantization must stay in the bit depth"); + assert_eq!(probe_info(&file).unwrap(), image.info()); + } + + /// Property 4: randomly generated IAD1 fields either decode — with `active_area ⊆ sensor dims` and + /// `crop ⊆ active_area` — or are rejected (no overflow or out-of-bounds geometry gets through). + #[test] + fn random_iad1_geometry_keeps_active_area_and_crop_invariants( + base in 0usize..2, + full in proptest::prop_oneof![Just(0u16), Just(2u16)], + crop in quad(), + masked_left in quad(), + masked_top in quad(), + active in quad(), + width_off in -3i32..=3, + height_off in -3i32..=3, + ) { + let (width, height, coding, sample) = match base { + 0 => (4usize, 4usize, cmp1(0x100, 4, 4, 4, 4, 0, 108), lossless_sample(100)), + _ => (16usize, 16usize, cmp1(0x100, 16, 16, 16, 16, 3, 540), constant_bands_sample(0, None, 4)), + }; + let clamp16 = |off: i32, base_dim: usize| u16::try_from((base_dim as i64 + off as i64).clamp(1, i64::from(u16::MAX))).unwrap(); + let area = iad1_bytes(clamp16(width_off, width), clamp16(height_off, height), full, crop, masked_left, masked_top, active); + let file = wrap(&coding, width as u32, height as u32, &sample, Some(&area)); + assert_eq!(probe(&file), Some(RawFormat::Cr3)); + if let Ok(image) = decode(&file) { + image.validate().unwrap(); + let a = image.active_area; + assert!(a.width > 0 && a.height > 0, "{a:?}"); + assert!(a.x + a.width <= image.width && a.y + a.height <= image.height, "active area outside sensor: {a:?}"); + assert!( + image.crop.x + image.crop.width <= a.width && image.crop.y + image.crop.height <= a.height, + "crop outside active area: {:?} in {a:?}", + image.crop + ); + assert_eq!(probe_info(&file).unwrap(), image.info()); + } + } +} + +/// Raw sample bytes of the 4×4 lossless fixture (a 108-byte marker header plus four flat planes). +fn lossless_sample(value: i32) -> Vec<u8> { + let band = Band { width: 2, height: 2, data: vec![value; 4] }; + let tiles = [std::array::from_fn(|_| vec![encode_band(&band, true)])]; + let (_, sample) = v100_sample(&tiles, 4); + sample +} + +/// Raw sample bytes of a constant-band 16×16 C-RAW fixture. +fn constant_bands_sample(ll3: i32, mid_band: Option<(usize, i32)>, quant: u8) -> Vec<u8> { + let (_, sample) = constant_band_planes(8, 8, 3, ll3, mid_band, quant, [0; 10], [0; 10], None, 0, false, false); + sample +} + +// --- adversarial entropy frames (property 3) --- + +/// A well-formed frame of the given kind (0: lossless 8×6, 1: C-RAW 16×16, 2: 0x200 with an adaptive +/// QP map, 3: the same 0x200 frame with the QP map itself replaced) in which one plane's entropy streams +/// (kinds 0 to 2) or the QP map stream (kind 3) are replaced by the adversarial payload. The marker +/// sizes follow the payload, so the layout stays parseable and the pathological bits reach the +/// Rice/Golomb readers; the untouched planes decode fine, reaching the mosaic stage on success. +fn adversarial_frame(kind: usize, payload: &[u8], slot: usize) -> Vec<u8> { + let shapes = if kind == 0 { vec![(4usize, 3usize)] } else { band_shapes(8, 8, false, false).to_vec() }; + let mut tiles: Vec<[Vec<Vec<u8>>; 4]> = vec![Default::default()]; + for (p, bands) in tiles[0].iter_mut().enumerate() { + *bands = if kind != 3 && p == slot % 4 { + shapes.iter().map(|_| payload.to_vec()).collect() + } else { + shapes + .iter() + .enumerate() + .map(|(i, &(w, h))| { + let band = Band { width: w, height: h, data: vec![0; w * h] }; + encode_band(&band, i == 0) + }) + .collect() + }; + } + let (header_size, sample) = if kind == 2 { + let map = vec![150i32; 16]; + v200_sample(&tiles[0], [0; 10], [1; 10], &encode_qp_map(&map, 1, 4), 3) + } else if kind == 3 { + v200_sample(&tiles[0], [0; 10], [1; 10], payload, 3) + } else { + v100_sample(&tiles, 4) + }; + let (width, height, levels) = match kind { + 0 => (8u32, 6u32, 0u8), + 1 => (16, 16, 3), + _ => (16, 16, 3), + }; + let version = if kind >= 2 { 0x200u16 } else { 0x100 }; + let coding = cmp1(version, width, height, width, height, levels, header_size as u32); + wrap(&coding, width, height, &sample, None) +} + +// --- exhaustive coverage of the verified quantization bands (property 2) --- + +#[test] +fn every_uniform_quant_value_dequantizes_within_the_bit_depth() { + for quant in 4u8..=44 { + let step = quant_step(quant); + // Overshoot: the dequantized DC leaves the 14-bit range and C-RAW clamps it. + let fixture = constant_bands_file(8000, None, quant); + let expected_value = (8192 + 8000 * step).clamp(0, 16383) as u16; + let image = decode(&fixture.file).unwrap(); + assert_eq!(image.data, RawData::U16(vec![expected_value; 256]), "quant {quant}"); + assert_eq!(probe_info(&fixture.file).unwrap().bits, 14); + // In range: the DC lands exactly on 8192 + level * step, so the step itself is observable + // (the overshoot case above saturates for every step above 1). + let level = 8000 / step; + let fixture = constant_bands_file(level, None, quant); + let image = decode(&fixture.file).unwrap(); + assert_eq!(image.data, RawData::U16(vec![(8192 + level * step) as u16; 256]), "quant {quant} in range"); + } + for quant in [3u8, 45] { + let fixture = constant_bands_file(8000, None, quant); + assert!(matches!(decode(&fixture.file), Err(RawError::Unsupported(_))), "quant {quant}"); + assert!(matches!(probe_info(&fixture.file), Err(RawError::Unsupported(_)))); + } +} + +#[test] +fn every_qp_value_in_the_supported_band_dequantizes_within_the_bit_depth() { + let gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let mut first_hash = 0u64; + let mut last_hash = 0u64; + for value in 131i32..=175 { + let fixture = v200_constant_bands_file(16, 16, value, 400, Some((4, 8)), gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&sample| sample <= 16383), "QP {value}"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); + if value == 131 { + first_hash = sensor_hash(&image); + } + last_hash = sensor_hash(&image); + } + assert_ne!(first_hash, last_hash, "the QP value must reach the dequantization step"); + for value in [130i32, 176] { + let fixture = v200_constant_bands_file(16, 16, value, 400, None, gains, bases); + assert!(matches!(decode(&fixture.file), Err(RawError::Unsupported(_))), "QP {value}"); + assert!(matches!(probe_info(&fixture.file), Err(RawError::Unsupported(_)))); + } +} + +// --- determinism (property 5) --- + +#[test] +fn decoding_is_deterministic_across_repeated_and_parallel_invocations() { + let craw = craw_roundtrip_file((24, 16, 2), 4, 7, &lcg_values(7, MAX_PLANE_VALUES)); + let lossless = lossless_file((8, 6), &lcg_values(11, 4 * 4 * 3)); + let adaptive = v200_constant_bands_file(16, 16, 150, 400, Some((4, 8)), [0, 0, 0, 0, 8, 8, 8, 8, 8, 8], [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]); + for fixture in [&craw, &lossless, &adaptive] { + let first = decode(&fixture.file).unwrap(); + assert_eq!(first, decode(&fixture.file).unwrap(), "repeated decodes must be identical"); + let expected_hash = sensor_hash(&first); + let hashes: Vec<u64> = std::thread::scope(|scope| { + let handles: Vec<_> = (0..4).map(|_| scope.spawn(|| sensor_hash(&decode(&fixture.file).unwrap()))).collect(); + handles.into_iter().map(|handle| handle.join().unwrap()).collect() + }); + assert!(hashes.iter().all(|&hash| hash == expected_hash), "parallel decodes must agree with the serial hash"); + } +} + +// --- QP map shapes: odd heights, large jumps, over-long prefixes (properties 3 and 6) --- + +/// An odd QP-map height (16×12 sensor, 1×3 map) decodes, and with step 1 (base 1, no gain) the flat LL3 +/// coefficient comes through exactly. +#[test] +fn odd_qp_map_height_0x200_decodes_within_range() { + let gains = [0; 10]; + let bases = [1; 10]; + let fixture = v200_constant_bands_file(16, 12, 150, 400, None, gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert_eq!(samples, &vec![8592u16; 16 * 12], "step 1 (base 1) keeps the LL3 coefficient exact"); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); +} + +/// Adaptive steps on an odd-height map: the coarser bands pair QP rows, averaging the QP values before the +/// step lookup, and the last row of an odd map has no partner and is used alone. Here HL2 has two rows: the +/// first pairs QP rows 0 and 1 (150 and 153, average 151), the second is QP row 2 alone (151). The adaptive +/// stream must therefore decode exactly like the uniform stream whose step is the one QP 151 gives, +/// and differ from the uniform streams one step either side. +#[test] +fn odd_height_qp_map_uses_its_last_row_alone() { + let map = [150, 153, 151]; + let step = |qp: i32| 2 + 8 * quant_step((qp - 124) as u8) as u32 / 8; + assert_eq!(step(151), 16); + let adaptive_gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let adaptive_bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let uniform = |hl_step: u32| { + let fixture = v200_mapped_file(16, 12, &map, 400, Some((4, 8)), [0; 10], [8, 8, 8, 8, hl_step, hl_step, hl_step, hl_step, hl_step, hl_step]); + decode(&fixture.file).unwrap() + }; + let fixture = v200_mapped_file(16, 12, &map, 400, Some((4, 8)), adaptive_gains, adaptive_bases); + let adaptive = decode(&fixture.file).unwrap(); + assert_eq!(adaptive, uniform(step(151)), "QP rows 0+1 average to 151 and the unpaired row 2 is 151 alone"); + assert_ne!(adaptive, uniform(step(151) - 1)); + assert_ne!(adaptive, uniform(step(151) + 1)); + assert_eq!(probe_info(&fixture.file).unwrap(), adaptive.info()); +} + +/// A map whose neighbouring cells differ by up to 37 forces symbols past the plain Rice range, so the +/// 23-zero escape is exercised deterministically (a run of equal cells drops the Rice parameter to 0 first). +#[test] +fn qp_map_with_large_jumps_uses_the_escape_code() { + let map = [131, 131, 131, 131, 168, 131, 168, 131]; + let gains = [0, 0, 0, 0, 8, 8, 8, 8, 8, 8]; + let bases = [8, 8, 8, 8, 2, 2, 2, 2, 2, 2]; + let fixture = v200_mapped_file(16, 32, &map, 400, Some((4, 8)), gains, bases); + let image = decode(&fixture.file).unwrap(); + let RawData::U16(samples) = &image.data else { panic!("u16 sensor data expected") }; + assert!(samples.iter().all(|&sample| sample <= 16383)); + assert_eq!(probe_info(&fixture.file).unwrap(), image.info()); +} + +/// A QP Rice prefix longer than the 23-zero escape is corrupt data, not an endless read. +#[test] +fn qp_rice_prefix_longer_than_the_escape_is_corrupt() { + // The 32-bit literal 300 (QP 150), then 24 zero bits with no terminating 1 within 23 zeros. + let payload = [0x00, 0x00, 0x01, 0x2c, 0x00, 0x00, 0x00, 0x80]; + let file = adversarial_frame(3, &payload, 0); + let error = decode(&file).unwrap_err(); + assert!(matches!(error, RawError::Corrupt(ref message) if message.contains("QP Rice escape prefix")), "{error:?}"); + assert!(probe_info(&file).is_err()); +} diff --git a/crates/raw/tests/dng.rs b/crates/raw/tests/dng.rs index f95afef22..dddafd5d0 100644 --- a/crates/raw/tests/dng.rs +++ b/crates/raw/tests/dng.rs @@ -59,8 +59,10 @@ fn synthetic(w: usize, h: usize, cfa: Option<Cfa>, cpp: usize) -> RawImage { color, wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata, + lens_metadata: Default::default(), } } diff --git a/crates/raw/tests/lossy_dng.rs b/crates/raw/tests/lossy_dng.rs new file mode 100644 index 000000000..3b7480ff2 --- /dev/null +++ b/crates/raw/tests/lossy_dng.rs @@ -0,0 +1,97 @@ +//! Lossy DNG (compression 34892) round trip: write, decode, develop, compare with the source. + +use lightcraft_raw::{ + BlackLevel, Cfa, ColorData, DngCompression, DngWriteOptions, Mat3, Method, OpcodeLists, Orientation, RawData, RawFormat, RawImage, Rect, +}; + +/// A smooth Bayer mosaic (12-bit, black 64) of `w × h` with a colour matrix and a white balance. +fn mosaic(w: usize, h: usize) -> RawImage { + let data: Vec<u16> = (0..w * h) + .map(|i| { + let (x, y) = (i % w, i / w); + let c = (x % 2) + (y % 2); + (64.0 + 3000.0 * (x as f32 / w as f32) * (0.5 + c as f32 * 0.25) + 500.0 * (y as f32 / h as f32)) as u16 + }) + .collect(); + let color = ColorData { + illuminant: [21, 0], + color_matrix: [Some(Mat3([[0.7, -0.1, -0.05], [-0.4, 1.2, 0.2], [-0.1, 0.2, 0.6]])), None], + as_shot_neutral: Some([0.5, 1.0, 0.7]), + ..Default::default() + }; + RawImage { + format: RawFormat::Dng, + width: w, + height: h, + cpp: 1, + data: RawData::U16(data), + cfa: Some(Cfa::bayer("RGGB").unwrap()), + bits: 12, + black: BlackLevel::uniform(64.0), + white: vec![4095.0], + active_area: Rect::new(0, 0, w, h), + crop: Rect::new(2, 2, w - 4, h - 4), + orientation: Orientation::Rotate90, + color, + wb_multipliers: None, + linearized: false, + opcodes: OpcodeLists::default(), + metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, + } +} + +fn lossy(raw: &RawImage, long_edge: Option<u32>) -> Vec<u8> { + let opts = DngWriteOptions { compression: DngCompression::LossyJpeg { tile: 32, quality: 95, long_edge }, ..Default::default() }; + lightcraft_raw::write_dng(raw, &opts).unwrap() +} + +#[test] +fn lossy_dng_round_trips_close_to_the_source() { + let raw = mosaic(80, 56); + let bytes = lossy(&raw, None); + let back = lightcraft_raw::decode(&bytes).unwrap(); + assert_eq!((back.width, back.height, back.cpp, back.cfa.is_none()), (76, 52, 3, true), "demosaiced, default crop applied"); + assert!(back.linearized); + assert_eq!(back.orientation, raw.orientation); + assert_eq!(back.color.color_matrix, raw.color.color_matrix); + assert_eq!(back.color.as_shot_neutral, raw.color.as_shot_neutral); + let a = raw.develop(Method::Ahd).unwrap(); + let b = back.develop(Method::Ahd).unwrap(); + assert_eq!((a.width, a.height), (b.width, b.height)); + let (mut sum, mut worst) = (0f64, 0f32); + for (p, q) in a.data.iter().zip(&b.data) { + for k in 0..3 { + let d = (p[k].clamp(0.0, 1.0) - q[k]).abs(); + sum += d as f64; + worst = worst.max(d); + } + } + let mean = sum / (a.data.len() * 3) as f64; + assert!(mean < 0.01 && worst < 0.08, "mean {mean} worst {worst}"); +} + +#[test] +fn lossy_dng_scales_to_the_long_edge_and_is_smaller() { + let raw = mosaic(160, 96); + let small = lossy(&raw, Some(78)); + let back = lightcraft_raw::decode(&small).unwrap(); + assert_eq!(back.width.max(back.height), 78); + assert_eq!(back.width, 78); + assert!(back.height == 46 || back.height == 47, "{}", back.height); + let full = lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: DngCompression::Uncompressed, ..Default::default() }).unwrap(); + assert!(small.len() < full.len() / 2, "{} vs {}", small.len(), full.len()); +} + +#[test] +fn lossy_dng_of_linear_data_and_extreme_values() { + // a 3-sample linear raw with values above white, negative and non-finite samples: no panic, clipped + let (w, h) = (20usize, 12usize); + let data: Vec<f32> = (0..w * h * 3).map(|i| [2.0, -1.0, f32::NAN, 0.5, f32::INFINITY][i % 5]).collect(); + let raw = RawImage { cpp: 3, cfa: None, data: RawData::F32(data), white: vec![1.0], black: BlackLevel::uniform(0.0), ..mosaic(w, h) }; + let back = lightcraft_raw::decode(&lossy(&raw, Some(4))).unwrap(); + assert_eq!(back.width.max(back.height), 4); + let rgb = back.develop(Method::Bilinear).unwrap(); + assert!(rgb.data.iter().flatten().all(|v| v.is_finite() && (0.0..=1.0).contains(v))); +} diff --git a/crates/raw/tests/olympus_samsung_corpus.rs b/crates/raw/tests/olympus_samsung_corpus.rs new file mode 100644 index 000000000..b90e7c7cb --- /dev/null +++ b/crates/raw/tests/olympus_samsung_corpus.rs @@ -0,0 +1,114 @@ +//! One CC0 file per uncompressed Olympus ORF and Samsung SRW body from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.70–1.00 on these files, a misread layout or stream gives r near 0). The E-10 is +//! pinned only (its small JPEG gives r 0.59); its render was inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn olympus_and_samsung_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("dng-samsung-gx10.dng", (3936, 2624), 0x25d5_1c29_e67f_99e8, true), + ("dng-samsung-gx20.dng", (4864, 3136), 0x516f_3df8_c879_a2c7, true), + ("orf-olympus-c5060wz.orf", (2608, 1949), 0x4185_d439_e31d_72bf, true), + ("orf-olympus-c7070wz.orf", (3088, 2309), 0x016a_f9d1_89e5_4aaf, true), + ("orf-olympus-e-10.orf", (2256, 1684), 0x1029_c758_4810_f56b, false), + ("orf-olympus-e-300.orf", (3360, 2504), 0xa9b4_e783_03a4_45c2, true), + ("orf-olympus-e-330.orf", (3280, 2450), 0xc938_38fa_9822_c81a, true), + ("orf-olympus-e-500.orf", (3360, 2504), 0x5e22_2ac8_17ed_89d9, true), + ("orf-olympus-e-m5-mark-ii.orf", (9280, 6932), 0xa222_be38_c767_7782, true), + ("orf-olympus-pen-f.orf", (10400, 7796), 0x415e_bf27_092e_9897, true), + ("orf-olympus-sh-2.orf", (4624, 3470), 0xb25b_8d24_4ade_1f3d, true), + ("orf-olympus-sp510uz.orf", (3088, 2309), 0xa885_b7ca_83ba_02f1, true), + ("orf-olympus-sp550uz.orf", (3088, 2309), 0x30ac_5e9f_34b9_8645, true), + ("orf-olympus-sp565uz.orf", (3664, 2741), 0x0962_c436_052d_efa8, true), + ("orf-olympus-sp570uz.orf", (3664, 2741), 0xdd62_06d6_17d0_8a46, true), + ("orf-olympus-xz-10.orf", (3984, 2990), 0x6274_1a67_cd42_40f7, true), + ("srw-samsung-nx1000.srw", (5632, 3720), 0xd99f_f1d0_e3d6_435f, true), + ("srw-samsung-nx11.srw", (4704, 3124), 0x29b5_58f8_09e4_85cb, true), + ("srw-samsung-nx1100.srw", (5632, 3720), 0x69cd_048b_c8a1_dd7a, true), + ("srw-samsung-nx200.srw", (5632, 3720), 0x61ef_6100_b9f6_dabd, true), + ("srw-samsung-nx210.srw", (5632, 3720), 0x50d5_e55e_6ec7_7faa, true), + ("srw-samsung-nx5.srw", (4704, 3124), 0xf20d_905b_41b3_32f6, true), + ("srw-samsung-wb2000.srw", (3728, 2781), 0xe40f_1c19_41ce_9000, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Olympus / Samsung bodies checked on {seen} files"); +} diff --git a/crates/raw/tests/robust.rs b/crates/raw/tests/robust.rs index 6dc383de8..323149fda 100644 --- a/crates/raw/tests/robust.rs +++ b/crates/raw/tests/robust.rs @@ -24,8 +24,10 @@ fn sample(comp: DngCompression) -> Vec<u8> { color: ColorData::default(), wb_multipliers: None, linearized: false, + pixel_aspect: 1.0, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), }; lightcraft_raw::write_dng(&raw, &DngWriteOptions { compression: comp, ..Default::default() }).unwrap() } @@ -75,7 +77,9 @@ proptest! { #[test] fn truncation_at_every_length() { - for comp in [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }] { + for comp in + [DngCompression::Uncompressed, DngCompression::Lj92 { tile: 16 }, DngCompression::LossyJpeg { tile: 16, quality: 80, long_edge: None }] + { let d = sample(comp); for n in (0..d.len()).step_by(7) { exercise(&d[..n]); @@ -161,9 +165,150 @@ fn non_finite_black_levels_and_samples_develop() { linearized: false, opcodes: OpcodeLists::default(), metadata: Default::default(), + lens_metadata: Default::default(), + pixel_aspect: 1.0, }; for img in [raw.clone(), lightcraft_raw::decode(&lightcraft_raw::write_dng(&raw, &DngWriteOptions::default()).unwrap()).unwrap()] { let rgb = img.develop(Method::Ahd).unwrap(); assert!(rgb.data.iter().flatten().all(|v| v.is_finite()), "float NaN samples: finite output"); } } + +// --- CR3 containers (Canon CRX): structurally valid files whose every byte may be mutated --- + +/// A CR3 with a single CRAW track carrying a CMP1 descriptor (and optional CDI1/IAD1) plus raw samples. +mod cr3 { + pub(crate) fn box_bytes(kind: &[u8; 4], body: &[u8]) -> Vec<u8> { + let mut out = ((body.len() + 8) as u32).to_be_bytes().to_vec(); + out.extend_from_slice(kind); + out.extend_from_slice(body); + out + } + + fn track(entry: &[u8], offset: u64, size: u32) -> Vec<u8> { + let mut stsd = vec![0u8; 4]; + stsd.extend(1u32.to_be_bytes()); + stsd.extend_from_slice(entry); + let mut stsz = vec![0u8; 4]; + stsz.extend(size.to_be_bytes()); + stsz.extend(1u32.to_be_bytes()); + let mut co64 = vec![0u8; 4]; + co64.extend(1u32.to_be_bytes()); + co64.extend(offset.to_be_bytes()); + let tables = [box_bytes(b"stsd", &stsd), box_bytes(b"stsz", &stsz), box_bytes(b"co64", &co64)].concat(); + box_bytes(b"trak", &box_bytes(b"mdia", &box_bytes(b"minf", &box_bytes(b"stbl", &tables)))) + } + + /// `CMP1` coding descriptor: 52 bytes, four planes, 14 bits, RGGB, `levels` wavelet levels. + pub(crate) fn cmp1(width: u32, height: u32, tile_width: u32, tile_height: u32, levels: u8, header_size: u32) -> Vec<u8> { + let mut coding = vec![0u8; 52]; + coding[0..2].copy_from_slice(&0xff00u16.to_be_bytes()); + coding[2..4].copy_from_slice(&48u16.to_be_bytes()); + coding[4..6].copy_from_slice(&0x100u16.to_be_bytes()); + coding[8..12].copy_from_slice(&width.to_be_bytes()); + coding[12..16].copy_from_slice(&height.to_be_bytes()); + coding[16..20].copy_from_slice(&tile_width.to_be_bytes()); + coding[20..24].copy_from_slice(&tile_height.to_be_bytes()); + coding[24] = 14; + coding[25] = 0x40; + coding[26] = levels; + coding[28..32].copy_from_slice(&header_size.to_be_bytes()); + coding + } + + pub(crate) fn file(coding: &[u8], width: u16, height: u16, sample: &[u8]) -> Vec<u8> { + let mut craw = vec![0u8; 82]; + craw[24..26].copy_from_slice(&width.to_be_bytes()); + craw[26..28].copy_from_slice(&height.to_be_bytes()); + craw.extend(box_bytes(b"CMP1", coding)); + let entry = box_bytes(b"CRAW", &craw); + let ftyp = box_bytes(b"ftyp", b"crx \0\0\0\x01crx isom"); + let track_len = track(&entry, 0, sample.len() as u32).len(); + let offset = (ftyp.len() + 8 + track_len + 7) & !7; + let mut out = ftyp; + out.extend(box_bytes(b"moov", &track(&entry, offset as u64, sample.len() as u32))); + out.resize(offset, 0); + out.extend_from_slice(sample); + out + } + + fn marker(out: &mut Vec<u8>, code: u16, size: u32, flags: u32) { + out.extend(code.to_be_bytes()); + out.extend(8u16.to_be_bytes()); + out.extend(size.to_be_bytes()); + out.extend(flags.to_be_bytes()); + } + + /// A 4×4 lossless CRX stream: four 2×2 planes, each sample value 1 (sensor level 8193). + pub(crate) fn lossless_sample() -> Vec<u8> { + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 4, 0); + for p in 0..4u32 { + marker(&mut sample, 0xff02, 1, (p << 28) | 0x0800_0000); + marker(&mut sample, 0xff03, 1, 4 << 19); + } + sample.extend([0x1e; 4]); + sample + } + + pub(crate) fn lossless_file() -> Vec<u8> { + file(&cmp1(4, 4, 4, 4, 0, 108), 4, 4, &lossless_sample()) + } + + /// A 16×16 three-level C-RAW CRX stream of flat zero subbands (sensor level 8192). + pub(crate) fn craw_sample() -> Vec<u8> { + let mut sample = Vec::new(); + marker(&mut sample, 0xff01, 52, 0); + for plane in 0..4u32 { + marker(&mut sample, 0xff02, 13, (plane << 28) | 0x0800_0000); + for band in 0..10u32 { + marker(&mut sample, 0xff03, if band >= 7 { 2 } else { 1 }, (band << 28) | (4 << 19)); + } + } + for _ in 0..4 { + sample.extend([0x80; 4]); + sample.extend([0xf0; 3]); + for _ in 0..3 { + sample.extend([0xff, 0xf8]); + } + } + sample + } + + pub(crate) fn craw_file() -> Vec<u8> { + file(&cmp1(16, 16, 16, 16, 3, 540), 16, 16, &craw_sample()) + } +} + +/// Both fixtures decode as built, so the mutations below hit live decode paths. +#[test] +fn cr3_fixtures_decode() { + let lossless = lightcraft_raw::decode(&cr3::lossless_file()).unwrap(); + assert_eq!((lossless.width, lossless.height, lossless.bits), (4, 4, 14)); + assert_eq!(lossless.data.get(0), 8193.0); + let craw = lightcraft_raw::decode(&cr3::craw_file()).unwrap(); + assert_eq!((craw.width, craw.height, craw.bits), (16, 16, 14)); + assert_eq!(craw.data.get(0), 8192.0); + assert_eq!(lightcraft_raw::probe_info(&cr3::craw_file()).unwrap(), craw.info()); +} + +proptest! { + /// Mutating or truncating structurally valid CR3 containers exercises the CRX marker walker and + /// entropy readers far deeper than random TIFF bytes: errors or clean decodes, never a panic. + #[test] + fn mutated_cr3_containers_never_panic( + kind in 0usize..2, + flips in proptest::collection::vec((any::<usize>(), any::<u8>()), 1..24), + cut in any::<usize>(), + ) { + let mut data = if kind == 0 { cr3::lossless_file() } else { cr3::craw_file() }; + for (i, v) in flips { + let n = data.len(); + data[i % n] = v; + } + let keep = if cut % 3 == 0 { cut % data.len() } else { data.len() }; + exercise(&data[..keep]); + // The header-only description path (used when importing) must hold up against the same mutations. + let _ = lightcraft_raw::probe_info(&data[..keep]); + } +} diff --git a/crates/raw/tests/sony_leica_corpus.rs b/crates/raw/tests/sony_leica_corpus.rs new file mode 100644 index 000000000..edd04cac3 --- /dev/null +++ b/crates/raw/tests/sony_leica_corpus.rs @@ -0,0 +1,114 @@ +//! One CC0 file per Sony and Leica body not covered by an earlier check from raw.pixls.us (URLs and SHA-256 in `RAW_SAMPLES`, +//! xtask/src/main.rs; downloaded into the gitignored corpus/raw by `cargo xtask corpus --download`). +//! +//! No external decoder was run on these: the FNV-1a hashes over the decoded u16 samples (row-major, +//! little-endian, masked borders included) pin LightCraft's own decode, and the check against the camera is +//! black-box: the green sites of the active area, on a 24 × 16 grid, must follow the camera's own embedded +//! JPEG (gamma-encoded Pearson r; 0.62–1.00 on these files, a misread layout or stream gives r near 0). Five files are +//! pinned only: the M8 and M9 DNGs carry no JPEG, the two M Monochrom bodies have no colour filter, and the +//! M (Typ 240) preview covers a different field (r 0.58); their renders were inspected by eye. +//! Missing files are reported as skipped. + +use lightcraft_raw::{RawData, decode, embedded_preview, probe_info}; +use std::path::{Path, PathBuf}; + +fn corpus() -> PathBuf { + std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("../../corpus")) +} + +/// Green of the decoded mosaic against the camera's embedded JPEG on a `gw × gh` grid of the active area. +fn green_vs_camera_jpeg(raw: &lightcraft_raw::RawImage, jpeg: &[u8], gw: usize, gh: usize) -> f64 { + use zune_core::{bytestream::ZCursor, colorspace::ColorSpace, options::DecoderOptions}; + let opts = DecoderOptions::default().jpeg_set_out_colorspace(ColorSpace::RGB); + let mut d = zune_jpeg::JpegDecoder::new_with_options(ZCursor::new(jpeg), opts); + let px = d.decode().unwrap(); + let info = d.info().unwrap(); + let (jw, jh) = (info.width as usize, info.height as usize); + let mean = |sum: Vec<f64>, n: Vec<f64>| sum.iter().zip(&n).map(|(s, n)| s / n.max(1.0)).collect::<Vec<f64>>(); + let (mut js, mut jn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in 0..jh { + for x in 0..jw { + let i = (y * gh / jh) * gw + x * gw / jw; + js[i] += f64::from(px[(y * jw + x) * 3 + 1]); + jn[i] += 1.0; + } + } + let RawData::U16(samples) = &raw.data else { panic!("integer sensor data expected") }; + let cfa = raw.cfa.clone().expect("colour-filter layout"); + let (a, black, white) = (raw.active_area, f64::from(raw.black.mean()), f64::from(raw.white_at(0))); + let (mut rs, mut rn) = (vec![0f64; gw * gh], vec![0f64; gw * gh]); + for y in a.y..a.y + a.height { + for x in a.x..a.x + a.width { + if cfa.color_at(x, y) == 1 { + let i = ((y - a.y) * gh / a.height) * gw + (x - a.x) * gw / a.width; + rs[i] += ((f64::from(samples[y * raw.width + x]) - black).max(0.0) / (white - black)).powf(1.0 / 2.2); + rn[i] += 1.0; + } + } + } + let (r, j) = (mean(rs, rn), mean(js, jn)); + let n = r.len() as f64; + let (mr, mj) = (r.iter().sum::<f64>() / n, j.iter().sum::<f64>() / n); + let cov: f64 = r.iter().zip(&j).map(|(x, y)| (x - mr) * (y - mj)).sum(); + let vr: f64 = r.iter().map(|x| (x - mr).powi(2)).sum(); + let vj: f64 = j.iter().map(|y| (y - mj).powi(2)).sum(); + cov / (vr * vj).sqrt() +} + +#[test] +fn sony_and_leica_bodies_decode_like_the_camera_jpeg() { + // (file, sensor width × height, pinned FNV-1a of the sensor samples, compare with the camera JPEG) + let cases: &[(&str, (usize, usize), u64, bool)] = &[ + ("arw-sony-dsc-rx100m7a.arw", (5504, 3672), 0xd307_0cab_69e7_be05, true), + ("arw-sony-dsc-rx1rm3.arw", (9600, 6376), 0x15a1_1634_d24c_0ba2, true), + ("arw-sony-ilce-1m2.arw", (8672, 5784), 0x7e88_f7b5_8759_d3e9, true), + ("arw-sony-ilce-6400a.arw", (6048, 4024), 0x538b_bd85_9783_7d07, true), + ("arw-sony-ilce-7m5.arw", (7168, 5120), 0x337c_89f0_d11a_3153, true), + ("arw-sony-ilme-fx2.arw", (7040, 4688), 0x0897_d087_2ac8_b8a5, true), + ("arw-sony-zv-1m2.arw", (5504, 3672), 0x25c4_09f4_ae51_5ada, true), + ("arw-sony-zv-e10m2.arw", (6656, 4608), 0x47e5_b9b2_3912_b2ac, true), + ("dng-leica-cl.dng", (6120, 4016), 0xc59f_487f_bb92_6877, true), + ("dng-leica-m-monochrom-typ-246.dng", (5984, 4000), 0xc0d0_57fa_ed5c_0d1d, false), + ("dng-leica-m-monochrom.dng", (5216, 3472), 0x17ef_5964_1abc_6b39, false), + ("dng-leica-m-typ-240.dng", (5984, 4000), 0x7955_7612_8c83_a10f, false), + ("dng-leica-m10-r.dng", (7872, 5208), 0x573a_9fc4_9aa2_284c, true), + ("dng-leica-m10.dng", (5984, 3992), 0x1594_dd1c_ca00_d124, true), + ("dng-leica-m8.dng", (3920, 2638), 0x8354_ac17_b3ca_f4b8, false), + ("dng-leica-m9.dng", (5216, 3472), 0x141c_4039_429d_5676, false), + ("dng-leica-q-typ-116.dng", (6120, 4016), 0x11f9_9859_db23_b42e, true), + ("dng-leica-q2.dng", (8424, 5632), 0xbb4b_2bf3_ffd0_e412, true), + ("dng-leica-sl-typ-601.dng", (6120, 4016), 0xa3df_d8cf_9d7c_eff2, true), + ("dng-leica-sl2.dng", (8424, 5632), 0xadbf_7522_c3d0_a02d, true), + ("dng-leica-x2.dng", (4944, 3272), 0xbde8_5411_9431_1bd6, true), + ("rwl-leica-c-lux.rwl", (5488, 3664), 0x3c13_2cae_7028_aa0d, true), + ]; + let mut seen = 0; + for &(name, sensor, hash, jpeg_check) in cases { + let path = corpus().join("raw").join(name); + let Ok(bytes) = std::fs::read(&path) else { + eprintln!("skip: {} absent", path.display()); + continue; + }; + let raw = decode(&bytes).unwrap_or_else(|e| panic!("{name}: {e}")); + raw.validate().unwrap(); + assert_eq!((raw.width, raw.height, raw.cpp), (sensor.0, sensor.1, 1), "{name}: sensor size"); + assert_eq!(raw.info(), probe_info(&bytes).unwrap(), "{name}: probe and decode disagree"); + let RawData::U16(samples) = &raw.data else { panic!("{name}: integer sensor data expected") }; + let actual = samples + .iter() + .flat_map(|v| v.to_le_bytes()) + .fold(0xcbf2_9ce4_8422_2325u64, |h, byte| (h ^ u64::from(byte)).wrapping_mul(0x100_0000_01b3)); + assert_eq!(actual, hash, "{name}: sensor pixels differ from the pinned decode"); + assert!(raw.white_at(0) > raw.black.mean(), "{name}: invalid black/white levels"); + if !jpeg_check { + seen += 1; + continue; + } + let jpeg = embedded_preview(&bytes).unwrap_or_else(|| panic!("{name}: no camera JPEG")); + let r = green_vs_camera_jpeg(&raw, &jpeg, 24, 16); + eprintln!("{name:40} {}x{}: green follows the camera JPEG, r = {r:.3}", raw.width, raw.height); + assert!(r > 0.6, "{name}: decoded mosaic does not follow the camera JPEG (r = {r:.3})"); + seen += 1; + } + eprintln!("Sony / Leica bodies checked on {seen} files"); +} diff --git a/crates/raw/tests/sony_lens_inventory.rs b/crates/raw/tests/sony_lens_inventory.rs new file mode 100644 index 000000000..3eda88418 --- /dev/null +++ b/crates/raw/tests/sony_lens_inventory.rs @@ -0,0 +1,351 @@ +//! Opt-in local check of Sony's lens-correction records ([`lightcraft_raw::lens_metadata`]); it does nothing unless +//! `LIGHTCRAFT_SONY_LENS_DIR` names a directory of ARW files, so CI never runs it. The files there (searched +//! recursively) are only read, from their headers, and one line per file is printed: settings, where each table came +//! from, whether its raw-IFD and `SR2SubIFD` copies agree, sample ranges and status. Header and full decode are +//! compared too when `LIGHTCRAFT_SONY_LENS_FULL=1` (slow on large files). +//! +//! With `LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory of those files (comma-separated, no quoting, a header +//! row, one row per file name), every file must have a row, every row a file, and these columns must match where +//! present: `split` (the file's folder name), `raw_size` (`WxH`), `compression`, `crop_origin` / `crop_size` (Sony's +//! crop tags, `x y` / `w h`), and for each of `shading`, `ca` and `distortion`: `_entries` (TIFF count), `_bytes`, +//! `_samples` (the leading sample count), `_min` / `_max` (over the samples), `_sr2_equal` (`True` / `False`), +//! `_setting_raw` and `_setting` (a label: `Off`, `Auto`, `Auto (ILCE-1)`, `Auto fixed by lens`, `No correction +//! params available`). A file that doesn't decode must still have a row, and fails the check when it has one. +//! +//! Any decode error fails the check except an unsupported variant (`RawError::Unsupported`, e.g. the packed ARW of +//! some older bodies): those are counted, and the count must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` (default 0). +//! +//! `LIGHTCRAFT_SONY_LENS_DIR=… LIGHTCRAFT_SONY_LENS_INVENTORY=….csv cargo test -p lightcraft-raw --release --test +//! sony_lens_inventory -- --nocapture` + +use lightcraft_raw::lens_metadata::{SonyCopy, SonyField, SonyLensCorrections, SonyLensStatus, SonySetting, SonySettingMeaning, SonySource}; +use lightcraft_raw::{RawError, RawInfo, decode, probe_info}; +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +fn arw_files(dir: &Path, depth: usize, out: &mut Vec<PathBuf>) { + let Ok(entries) = std::fs::read_dir(dir) else { return }; + for entry in entries.flatten() { + let path = entry.path(); + if path.is_dir() && depth < 8 { + arw_files(&path, depth + 1, out); + } else if path.extension().is_some_and(|e| e.eq_ignore_ascii_case("arw")) { + out.push(path); + } + } +} + +/// One component's columns, from the decoded record. +struct Component { + setting: Option<(u16, SonySettingMeaning)>, + /// Samples when the table was decoded. + samples: Option<Vec<i16>>, + source: Option<SonySource>, + /// The raw-IFD and `SR2SubIFD` copies' states. + copies: (SonyCopy, SonyCopy), + agree: Option<bool>, + status: String, +} + +fn component<T>(setting: &SonyField<SonySetting>, table: &SonyField<T>, samples: Option<Vec<i16>>, status: SonyLensStatus) -> Component { + Component { + setting: setting.value.map(|s| (s.value, s.meaning)), + samples, + source: table.source, + copies: (table.raw_ifd, table.sr2), + agree: table.copies_agree, + status: format!("{status:?}"), + } +} + +/// Shading, CA and distortion of a record, by CSV column prefix. +fn components(s: &SonyLensCorrections) -> [(&'static str, Component); 3] { + let (sh, ca, d) = (&s.shading, &s.chromatic_aberration, &s.distortion); + [ + ("shading", component(&sh.setting, &sh.table, sh.table.value.map(|v| v.to_vec()), sh.status)), + ("ca", component(&ca.setting, &ca.table, ca.table.value.map(|v| v.concat()), ca.status)), + ("distortion", component(&d.setting, &d.table, d.table.value.map(|v| v.to_vec()), d.status)), + ] +} + +fn label(meaning: SonySettingMeaning) -> &'static [&'static str] { + match meaning { + SonySettingMeaning::Off => &["Off"], + SonySettingMeaning::Auto => &["Auto", "Auto (ILCE-1)"], + SonySettingMeaning::ForcedAuto => &["Auto fixed by lens"], + SonySettingMeaning::Unavailable => &["No correction params available"], + SonySettingMeaning::Unknown(_) => &[], + } +} + +/// The columns this check derives from a file, by CSV column name. +fn columns(path: &Path, info: &RawInfo) -> BTreeMap<String, String> { + let mut out = BTreeMap::new(); + let mut put = |k: &str, v: String| { + out.insert(k.to_string(), v); + }; + if let Some(split) = path.parent().and_then(|p| p.file_name()) { + put("split", split.to_string_lossy().into_owned()); + } + let Some(s) = &info.lens_metadata.sony else { return out }; + let g = &s.geometry; + put("raw_size", format!("{}x{}", g.storage_width, g.storage_height)); + put("compression", g.compression.to_string()); + if let Some(c) = g.sony_crop { + put("crop_origin", format!("{} {}", c.x, c.y)); + put("crop_size", format!("{} {}", c.width, c.height)); + } + for (name, c) in components(s) { + if let Some(v) = &c.samples { + put(&format!("{name}_entries"), (v.len() + 1).to_string()); + put(&format!("{name}_bytes"), (2 * (v.len() + 1)).to_string()); + put(&format!("{name}_samples"), v.len().to_string()); + put(&format!("{name}_min"), v.iter().min().map_or(String::new(), i16::to_string)); + put(&format!("{name}_max"), v.iter().max().map_or(String::new(), i16::to_string)); + } + put(&format!("{name}_sr2_equal"), c.agree.map_or("unknown", |a| if a { "True" } else { "False" }).to_string()); + if let Some((value, _)) = c.setting { + put(&format!("{name}_setting_raw"), value.to_string()); + } + } + out +} + +fn summary(info: &RawInfo) -> String { + let Some(s) = &info.lens_metadata.sony else { return "no lens record".into() }; + let mut line = format!("sr2 {} |", if s.sr2_read { "read" } else { "absent" }); + for (name, c) in components(s) { + let setting = c.setting.map_or("-".into(), |(v, m)| format!("{v} {m:?}")); + let range = c + .samples + .as_ref() + .map_or("no table".into(), |v| format!("{} samples {}..{}", v.len(), v.iter().min().unwrap_or(&0), v.iter().max().unwrap_or(&0))); + line += + &format!(" {name}: {setting}, {range}, {:?} (copies {:?} / {:?}), agree {:?}, {} |", c.source, c.copies.0, c.copies.1, c.agree, c.status); + } + line + &format!(" crop {:?} reference {:?} 3:2 {}", s.geometry.image_crop, s.geometry.reference, s.geometry.native_3_2) +} + +/// `(header, rows by file name)` of a simple CSV file. +fn read_csv(path: &Path) -> (Vec<String>, BTreeMap<String, Vec<String>>) { + let text = std::fs::read_to_string(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + let mut lines = text.lines().filter(|l| !l.trim().is_empty()); + let header: Vec<String> = lines.next().expect("CSV header").split(',').map(|s| s.trim().to_string()).collect(); + let file = header.iter().position(|h| h == "file").expect("a `file` column"); + let mut rows = BTreeMap::new(); + for line in lines { + assert!(!line.contains('"'), "quoted CSV fields are not supported: {line}"); + let row: Vec<String> = line.split(',').map(|s| s.trim().to_string()).collect(); + assert_eq!(row.len(), header.len(), "CSV row with {} fields, header has {}: {line}", row.len(), header.len()); + let name = row[file].clone(); + assert!(rows.insert(name.clone(), row).is_none(), "{name} listed twice"); + } + (header, rows) +} + +#[test] +fn sony_lens_records_reproduce_a_local_inventory() { + let Some(dir) = std::env::var_os("LIGHTCRAFT_SONY_LENS_DIR").map(PathBuf::from) else { + eprintln!("skip: LIGHTCRAFT_SONY_LENS_DIR unset"); + return; + }; + let full = std::env::var_os("LIGHTCRAFT_SONY_LENS_FULL").is_some_and(|v| v == "1"); + let inventory = std::env::var_os("LIGHTCRAFT_SONY_LENS_INVENTORY").map(PathBuf::from); + let unsupported = std::env::var("LIGHTCRAFT_SONY_LENS_UNSUPPORTED").map_or(0, |v| v.parse().expect("LIGHTCRAFT_SONY_LENS_UNSUPPORTED: a count")); + let report = check(&dir, inventory.as_deref(), full, unsupported); + assert!(report.problems.is_empty(), "{} problems", report.problems.len()); +} + +/// What [`check`] found. +#[derive(Debug, Default)] +struct Report { + files: usize, + records: usize, + without_record: usize, + unsupported: usize, + problems: Vec<String>, +} + +/// Check every ARW under `dir` (see the module docs). Any decode error is a problem except `Unsupported`, which +/// is counted and must come to exactly `expected_unsupported`; with an inventory, every file (decoded or not) must +/// have a row, and a file with a row must decode. +fn check(dir: &Path, inventory: Option<&Path>, full: bool, expected_unsupported: usize) -> Report { + let inventory = inventory.map(read_csv); + let mut files = Vec::new(); + arw_files(dir, 0, &mut files); + files.sort(); + let mut report = Report { files: files.len(), ..Default::default() }; + if files.is_empty() { + report.problems.push(format!("no ARW files under {}", dir.display())); + } + let (mut matched, mut mismatches, mut seen) = (0usize, Vec::new(), std::collections::BTreeSet::new()); + let compared: Vec<String> = ["split", "raw_size", "compression", "crop_origin", "crop_size"] + .into_iter() + .map(String::from) + .chain( + ["shading", "ca", "distortion"] + .into_iter() + .flat_map(|k| ["entries", "bytes", "samples", "min", "max", "sr2_equal", "setting_raw"].into_iter().map(move |c| format!("{k}_{c}"))), + ) + .collect(); + for path in &files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let bytes = std::fs::read(path).unwrap_or_else(|e| panic!("{}: {e}", path.display())); + seen.insert(name.clone()); + let info = match probe_info(&bytes) { + Ok(info) => info, + Err(e) => { + eprintln!("{name}: {e}"); + match (&inventory, &e) { + (Some((_, rows)), _) if rows.contains_key(&name) => mismatches.push(format!("{name}: does not decode: {e}")), + (Some(_), _) => mismatches.push(format!("{name}: does not decode ({e}) and is not in the inventory")), + (None, RawError::Unsupported(_)) => {} + (None, _) => mismatches.push(format!("{name}: does not decode: {e}")), + } + report.unsupported += usize::from(matches!(e, RawError::Unsupported(_))); + continue; + } + }; + if full { + match decode(&bytes) { + Ok(image) if image.info() == info => {} + Ok(_) => mismatches.push(format!("{name}: header and full decode differ")), + Err(e) => mismatches.push(format!("{name}: full decode fails: {e}")), + } + } + if info.lens_metadata.sony.is_some() { + report.records += 1; + } else { + report.without_record += 1; + } + eprintln!("{name}: {} {}", info.metadata.model.as_deref().unwrap_or("?"), summary(&info)); + let Some((header, rows)) = &inventory else { continue }; + let Some(row) = rows.get(&name) else { + mismatches.push(format!("{name}: not in the inventory")); + continue; + }; + let got = columns(path, &info); + let mut ok = true; + for (i, column) in header.iter().enumerate() { + let expected = &row[i]; + if let Some(setting) = column.strip_suffix("_setting") + && let Some(s) = &info.lens_metadata.sony + { + let meaning = components(s).into_iter().find(|(n, _)| *n == setting).and_then(|(_, c)| c.setting).map(|(_, m)| m); + if !meaning.is_some_and(|m| label(m).contains(&expected.as_str())) { + mismatches.push(format!("{name}: {column} {meaning:?}, inventory {expected}")); + ok = false; + } + continue; + } + if !compared.contains(column) { + continue; + } + let value = got.get(column).map(String::as_str).unwrap_or("(none)"); + if value != expected { + mismatches.push(format!("{name}: {column} {value}, inventory {expected}")); + ok = false; + } + } + matched += usize::from(ok); + } + if let Some((header, rows)) = &inventory { + for name in rows.keys().filter(|n| !seen.contains(*n)) { + mismatches.push(format!("{name}: in the inventory, no such file")); + } + let checked = header.iter().filter(|c| compared.contains(c) || c.ends_with("_setting")).count(); + assert!(checked > 0, "the inventory has none of the compared columns"); + eprintln!("{matched} of {} inventory rows reproduced from {} files ({checked} columns compared)", rows.len(), files.len()); + } + if report.unsupported != expected_unsupported { + mismatches.push(format!("{} files are unsupported variants, {expected_unsupported} expected", report.unsupported)); + } + eprintln!( + "{} files: {} with a lens record, {} decoded without one, {} unsupported ({expected_unsupported} expected)", + report.files, report.records, report.without_record, report.unsupported + ); + for m in &mismatches { + eprintln!("problem: {m}"); + } + report.problems.extend(mismatches); + report +} + +/// A directory under the system temporary directory, removed when dropped. +struct Scratch(PathBuf); + +impl Scratch { + fn new(name: &str) -> Scratch { + let dir = std::env::temp_dir().join(format!("lightcraft-sony-lens-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + Scratch(dir) + } +} + +impl Drop for Scratch { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// A small Sony CFA file with a distortion setting (a lens record), or, `unsupported`, the packed 12-bit ARW variant +/// the decoder refuses as unsupported. +fn synthetic_arw(unsupported: bool) -> Vec<u8> { + use lightcraft_tiff::{IfdBuilder, ImageData, TiffWriter, Value, tags as t}; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![4])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(0x7036, Value::Short(vec![0])); + if unsupported { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![12])); + raw.set(t::COMPRESSION, Value::Short(vec![32767])); + raw.set(0x7010, Value::Short(vec![8000, 10400, 12900, 14100])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 + 8]] }); + } else { + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set_image(ImageData::Strips { rows_per_strip: 4, strips: vec![vec![0; 32 * 4 * 2]] }); + } + TiffWriter::default().write(&[raw]).unwrap() +} + +#[test] +fn files_that_fail_to_decode_fail_the_check() { + // a file that is no raw at all fails the check, whatever is expected + let dir = Scratch::new("decode-error"); + std::fs::write(dir.0.join("not-a-raw.arw"), b"not a raw file").unwrap(); + let report = check(&dir.0, None, false, 0); + assert!(!report.problems.is_empty(), "an ARW that does not decode passed the check"); + assert!(!check(&dir.0, None, false, 1).problems.is_empty(), "a decode error counted as an unsupported variant"); + // an unsupported variant is counted, and must be expected + let dir = Scratch::new("unsupported"); + std::fs::write(dir.0.join("good.arw"), synthetic_arw(false)).unwrap(); + std::fs::write(dir.0.join("packed.arw"), synthetic_arw(true)).unwrap(); + let report = check(&dir.0, None, false, 0); + assert_eq!((report.files, report.records, report.unsupported), (2, 1, 1)); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(check(&dir.0, None, true, 1).problems.is_empty()); + // with an inventory, a file that fails must still be listed: here it is not + let csv = dir.0.join("inventory.csv"); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + // (the inventory itself sits in the directory, but only .arw files are checked) + let mut report = check(&dir.0, Some(&csv), false, 1); + report.problems.sort(); + assert_eq!(report.problems.len(), 1, "{:?}", report.problems); + assert!(report.problems[0].contains("packed.arw") && report.problems[0].contains("not in the inventory"), "{:?}", report.problems); + // listed but failing, and listed and decoding + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\npacked.arw,0\n").unwrap(); + let report = check(&dir.0, Some(&csv), false, 1); + assert!(report.problems.len() == 1 && report.problems[0].contains("packed.arw: does not decode"), "{:?}", report.problems); + std::fs::remove_file(dir.0.join("packed.arw")).unwrap(); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,0\n").unwrap(); + assert!(check(&dir.0, Some(&csv), false, 0).problems.is_empty()); + std::fs::write(&csv, "file,distortion_setting_raw\ngood.arw,17\n").unwrap(); + assert!(!check(&dir.0, Some(&csv), false, 0).problems.is_empty(), "a wrong value passed"); +} diff --git a/crates/raw/tests/sparse_corpus.rs b/crates/raw/tests/sparse_corpus.rs index 15e6398b2..a602dd148 100644 --- a/crates/raw/tests/sparse_corpus.rs +++ b/crates/raw/tests/sparse_corpus.rs @@ -58,7 +58,7 @@ fn partial_reads_describe_raws_as_whole_files_do() { .filter(|p| p.is_file() && !p.extension().is_some_and(|e| e == "part" || e == "txt" || e == "md")) .collect(); if paths.is_empty() { - eprintln!("skip: {} absent", dir.display()); + lightcraft_testkit::skip!(Corpus, "{} absent", dir.display()); return; } paths.sort(); diff --git a/crates/raw/tests/sr2_heap.rs b/crates/raw/tests/sr2_heap.rs new file mode 100644 index 000000000..2b936d18c --- /dev/null +++ b/crates/raw/tests/sr2_heap.rs @@ -0,0 +1,79 @@ +//! Peak heap of reading Sony's `SR2Private` directory (`crates/raw/src/vendor/sr2.rs`), measured with dhat's testing +//! mode. Every ARW with `DNGPrivateData` has its `SR2SubIFD` located and decrypted, so the directory that points to +//! it must not cost memory sized by its other entries: a hostile file can give it fields that each alias most of the +//! file. Only the block's offset, length and key may be read. One test per binary (dhat's profiler is global). + +use lightcraft_tiff::{ByteOrder, IfdBuilder, ImageData, Tiff, TiffWriter, Value, tags as t}; + +#[global_allocator] +static ALLOC: dhat::Alloc = dhat::Alloc; + +/// A 32 × 24 Sony CFA file of `size` bytes whose `SR2Private` directory holds the block's offset, length and key plus +/// `aliases` UNDEFINED fields that each cover the first `size` bytes of the file. Plain black level and white balance +/// are present, so nothing but the lens record needs the `SR2SubIFD`. +fn hostile_arw(size: usize, aliases: u16) -> Vec<u8> { + let o = ByteOrder::Little; + let mut raw = IfdBuilder::new(); + raw.set(t::MAKE, Value::Ascii("SONY".into())); + raw.set(t::MODEL, Value::Ascii("ILCE-7CR".into())); + raw.set(t::IMAGE_WIDTH, Value::Long(vec![32])); + raw.set(t::IMAGE_LENGTH, Value::Long(vec![24])); + raw.set(t::BITS_PER_SAMPLE, Value::Short(vec![16])); + raw.set(t::SAMPLES_PER_PIXEL, Value::Short(vec![1])); + raw.set(t::PHOTOMETRIC, Value::Short(vec![t::photometric::CFA])); + raw.set(t::COMPRESSION, Value::Short(vec![1])); + raw.set(t::WHITE_LEVEL, Value::Long(vec![16383])); + raw.set(0x7310, Value::Short(vec![512; 4])); + raw.set(0x7313, Value::Short(vec![2048, 1024, 1024, 1536])); + // a distortion setting, so the file has a lens record that says whether the SR2SubIFD was read + raw.set(0x7036, Value::Short(vec![0])); + raw.set(t::DNG_PRIVATE_DATA, Value::Byte(vec![0; 4])); + raw.set_image(ImageData::Strips { rows_per_strip: 24, strips: vec![vec![0; 32 * 24 * 2]] }); + let mut file = TiffWriter::new(o, false).write(&[raw]).unwrap(); + let pointer = Tiff::parse(&file).unwrap().ifds[0].get(t::DNG_PRIVATE_DATA).unwrap().offset as usize; + file.resize(size, 0); + let private = file.len(); + let entries = 3 + aliases; + let block_at = private + 2 + 12 * usize::from(entries) + 4; + let block_len = 64u32; + o.put_u16(&mut file, entries); + for (tag, kind, count, value) in [(0x7200u16, 4u16, 1u32, block_at as u32), (0x7201, 4, 1, block_len)] { + o.put_u16(&mut file, tag); + o.put_u16(&mut file, kind); + o.put_u32(&mut file, count); + o.put_u32(&mut file, value); + } + o.put_u16(&mut file, 0x7221); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, 4); + file.extend([0x11, 0x22, 0x33, 0x44]); + for i in 0..aliases { + o.put_u16(&mut file, 0x7230 + i); + o.put_u16(&mut file, 7); + o.put_u32(&mut file, size as u32); + o.put_u32(&mut file, 0); + } + o.put_u32(&mut file, 0); + file.resize(block_at + block_len as usize, 0); + let mut at = Vec::new(); + o.put_u32(&mut at, private as u32); + file[pointer..pointer + 4].copy_from_slice(&at); + file +} + +#[test] +fn sr2_private_fields_cost_no_memory_sized_by_the_file() { + // eight fields of 16 MiB each: 128 MiB if they were decoded, within the TIFF reader's default value budget + let file = hostile_arw(16 << 20, 8); + let _profiler = dhat::Profiler::builder().testing().build(); + let header = lightcraft_raw::probe_info(&file).unwrap(); + let full = lightcraft_raw::decode(&file).unwrap(); + let peak = dhat::HeapStats::get().max_bytes; + // the block was still found and decrypted (its content is noise: no SR2SubIFD copies) + for info in [&header, &full.info()] { + let sony = info.lens_metadata.sony.as_ref().expect("a lens record"); + assert!(sony.sr2_read, "the SR2SubIFD was not read"); + } + eprintln!("peak heap {peak} bytes decoding a {} byte file", file.len()); + assert!(peak < 2 << 20, "peak heap {peak} bytes decoding a {} byte file", file.len()); +} diff --git a/crates/segment/Cargo.toml b/crates/segment/Cargo.toml index 11dff5c77..b4614a81d 100644 --- a/crates/segment/Cargo.toml +++ b/crates/segment/Cargo.toml @@ -23,3 +23,7 @@ lightcraft-fetch = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] candle-core = { workspace = true, features = ["metal"] } candle-nn = { workspace = true, features = ["metal"] } + +[dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } diff --git a/crates/segment/tests/reference.rs b/crates/segment/tests/reference.rs index a73fcaa7c..315d36cd1 100644 --- a/crates/segment/tests/reference.rs +++ b/crates/segment/tests/reference.rs @@ -38,7 +38,7 @@ fn mask_disagreement(a: &[f32], b: &[f32]) -> f32 { #[test] fn matches_reference() { let Some((dir, r)) = setup() else { - eprintln!("skipped: set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); + lightcraft_testkit::skip!(Model, "set LIGHTCRAFT_SAM3_DIR and LIGHTCRAFT_SAM3_REF"); return; }; let mut model = Sam3::load(&dir).unwrap(); diff --git a/crates/slideshow/Cargo.toml b/crates/slideshow/Cargo.toml new file mode 100644 index 000000000..15cda43c1 --- /dev/null +++ b/crates/slideshow/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "lightcraft-slideshow" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft slideshow slides: layout, options and backdrop settings and the CPU slide composer (L1)" + +[lints] +workspace = true + +[dependencies] +lightcraft-raster = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } diff --git a/crates/slideshow/src/compose.rs b/crates/slideshow/src/compose.rs new file mode 100644 index 000000000..61ce91f3f --- /dev/null +++ b/crates/slideshow/src/compose.rs @@ -0,0 +1,541 @@ +//! The slide composer: [`layout`] (where the image cell, the photo, its frame and shadow go) and +//! [`compose`] (the slide's pixels, on the CPU). +//! +//! Drawing order: backdrop colour → background image (at its opacity, filling the slide) → colour +//! wash → cast shadow → stroke border → photo. The stroke is drawn around the outside of the +//! photo (the photo keeps its size); the shadow falls from the photo with its stroke. + +use lightcraft_raster::blur::gaussian; +use lightcraft_raster::resample::{Filter, resize}; +use lightcraft_raster::{Image, Plane, Rgba8, par_rows}; + +use crate::settings::{MAX_SIDE, SlideSettings}; + +/// A rectangle in slide pixels (`x0 ≤ x1`, `y0 ≤ y1`). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Rect { + pub x0: f64, + pub y0: f64, + pub x1: f64, + pub y1: f64, +} + +impl Rect { + pub fn width(&self) -> f64 { + self.x1 - self.x0 + } + pub fn height(&self) -> f64 { + self.y1 - self.y0 + } + fn intersect(&self, o: &Rect) -> Rect { + let (x0, y0) = (self.x0.max(o.x0), self.y0.max(o.y0)); + Rect { x0, y0, x1: self.x1.min(o.x1).max(x0), y1: self.y1.min(o.y1).max(y0) } + } + fn expand(&self, d: f64) -> Rect { + Rect { x0: self.x0 - d, y0: self.y0 - d, x1: self.x1 + d, y1: self.y1 + d } + } + fn shift(&self, dx: f64, dy: f64) -> Rect { + Rect { x0: self.x0 + dx, y0: self.y0 + dy, x1: self.x1 + dx, y1: self.y1 + dy } + } + /// Whole pixel bounds clipped to `w × h`: (x0, y0, x1, y1). + fn pixels(&self, w: usize, h: usize) -> (usize, usize, usize, usize) { + let c = |v: f64, max: usize| if v.is_finite() { v.round().clamp(0.0, max as f64) as usize } else { 0 }; + let (x0, y0) = (c(self.x0, w), c(self.y0, h)); + (x0, y0, c(self.x1, w).max(x0), c(self.y1, h).max(y0)) + } +} + +/// Where a slide's parts go, in slide pixels. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Layout { + /// The slide. + pub slide: Rect, + /// The image cell: the slide inside its margins. + pub cell: Rect, + /// The whole photo, scaled (with zoom to fill it extends past the cell). + pub photo: Rect, + /// The part of the photo that shows (the photo inside the cell). + pub visible: Rect, + /// The visible photo with its stroke border (the visible photo when there is none). + pub frame: Rect, + /// The rectangle that casts the shadow (the frame, offset), and the blur's sigma in pixels. + pub shadow: Option<(Rect, f64)>, +} + +/// A usable aspect: a non-finite, zero or absurd one takes `fallback`. +fn sane_aspect(a: f64, fallback: f64) -> f64 { + if a.is_finite() && a > 1e-3 && a < 1e3 { a } else { fallback } +} + +/// The slide size `out_w × out_h` limited to [`MAX_SIDE`] (proportionally) and at least 1 × 1. +fn slide_size(out_w: usize, out_h: usize) -> (usize, usize) { + let (w, h) = (out_w.max(1), out_h.max(1)); + let long = w.max(h); + if long <= MAX_SIDE { + return (w, h); + } + let k = MAX_SIDE as f64 / long as f64; + (((w as f64 * k).round() as usize).clamp(1, MAX_SIDE), ((h as f64 * k).round() as usize).clamp(1, MAX_SIDE)) +} + +/// The layout of a `out_w × out_h` slide showing a photo of aspect `photo_aspect` (width / +/// height; a bad one fills the cell). +pub fn layout(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> Layout { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let (wf, hf) = (w as f64, h as f64); + let short = wf.min(hf); + // margins in pixels; when they leave no room (a tiny slide), they shrink to leave one pixel + let fit = |a: f64, b: f64, room: f64| { + let (a, b) = ((a * short).round(), (b * short).round()); + if a + b <= room - 1.0 { + (a, b) + } else { + let k = ((room - 1.0).max(0.0)) / (a + b).max(1e-9); + ((a * k).floor(), (b * k).floor()) + } + }; + let (ml, mr) = fit(s.margin_left, s.margin_right, wf); + let (mt, mb) = fit(s.margin_top, s.margin_bottom, hf); + let slide = Rect { x0: 0.0, y0: 0.0, x1: wf, y1: hf }; + let cell = Rect { x0: ml, y0: mt, x1: wf - mr, y1: hf - mb }; + let (cw, ch) = (cell.width().max(1.0), cell.height().max(1.0)); + let a = sane_aspect(photo_aspect, cw / ch); + let wider = a > cw / ch; + // fit: the photo's longer side (relative to the cell) meets the cell; fill: the shorter + let (dw, dh) = if wider != s.zoom_to_fill { (cw, cw / a) } else { (ch * a, ch) }; + let (dw, dh) = (dw.round().max(1.0), dh.round().max(1.0)); + let x0 = (cell.x0 + (cw - dw) / 2.0).round(); + let y0 = (cell.y0 + (ch - dh) / 2.0).round(); + let photo = Rect { x0, y0, x1: x0 + dw, y1: y0 + dh }; + let visible = photo.intersect(&cell); + let stroke = if s.stroke_border && s.stroke_width > 0.0 { (s.stroke_width * short).round().max(1.0) } else { 0.0 }; + let frame = visible.expand(stroke).intersect(&slide); + let shadow = (s.cast_shadow && s.shadow_opacity > 0.0).then(|| { + let (d, t) = (s.shadow_offset * short, s.shadow_angle.to_radians()); + // counter-clockwise from the right, y down on screen + (frame.shift(d * t.cos(), -d * t.sin()), s.shadow_radius * short / 2.0) + }); + Layout { slide, cell, photo, visible, frame, shadow } +} + +/// The size to render a photo of aspect `photo_aspect` at for a `out_w × out_h` slide: the +/// photo's whole size in the slide (with zoom to fill, larger than its cell), at most 4× the +/// slide's long side. +pub fn photo_size(s: &SlideSettings, photo_aspect: f64, out_w: usize, out_h: usize) -> (usize, usize) { + let l = layout(s, photo_aspect, out_w, out_h); + let (w, h) = (l.photo.width().max(1.0), l.photo.height().max(1.0)); + let cap = 4.0 * l.slide.width().max(l.slide.height()); + let k = (cap / w.max(h)).min(1.0); + (((w * k).round() as usize).max(1), ((h * k).round() as usize).max(1)) +} + +fn mix(a: u8, b: u8, t: f32) -> u8 { + (a as f32 + (b as f32 - a as f32) * t).round().clamp(0.0, 255.0) as u8 +} + +fn mix_px(dst: &mut [u8; 4], src: [u8; 3], t: f32) { + for (d, s) in dst.iter_mut().zip(src) { + *d = mix(*d, s, t); + } + dst[3] = 255; +} + +/// Draw `src` scaled to `dest` (slide pixels), only where it meets `clip`, at `opacity`. +/// Only the part of `src` that shows is resampled (a zoomed-to-fill panorama stays cheap). +fn draw_image(out: &mut Rgba8, src: &Rgba8, dest: &Rect, clip: &Rect, opacity: f32) { + if src.width == 0 || src.height == 0 || src.data.len() != src.width * src.height || opacity <= 0.0 { + return; + } + let (w, h) = (out.width, out.height); + let (vx0, vy0, vx1, vy1) = dest.intersect(clip).pixels(w, h); + let (vw, vh) = (vx1 - vx0, vy1 - vy0); + if vw == 0 || vh == 0 || dest.width() <= 0.0 || dest.height() <= 0.0 { + return; + } + // the source pixels behind the visible part + let (sx, sy) = (src.width as f64 / dest.width(), src.height as f64 / dest.height()); + let to_src = |v: f64, origin: f64, k: f64, max: usize| ((v - origin) * k).clamp(0.0, max as f64); + let (s0x, s1x) = (to_src(vx0 as f64, dest.x0, sx, src.width).floor(), to_src(vx1 as f64, dest.x0, sx, src.width).ceil()); + let (s0y, s1y) = (to_src(vy0 as f64, dest.y0, sy, src.height).floor(), to_src(vy1 as f64, dest.y0, sy, src.height).ceil()); + let (cx, cy) = (s0x as usize, s0y as usize); + let (cw, ch) = (((s1x - s0x) as usize).max(1), ((s1y - s0y) as usize).max(1)); + let part = if (cx, cy, cw, ch) == (0, 0, src.width, src.height) { None } else { Some(src.crop(cx, cy, cw, ch)) }; + let part = part.as_ref().unwrap_or(src); + let scaled: Rgba8 = if (part.width, part.height) == (vw, vh) { + part.clone() + } else { + let f = part.map(|p| [p[0] as f32, p[1] as f32, p[2] as f32, p[3] as f32]); + let filter = if vw < part.width { Filter::Lanczos3 } else { Filter::Mitchell }; + resize(&f, vw, vh, filter).map(|p| [0, 1, 2, 3].map(|i| p[i].round().clamp(0.0, 255.0) as u8)) + }; + for (row, y) in (vy0..vy1).enumerate() { + let (Some(dst), Some(s)) = (out.data.get_mut(y * w + vx0..y * w + vx1), scaled.data.get(row * vw..(row + 1) * vw)) else { + continue; + }; + for (d, p) in dst.iter_mut().zip(s) { + let a = opacity * p[3] as f32 / 255.0; + mix_px(d, [p[0], p[1], p[2]], a); + } + } +} + +/// Fill `r` with `c`. +fn fill(out: &mut Rgba8, r: &Rect, c: [u8; 3]) { + let w = out.width; + let (x0, y0, x1, y1) = r.pixels(out.width, out.height); + for y in y0..y1 { + if let Some(row) = out.data.get_mut(y * w + x0..y * w + x1) { + row.fill([c[0], c[1], c[2], 255]); + } + } +} + +/// A 1-D blurred step: 1 inside `[a, b)`, blurred with `sigma` (pixels), over `0..n`. +fn blurred_step(n: usize, a: f64, b: f64, sigma: f64) -> Vec<f32> { + let inside = Plane::from_fn(n, 1, |x, _| { + let c = x as f64 + 0.5; + if c >= a && c < b { 1.0 } else { 0.0 } + }); + gaussian(&inside, sigma as f32).data +} + +/// The slide: `photo` (the developed photo, any size: it is scaled into its cell) and the +/// backdrop image (any size; it fills the slide) composed with `s` at `out_w × out_h` pixels +/// (limited to [`MAX_SIDE`] a side, at least 1 × 1). An empty `photo` draws the backdrop alone. +pub fn compose(photo: &Rgba8, backdrop_image: Option<&Rgba8>, s: &SlideSettings, out_w: usize, out_h: usize) -> Rgba8 { + compose_with(photo, backdrop_image, s, out_w, out_h, &mut |_, _| {}).0 +} + +/// [`compose`], calling `behind(slide, layout)` once the backdrop is drawn and before the shadow, +/// the stroke and the photo (so it draws behind the photo: a slideshow identity plate with +/// "render behind image"), and returning the layout it drew with (for overlays drawn on top). +pub fn compose_with( + photo: &Rgba8, + backdrop_image: Option<&Rgba8>, + s: &SlideSettings, + out_w: usize, + out_h: usize, + behind: &mut dyn FnMut(&mut Rgba8, &Layout), +) -> (Rgba8, Layout) { + let s = s.clone().sanitized(); + let (w, h) = slide_size(out_w, out_h); + let has_photo = photo.width > 0 && photo.height > 0 && photo.data.len() == photo.width * photo.height; + let aspect = if has_photo { photo.width as f64 / photo.height as f64 } else { f64::NAN }; + let l = layout(&s, aspect, w, h); + let c = s.backdrop_color; + let mut out = Image::filled(w, h, [c[0], c[1], c[2], 255]); + // background image: the photo filling the slide, at its opacity + if let Some(bg) = backdrop_image.filter(|_| s.background_image.is_some() && s.background_opacity > 0.0) + && bg.width > 0 + && bg.height > 0 + { + let a = bg.width as f64 / bg.height as f64; + let fill_all = SlideSettings { zoom_to_fill: true, margin_left: 0.0, margin_right: 0.0, margin_top: 0.0, margin_bottom: 0.0, ..s.clone() }; + let bl = layout(&fill_all, a, w, h); + draw_image(&mut out, bg, &bl.photo, &bl.slide, s.background_opacity as f32); + } + // colour wash: full wash colour at the side the angle points to, none at the opposite side + if s.color_wash && s.wash_opacity > 0.0 { + let t = s.wash_angle.to_radians(); + let (dx, dy) = (t.cos(), -t.sin()); + let (cx, cy) = (w as f64 / 2.0, h as f64 / 2.0); + let extent = (cx * dx.abs() + cy * dy.abs()).max(1e-9); + let (wc, op) = (s.wash_color, s.wash_opacity); + par_rows(&mut out.data, w, |y, row| { + let py = y as f64 + 0.5 - cy; + for (x, p) in row.iter_mut().enumerate() { + let proj = (x as f64 + 0.5 - cx) * dx + py * dy; + let k = ((proj / extent + 1.0) / 2.0).clamp(0.0, 1.0); + mix_px(p, wc, (k * op) as f32); + } + }); + } + behind(&mut out, &l); + // cast shadow: a blurred rectangle (separable: the product of two blurred steps) + if let Some((r, sigma)) = l.shadow { + let op = s.shadow_opacity as f32; + let fx = blurred_step(w, r.x0, r.x1, sigma); + let fy = blurred_step(h, r.y0, r.y1, sigma); + par_rows(&mut out.data, w, |y, row| { + let ky = fy.get(y).copied().unwrap_or(0.0); + if ky <= 1e-4 { + return; + } + for (p, kx) in row.iter_mut().zip(&fx) { + let a = op * ky * kx; + if a > 1e-4 { + mix_px(p, [0, 0, 0], a); + } + } + }); + } + if l.frame != l.visible { + fill(&mut out, &l.frame, s.stroke_color); + } + if has_photo { + draw_image(&mut out, photo, &l.photo, &l.cell, 1.0); + } + (out, l) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; + use serde_json::json; + + fn solid(w: usize, h: usize, c: [u8; 3]) -> Rgba8 { + Image::filled(w, h, [c[0], c[1], c[2], 255]) + } + + fn with(p: serde_json::Value) -> SlideSettings { + SlideSettings::default().merged(&p).unwrap() + } + + fn px(img: &Rgba8, x: usize, y: usize) -> [u8; 3] { + let p = img.get(x, y); + [p[0], p[1], p[2]] + } + + const RED: [u8; 3] = [255, 0, 0]; + const GREEN: [u8; 3] = [0, 200, 0]; + + #[test] + fn margins_make_the_cell_and_the_photo_fits_inside_it() { + // 1000 × 500 slide, 10 % margins (of the 500 px short side = 50 px) + let s = with(json!({"margins": 0.1})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 50.0, y0: 50.0, x1: 950.0, y1: 450.0 }); + // a square photo fits the 900 × 400 cell's height, centred + assert_eq!(l.photo, Rect { x0: 300.0, y0: 50.0, x1: 700.0, y1: 450.0 }); + assert_eq!(l.visible, l.photo); + // independent margins + let s = with(json!({"linkMargins": false, "marginLeft": 0.2, "marginTop": 0.0, "marginRight": 0.0, "marginBottom": 0.4})); + let l = layout(&s, 3.0, 1000, 500); + assert_eq!(l.cell, Rect { x0: 100.0, y0: 0.0, x1: 1000.0, y1: 300.0 }); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 300.0), "3:1 fits 900 × 300 exactly"); + + let img = compose(&solid(64, 64, GREEN), None, &with(json!({"margins": 0.1, "backdropColor": "#ff0000"})), 1000, 500); + assert_eq!((img.width, img.height), (1000, 500)); + assert_eq!(px(&img, 10, 10), RED, "margin: backdrop"); + assert_eq!(px(&img, 500, 250), GREEN, "cell centre: photo"); + assert_eq!(px(&img, 290, 250), RED, "beside a fitted square photo: backdrop"); + assert_eq!(px(&img, 310, 60), GREEN); + } + + #[test] + fn zoom_to_fill_covers_the_cell_and_crops() { + let s = with(json!({"margins": 0.1, "zoomToFill": true})); + let l = layout(&s, 1.0, 1000, 500); + assert_eq!((l.photo.width(), l.photo.height()), (900.0, 900.0), "the square photo covers the 900 × 400 cell"); + assert_eq!(l.visible, l.cell, "and shows only inside it"); + assert_eq!(photo_size(&s, 1.0, 1000, 500), (900, 900), "rendered whole at the covering size"); + // a gradient photo: zoomed to fill, its middle rows show, its top and bottom are cut + let photo = Rgba8::from_fn(100, 100, |_, y| [(y * 2) as u8, 0, 0, 255]); + let img = compose(&photo, None, &s, 1000, 500); + assert_eq!(px(&img, 500, 10), [0, 0, 0], "margin stays the backdrop"); + assert_eq!(px(&img, 60, 250), px(&img, 940, 250), "covers the cell edge to edge"); + let top = img.get(500, 51)[0]; + assert!(top > 40, "the photo's top rows are cropped away ({top})"); + // fit draws the whole photo (its first rows near black) + let img = compose(&photo, None, &with(json!({"margins": 0.1})), 1000, 500); + assert!(img.get(500, 51)[0] < 6); + } + + #[test] + fn aspect_preview_ratios() { + use crate::settings::Aspect; + let s = with(json!({"aspect": "4:3"})); + let r = s.aspect.ratio(2.0); + assert!((r - 4.0 / 3.0).abs() < 1e-12); + assert_eq!(with(json!({"aspect": "screen"})).aspect.ratio(2.0), 2.0); + assert_eq!(Aspect::parse("16:9"), Some(Aspect::Wide)); + // a 4:3 slide of a 3:2 photo letterboxes top and bottom + let l = layout(&s, 1.5, 800, 600); + assert_eq!((l.photo.width(), l.photo.height(), l.photo.y0), (800.0, 533.0, 34.0)); + } + + #[test] + fn stroke_surrounds_the_photo_and_shadow_falls_at_its_angle() { + let s = with(json!({"margins": 0.2, "strokeBorder": true, "strokeWidth": 0.02, "strokeColor": "#ffffff"})); + let l = layout(&s, 1.0, 1000, 1000); + assert_eq!(l.visible, Rect { x0: 200.0, y0: 200.0, x1: 800.0, y1: 800.0 }); + assert_eq!(l.frame, Rect { x0: 180.0, y0: 180.0, x1: 820.0, y1: 820.0 }, "20 px outside the photo"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 190, 500), [255, 255, 255], "stroke left"); + assert_eq!(px(&img, 810, 810), [255, 255, 255], "stroke corner"); + assert_eq!(px(&img, 175, 500), [0, 0, 0], "outside the stroke"); + assert_eq!(px(&img, 205, 500), GREEN, "the photo keeps its size"); + + // shadow at −45° (down-right) by 5 %, hard edged (radius 0), opaque + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": -45, "shadowRadius": 0, "shadowOpacity": 1, "backdropColor": "#ffffff"}), + ); + let l = layout(&s, 1.0, 1000, 1000); + let (r, _) = l.shadow.unwrap(); + let d = 50.0 / 2f64.sqrt(); + assert!((r.x0 - (200.0 + d)).abs() < 1e-9 && (r.y0 - (200.0 + d)).abs() < 1e-9, "{r:?}"); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert_eq!(px(&img, 820, 820), [0, 0, 0], "shadow below right"); + assert_eq!(px(&img, 180, 180), [255, 255, 255], "no shadow above left"); + assert_eq!(px(&img, 850, 500), [255, 255, 255], "shadow ends at its offset"); + // at 90° the shadow goes straight up; a soft radius spreads it but fades out + let s = with( + json!({"margins": 0.2, "castShadow": true, "shadowOffset": 0.05, "shadowAngle": 90, "shadowRadius": 0.04, "shadowOpacity": 0.8, "backdropColor": "#ffffff"}), + ); + let img = compose(&solid(10, 10, GREEN), None, &s, 1000, 1000); + assert!(img.get(500, 170)[0] < 200, "above: in shadow"); + assert_eq!(px(&img, 500, 950), [255, 255, 255], "below: none"); + assert_eq!(px(&img, 500, 50), [255, 255, 255], "beyond the blur: none"); + let mid = img.get(500, 151)[0]; + assert!(mid > 60 && mid < 250, "a soft edge ({mid})"); + } + + #[test] + fn colour_wash_follows_its_angle() { + let wash = |angle: f64| { + compose( + &Rgba8::new(0, 0), + None, + &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 1, "washAngle": angle})), + 200, + 100, + ) + }; + // 0°: white at the right, black at the left + let img = wash(0.0); + assert!(img.get(199, 50)[0] > 250 && img.get(0, 50)[0] < 5); + assert!((img.get(100, 50)[0] as i32 - 128).abs() < 3, "half way in the middle"); + assert_eq!(img.get(150, 0), img.get(150, 99), "constant along the other axis"); + // 90°: white at the top + let img = wash(90.0); + assert!(img.get(100, 0)[0] > 245 && img.get(100, 99)[0] < 10); + // 180°: white at the left + assert!(wash(180.0).get(0, 50)[0] > 250); + // −45°: bottom-right is the brightest corner + let img = wash(-45.0); + assert!(img.get(199, 99)[0] > 250 && img.get(0, 0)[0] < 5); + // opacity scales it + let img = + compose(&Rgba8::new(0, 0), None, &with(json!({"colorWash": true, "washColor": "#ffffff", "washOpacity": 0.5, "washAngle": 0})), 200, 100); + assert!((img.get(199, 50)[0] as i32 - 127).abs() < 3); + } + + #[test] + fn background_image_fills_the_slide_at_its_opacity() { + let bg = solid(30, 10, [200, 100, 0]); + let s = with(json!({"backgroundImage": 5, "backgroundOpacity": 0.5, "backdropColor": "#000000", "margins": 0.25})); + let img = compose(&solid(4, 4, GREEN), Some(&bg), &s, 400, 200); + assert_eq!(px(&img, 2, 2), [100, 50, 0], "half the image over black"); + assert_eq!(px(&img, 398, 198), [100, 50, 0], "fills the whole slide"); + assert_eq!(px(&img, 200, 100), GREEN, "the photo on top"); + // off, or no image given: the backdrop colour + let off = SlideSettings { background_image: None, ..s.clone() }; + assert_eq!(px(&compose(&solid(4, 4, GREEN), Some(&bg), &off, 400, 200), 2, 2), [0, 0, 0]); + assert_eq!(px(&compose(&solid(4, 4, GREEN), None, &s, 400, 200), 2, 2), [0, 0, 0]); + } + + #[test] + fn layouts_are_resolution_independent() { + let s = with( + json!({"linkMargins": false, "marginLeft": 0.1, "marginTop": 0.05, "marginRight": 0.2, "marginBottom": 0.08, "strokeBorder": true, "strokeWidth": 0.01, "castShadow": true, "shadowOffset": 0.02}), + ); + let a = layout(&s, 1.5, 800, 450); + let b = layout(&s, 1.5, 1600, 900); + for (ra, rb) in [(a.cell, b.cell), (a.photo, b.photo), (a.frame, b.frame), (a.shadow.unwrap().0, b.shadow.unwrap().0)] { + for (va, vb) in [(ra.x0, rb.x0), (ra.y0, rb.y0), (ra.x1, rb.x1), (ra.y1, rb.y1)] { + assert!((va * 2.0 - vb).abs() <= 2.0, "{ra:?} vs {rb:?}"); + } + } + assert!((a.shadow.unwrap().1 * 2.0 - b.shadow.unwrap().1).abs() < 1e-9); + // and the pixels agree: the big slide, halved, is the small one (away from edges) + let photo = Rgba8::from_fn(60, 40, |x, y| [(x * 4) as u8, (y * 6) as u8, 90, 255]); + let small = compose(&photo, None, &s, 800, 450); + let big = compose(&photo, None, &s, 1600, 900); + for (x, y) in [(10, 10), (400, 225), (300, 100), (700, 400)] { + let (p, q) = (small.get(x, y), big.get(x * 2, y * 2)); + assert!((0..3).all(|i| (p[i] as i32 - q[i] as i32).abs() < 24), "({x}, {y}): {p:?} vs {q:?}"); + } + } + + #[test] + fn the_default_slide_is_the_photo_fitted_on_black() { + let photo = Rgba8::from_fn(300, 200, |x, y| [x as u8, y as u8, 7, 255]); + let s = SlideSettings::default(); + assert!(s.is_plain()); + // a 3:2 photo on a 16:10 screen: the photo at its own pixels, black bars beside it + assert_eq!(photo_size(&s, 1.5, 320, 200), (300, 200)); + let img = compose(&photo, None, &s, 320, 200); + assert_eq!(px(&img, 5, 100), [0, 0, 0]); + assert_eq!(px(&img, 314, 100), [0, 0, 0]); + for (x, y) in [(0, 0), (150, 100), (299, 199)] { + assert_eq!(img.get(x + 10, y), photo.get(x, y), "unchanged pixels at ({x}, {y})"); + } + } + + #[test] + fn behind_draws_under_the_photo_and_the_layout_comes_back() { + let s = with(json!({"margins": 0.2})); + let mut seen = None; + let (img, l) = compose_with(&solid(10, 10, GREEN), None, &s, 200, 100, &mut |out, l| { + seen = Some(*l); + out.data.fill([255, 0, 0, 255]); + }); + assert_eq!(seen, Some(l), "the hook gets the layout the slide is drawn with"); + assert_eq!(px(&img, 2, 2), RED, "the hook's paint stays on the backdrop"); + assert_eq!(px(&img, 100, 50), GREEN, "and the photo covers it"); + } + + #[test] + fn hostile_input_never_panics() { + let wild = SlideSettings { + margin_left: f64::NAN, + margin_right: 1e300, + margin_top: -5.0, + margin_bottom: f64::INFINITY, + stroke_border: true, + stroke_width: 1e9, + cast_shadow: true, + shadow_opacity: f64::NAN, + shadow_offset: -1.0, + shadow_radius: f64::INFINITY, + shadow_angle: f64::NAN, + color_wash: true, + wash_opacity: 9.0, + wash_angle: f64::INFINITY, + background_image: Some(u64::MAX), + background_opacity: f64::NEG_INFINITY, + zoom_to_fill: true, + ..SlideSettings::default() + }; + let photos = [Rgba8::new(0, 0), Rgba8::new(1, 1), Rgba8::new(5000, 1), Rgba8::new(1, 3000), Rgba8 { width: 4, height: 4, data: vec![] }]; + for photo in &photos { + for (w, h) in [(0, 0), (1, 1), (3, 2), (1, 400), (100_000, 2), (64, 48)] { + let img = compose(photo, Some(&Rgba8::new(0, 5)), &wild, w, h); + assert!(img.width >= 1 && img.height >= 1 && img.width <= MAX_SIDE && img.height <= MAX_SIDE); + assert_eq!(img.data.len(), img.width * img.height); + let (pw, ph) = photo_size(&wild, photo.width as f64 / photo.height.max(1) as f64, w, h); + assert!(pw >= 1 && ph >= 1 && pw <= 4 * MAX_SIDE && ph <= 4 * MAX_SIDE); + } + } + for a in [f64::NAN, 0.0, -1.0, f64::INFINITY, 1e-300, 1e300] { + let l = layout(&wild, a, 640, 480); + assert!(l.photo.width() >= 1.0 && l.photo.width().is_finite()); + } + // the clamps: biggest margins still leave a cell, biggest stroke and shadow stay bounded + let s = SlideSettings { + margin_left: MARGIN_MAX, + margin_right: MARGIN_MAX, + stroke_border: true, + stroke_width: STROKE_MAX, + cast_shadow: true, + shadow_offset: SHADOW_OFFSET_MAX, + shadow_radius: SHADOW_RADIUS_MAX, + ..SlideSettings::default() + }; + let l = layout(&s, 1.0, 100, 100); + assert!(l.cell.width() >= 1.0, "{l:?}"); + assert!(l.frame.x0 >= 0.0 && l.frame.x1 <= 100.0, "the stroke stays on the slide"); + } +} diff --git a/crates/slideshow/src/lib.rs b/crates/slideshow/src/lib.rs new file mode 100644 index 000000000..5827491d9 --- /dev/null +++ b/crates/slideshow/src/lib.rs @@ -0,0 +1,23 @@ +//! Slideshow slides for LightCraft: the settings of Lightroom Classic's Slideshow Layout, Options +//! and Backdrop panels ([`SlideSettings`]) and the CPU composer that draws one slide from a +//! rendered photo ([`compose`]). +//! +//! No UI and no catalog here: playback, the settings preview, the `slideshow.preview` command and +//! (later) slideshow export all call [`compose`], so every one of them draws the same pixels. +//! +//! **Resolution independence:** every length is relative to the slide: margins, the stroke width +//! and the shadow's offset and radius are fractions of the slide's *shorter side* (so linked +//! margins are equal in pixels, and a slide composed at two sizes has the same proportions). +//! Angles are degrees counter-clockwise from the right (−45° points down and to the right). +//! +//! **Never crash:** settings are validated by [`SlideSettings::merged`] (a wrong type is an error) +//! and clamped by [`SlideSettings::sanitized`] (NaN, ±∞, negative and huge values land in range); +//! [`compose`] accepts any size and any photo (an empty one draws the backdrop alone). +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +mod compose; +mod settings; + +pub use compose::{Layout, Rect, compose, compose_with, layout, photo_size}; +pub use settings::{Aspect, MARGIN_MAX, MAX_SIDE, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX, SlideSettings, hex, parse_color}; diff --git a/crates/slideshow/src/settings.rs b/crates/slideshow/src/settings.rs new file mode 100644 index 000000000..fd3b327ef --- /dev/null +++ b/crates/slideshow/src/settings.rs @@ -0,0 +1,404 @@ +//! [`SlideSettings`]: Layout (margins, guides, aspect preview), Options (zoom to fill, stroke +//! border, cast shadow) and Backdrop (colour, colour wash, background image). + +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; + +/// Largest margin (fraction of the slide's shorter side). +pub const MARGIN_MAX: f64 = 0.45; +/// Widest stroke border (fraction of the slide's shorter side). +pub const STROKE_MAX: f64 = 0.05; +/// Longest shadow offset (fraction of the slide's shorter side). +pub const SHADOW_OFFSET_MAX: f64 = 0.1; +/// Largest shadow radius (fraction of the slide's shorter side). +pub const SHADOW_RADIUS_MAX: f64 = 0.1; +/// Largest slide side [`crate::compose`] draws (bigger requests are scaled down to it). +pub const MAX_SIDE: usize = 8192; + +/// The aspect the slide is previewed at (Lightroom Classic's Aspect Preview). Playback always +/// fills the screen; the settings preview and `slideshow.preview` use this. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum Aspect { + /// The screen's own aspect. + #[default] + #[serde(rename = "screen")] + Screen, + #[serde(rename = "16:9")] + Wide, + #[serde(rename = "4:3")] + Standard, +} + +impl Aspect { + pub const ALL: [Aspect; 3] = [Aspect::Screen, Aspect::Wide, Aspect::Standard]; + + pub fn id(self) -> &'static str { + match self { + Aspect::Screen => "screen", + Aspect::Wide => "16:9", + Aspect::Standard => "4:3", + } + } + + pub fn parse(s: &str) -> Option<Aspect> { + Aspect::ALL.into_iter().find(|a| a.id().eq_ignore_ascii_case(s.trim())) + } + + /// Width / height, given the screen's (used for [`Aspect::Screen`]; a bad one gives 16:10). + pub fn ratio(self, screen: f64) -> f64 { + match self { + Aspect::Screen if screen.is_finite() && screen > 0.05 && screen < 20.0 => screen, + Aspect::Screen => 1.6, + Aspect::Wide => 16.0 / 9.0, + Aspect::Standard => 4.0 / 3.0, + } + } +} + +/// How a slide looks (everything but the photo). Lengths are fractions of the slide's shorter +/// side, angles degrees counter-clockwise from the right, opacities 0–1. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct SlideSettings { + // Layout + pub margin_left: f64, + pub margin_right: f64, + pub margin_top: f64, + pub margin_bottom: f64, + /// Changing one margin changes all four. + pub link_margins: bool, + /// Show the margin guides in the slide editor (never in playback or export). + pub show_guides: bool, + pub aspect: Aspect, + // Options + /// Crop the photo to fill its cell (off: the whole photo fits inside it). + pub zoom_to_fill: bool, + pub stroke_border: bool, + pub stroke_color: [u8; 3], + pub stroke_width: f64, + pub cast_shadow: bool, + pub shadow_opacity: f64, + pub shadow_offset: f64, + pub shadow_radius: f64, + pub shadow_angle: f64, + // Backdrop + pub backdrop_color: [u8; 3], + /// A gradient from `wash_color` (full at the side `wash_angle` points to) to the backdrop. + pub color_wash: bool, + pub wash_color: [u8; 3], + pub wash_opacity: f64, + pub wash_angle: f64, + /// A library photo (its id) drawn over the backdrop colour, filling the slide. + pub background_image: Option<u64>, + pub background_opacity: f64, +} + +impl Default for SlideSettings { + fn default() -> Self { + SlideSettings { + margin_left: 0.0, + margin_right: 0.0, + margin_top: 0.0, + margin_bottom: 0.0, + link_margins: true, + show_guides: true, + aspect: Aspect::Screen, + zoom_to_fill: false, + stroke_border: false, + stroke_color: [255, 255, 255], + stroke_width: 0.004, + cast_shadow: false, + shadow_opacity: 0.4, + shadow_offset: 0.012, + shadow_radius: 0.02, + shadow_angle: -45.0, + backdrop_color: [0, 0, 0], + color_wash: false, + wash_color: [96, 96, 96], + wash_opacity: 0.6, + wash_angle: 90.0, + background_image: None, + background_opacity: 0.5, + } + } +} + +/// The keys [`SlideSettings::merged`] takes (for error messages and docs). +const KEYS: &str = "margins, marginLeft, marginRight, marginTop, marginBottom, linkMargins, showGuides, aspect, zoomToFill, strokeBorder, strokeColor, strokeWidth, castShadow, shadowOpacity, shadowOffset, shadowRadius, shadowAngle, backdropColor, colorWash, washColor, washOpacity, washAngle, backgroundImage, backgroundOpacity"; + +fn clamp_or(v: f64, lo: f64, hi: f64, fallback: f64) -> f64 { + if v.is_finite() { v.clamp(lo, hi) } else { fallback } +} + +/// An angle in degrees into (−180, 180]; non-finite → `fallback`. +fn wrap_angle(a: f64, fallback: f64) -> f64 { + if !a.is_finite() { + return fallback; + } + let w = (a + 180.0).rem_euclid(360.0) - 180.0; + if w <= -180.0 { 180.0 } else { w } +} + +impl SlideSettings { + /// The settings with every value in range: lengths and opacities clamped, angles wrapped, + /// non-finite numbers replaced by the defaults. + pub fn sanitized(mut self) -> Self { + let d = SlideSettings::default(); + for m in [&mut self.margin_left, &mut self.margin_right, &mut self.margin_top, &mut self.margin_bottom] { + *m = clamp_or(*m, 0.0, MARGIN_MAX, 0.0); + } + self.stroke_width = clamp_or(self.stroke_width, 0.0, STROKE_MAX, d.stroke_width); + self.shadow_opacity = clamp_or(self.shadow_opacity, 0.0, 1.0, d.shadow_opacity); + self.shadow_offset = clamp_or(self.shadow_offset, 0.0, SHADOW_OFFSET_MAX, d.shadow_offset); + self.shadow_radius = clamp_or(self.shadow_radius, 0.0, SHADOW_RADIUS_MAX, d.shadow_radius); + self.shadow_angle = wrap_angle(self.shadow_angle, d.shadow_angle); + self.wash_opacity = clamp_or(self.wash_opacity, 0.0, 1.0, d.wash_opacity); + self.wash_angle = wrap_angle(self.wash_angle, d.wash_angle); + self.background_opacity = clamp_or(self.background_opacity, 0.0, 1.0, d.background_opacity); + self + } + + /// Does a slide with these settings show nothing but the photo fitted on black (the plain + /// slideshow)? Settings that are switched on but draw nothing (a 0-wide stroke, a shadow at + /// 0 % opacity) count as plain. + pub fn is_plain(&self) -> bool { + let s = self.clone().sanitized(); + s.margin_left == 0.0 + && s.margin_right == 0.0 + && s.margin_top == 0.0 + && s.margin_bottom == 0.0 + && !s.zoom_to_fill + && !(s.stroke_border && s.stroke_width > 0.0) + && !(s.cast_shadow && s.shadow_opacity > 0.0) + && s.backdrop_color == [0, 0, 0] + && !(s.color_wash && s.wash_opacity > 0.0) + && !(s.background_image.is_some() && s.background_opacity > 0.0) + } + + /// A hash of what changes the slide's pixels (guides and the aspect preview don't), for + /// render keys. Stable within a run. + pub fn pixel_key(&self) -> u64 { + use std::hash::{Hash, Hasher}; + let mut s = self.clone().sanitized(); + s.show_guides = false; + s.aspect = Aspect::Screen; + s.link_margins = false; + let mut h = std::collections::hash_map::DefaultHasher::new(); + s.to_json().to_string().hash(&mut h); + h.finish() + } + + pub fn to_json(&self) -> Value { + json!({ + "marginLeft": self.margin_left, + "marginRight": self.margin_right, + "marginTop": self.margin_top, + "marginBottom": self.margin_bottom, + "linkMargins": self.link_margins, + "showGuides": self.show_guides, + "aspect": self.aspect.id(), + "zoomToFill": self.zoom_to_fill, + "strokeBorder": self.stroke_border, + "strokeColor": hex(self.stroke_color), + "strokeWidth": self.stroke_width, + "castShadow": self.cast_shadow, + "shadowOpacity": self.shadow_opacity, + "shadowOffset": self.shadow_offset, + "shadowRadius": self.shadow_radius, + "shadowAngle": self.shadow_angle, + "backdropColor": hex(self.backdrop_color), + "colorWash": self.color_wash, + "washColor": hex(self.wash_color), + "washOpacity": self.wash_opacity, + "washAngle": self.wash_angle, + "backgroundImage": self.background_image, + "backgroundOpacity": self.background_opacity, + }) + } + + /// These settings with the keys `p` names changed (the others kept), validated and clamped. + /// A wrong type, an unknown key or a non-finite number is an error, and nothing changes. + /// With `linkMargins` on (already, or set in `p`), a margin given sets all four; `margins` + /// sets all four in any case. + pub fn merged(&self, p: &Value) -> Result<Self, String> { + let Some(obj) = p.as_object() else { return Err("parameters must be an object".into()) }; + let mut s = self.clone(); + let mut margin: Option<f64> = None; + for (k, v) in obj { + match k.as_str() { + "margins" => { + let m = number(k, v)?; + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + "marginLeft" => s.margin_left = *margin.insert(number(k, v)?), + "marginRight" => s.margin_right = *margin.insert(number(k, v)?), + "marginTop" => s.margin_top = *margin.insert(number(k, v)?), + "marginBottom" => s.margin_bottom = *margin.insert(number(k, v)?), + "linkMargins" => s.link_margins = boolean(k, v)?, + "showGuides" => s.show_guides = boolean(k, v)?, + "aspect" => { + s.aspect = + v.as_str().and_then(Aspect::parse).ok_or_else(|| format!("`aspect` must be \"screen\", \"16:9\" or \"4:3\", not {v}"))? + } + "zoomToFill" => s.zoom_to_fill = boolean(k, v)?, + "strokeBorder" => s.stroke_border = boolean(k, v)?, + "strokeColor" => s.stroke_color = color(k, v)?, + "strokeWidth" => s.stroke_width = number(k, v)?, + "castShadow" => s.cast_shadow = boolean(k, v)?, + "shadowOpacity" => s.shadow_opacity = number(k, v)?, + "shadowOffset" => s.shadow_offset = number(k, v)?, + "shadowRadius" => s.shadow_radius = number(k, v)?, + "shadowAngle" => s.shadow_angle = number(k, v)?, + "backdropColor" => s.backdrop_color = color(k, v)?, + "colorWash" => s.color_wash = boolean(k, v)?, + "washColor" => s.wash_color = color(k, v)?, + "washOpacity" => s.wash_opacity = number(k, v)?, + "washAngle" => s.wash_angle = number(k, v)?, + "backgroundImage" => { + s.background_image = match v { + Value::Null => None, + _ => Some(v.as_u64().ok_or_else(|| format!("`backgroundImage` must be a photo id or null, not {v}"))?), + } + } + "backgroundOpacity" => s.background_opacity = number(k, v)?, + other => return Err(format!("unknown setting `{other}` ({KEYS})")), + } + } + if s.link_margins + && let Some(m) = margin + { + (s.margin_left, s.margin_right, s.margin_top, s.margin_bottom) = (m, m, m, m); + } + Ok(s.sanitized()) + } +} + +fn number(k: &str, v: &Value) -> Result<f64, String> { + match v.as_f64() { + Some(x) if x.is_finite() => Ok(x), + _ => Err(format!("`{k}` must be a finite number, not {v}")), + } +} + +fn boolean(k: &str, v: &Value) -> Result<bool, String> { + v.as_bool().ok_or_else(|| format!("`{k}` must be true or false, not {v}")) +} + +fn color(k: &str, v: &Value) -> Result<[u8; 3], String> { + parse_color(v).ok_or_else(|| format!("`{k}` must be \"#rrggbb\" or [r, g, b] (0–255), not {v}")) +} + +/// `"#rrggbb"` (the `#` optional) or `[r, g, b]` with 0–255 components. +pub fn parse_color(v: &Value) -> Option<[u8; 3]> { + match v { + Value::String(s) => { + let h = s.trim().strip_prefix('#').unwrap_or(s.trim()); + if h.len() != 6 || !h.bytes().all(|b| b.is_ascii_hexdigit()) { + return None; + } + let byte = |i: usize| h.get(i..i + 2).and_then(|x| u8::from_str_radix(x, 16).ok()); + Some([byte(0)?, byte(2)?, byte(4)?]) + } + Value::Array(a) if a.len() == 3 => { + let mut c = [0u8; 3]; + for (o, x) in c.iter_mut().zip(a) { + *o = u8::try_from(x.as_u64()?).ok()?; + } + Some(c) + } + _ => None, + } +} + +/// `#rrggbb`. +pub fn hex(c: [u8; 3]) -> String { + format!("#{:02x}{:02x}{:02x}", c[0], c[1], c[2]) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn merge_validates_links_margins_and_round_trips() { + let d = SlideSettings::default(); + assert!(d.is_plain(), "the default slide is the plain slideshow"); + let s = d.merged(&json!({"marginLeft": 0.1})).unwrap(); + assert_eq!((s.margin_left, s.margin_right, s.margin_top, s.margin_bottom), (0.1, 0.1, 0.1, 0.1), "linked: all four"); + let s = d.merged(&json!({"linkMargins": false, "marginTop": 0.2})).unwrap(); + assert_eq!((s.margin_left, s.margin_top), (0.0, 0.2), "unlinked: one"); + let s = s.merged(&json!({"margins": 0.05})).unwrap(); + assert_eq!((s.margin_left, s.margin_bottom), (0.05, 0.05)); + let s = d + .merged(&json!({"strokeBorder": true, "strokeColor": "#FF8000", "washColor": [1, 2, 3], "aspect": "16:9", "backgroundImage": 7})) + .unwrap(); + assert_eq!(s.stroke_color, [255, 128, 0]); + assert_eq!(s.wash_color, [1, 2, 3]); + assert_eq!(s.aspect, Aspect::Wide); + assert_eq!(s.background_image, Some(7)); + assert!(!s.is_plain()); + // the JSON form reads back as the same settings + assert_eq!(d.merged(&s.to_json()).unwrap(), s); + // serde (prefs.json) round trip, and missing keys take the defaults + let back: SlideSettings = serde_json::from_str(&serde_json::to_string(&s).unwrap()).unwrap(); + assert_eq!(back, s); + let partial: SlideSettings = serde_json::from_str(r#"{"marginTop": 0.3}"#).unwrap(); + assert_eq!(partial, SlideSettings { margin_top: 0.3, ..d.clone() }); + assert_eq!(d.merged(&json!({"backgroundImage": null})).unwrap().background_image, None); + } + + #[test] + fn hostile_settings_are_rejected_or_clamped() { + let d = SlideSettings::default(); + for bad in [ + json!({"marginLeft": "wide"}), + json!({"margins": null}), + json!({"zoomToFill": 1}), + json!({"strokeColor": "#12345"}), + json!({"strokeColor": "#ééééé"}), + json!({"strokeColor": "#+f+f+f"}), + json!({"backdropColor": "+12345"}), + json!({"washColor": "#-00000"}), + json!({"washColor": [256, 0, 0]}), + json!({"washColor": [-1, 0, 0]}), + json!({"backdropColor": [1, 2]}), + json!({"aspect": "21:9"}), + json!({"backgroundImage": -3}), + json!({"backgroundImage": "photo"}), + json!({"sparkle": true}), + json!([1]), + ] { + assert!(d.merged(&bad).is_err(), "{bad}"); + } + let s = d + .merged(&json!({"margins": 1e300, "strokeWidth": -2, "shadowOpacity": 7, "shadowOffset": 1e9, "shadowRadius": -1, "shadowAngle": 540, "washAngle": -190, "washOpacity": -0.5})) + .unwrap(); + assert_eq!((s.margin_left, s.margin_top), (MARGIN_MAX, MARGIN_MAX)); + assert_eq!((s.stroke_width, s.shadow_opacity, s.shadow_offset, s.shadow_radius), (0.0, 1.0, SHADOW_OFFSET_MAX, 0.0)); + assert_eq!((s.shadow_angle, s.wash_angle, s.wash_opacity), (180.0, 170.0, 0.0)); + // NaN / ±∞ (a hand-edited prefs file, an in-process caller) take the defaults + let s = SlideSettings { + margin_left: f64::NAN, + stroke_width: f64::INFINITY, + shadow_angle: f64::NEG_INFINITY, + background_opacity: f64::NAN, + ..d.clone() + } + .sanitized(); + assert_eq!( + (s.margin_left, s.stroke_width, s.shadow_angle, s.background_opacity), + (0.0, d.stroke_width, d.shadow_angle, d.background_opacity) + ); + assert_eq!(Aspect::Screen.ratio(f64::NAN), 1.6); + assert_eq!(Aspect::Screen.ratio(1.5), 1.5); + assert!((Aspect::Wide.ratio(1.0) - 16.0 / 9.0).abs() < 1e-12); + } + + #[test] + fn pixel_key_ignores_what_does_not_change_pixels() { + let d = SlideSettings::default(); + let guides = SlideSettings { show_guides: false, aspect: Aspect::Standard, ..d.clone() }; + assert_eq!(d.pixel_key(), guides.pixel_key()); + assert_ne!(d.pixel_key(), SlideSettings { backdrop_color: [9, 9, 9], ..d.clone() }.pixel_key()); + } +} diff --git a/crates/testkit/Cargo.toml b/crates/testkit/Cargo.toml new file mode 100644 index 000000000..cefa1ef5c --- /dev/null +++ b/crates/testkit/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "lightcraft-testkit" +version.workspace = true +edition.workspace = true +license.workspace = true +description = "LightCraft test tooling (dev-dependency only): a test that can't run here records why, so `cargo xtask ci` reports it" +publish = false + +[lints] +workspace = true + +[dependencies] diff --git a/crates/testkit/src/lib.rs b/crates/testkit/src/lib.rs new file mode 100644 index 000000000..7a2322245 --- /dev/null +++ b/crates/testkit/src/lib.rs @@ -0,0 +1,190 @@ +//! Test tooling shared by the workspace's tests. A dev-dependency only: `cargo xtask layers` +//! refuses it as a normal dependency. Std only, so every crate can use it at no build cost. +//! +//! **Skips.** libtest has no "skipped" status and hides the output of passing tests, so a test that +//! needs a GPU, a corpus file, the craft-fonts faces or a model, and returns early without them, +//! passes silently: "all steps passed" could mean the GPU tests never ran. Such a test calls +//! [`skip!`] before it returns: +//! +//! ```no_run +//! # let gpu_available = false; +//! if !gpu_available { +//! lightcraft_testkit::skip!(Gpu, "no GPU adapter"); +//! return; +//! } +//! ``` +//! +//! It prints the note to stderr as before and, when the [`SKIP_LOG_ENV`] variable names a file +//! (`cargo xtask ci` sets it), appends one JSON line there: +//! `{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}`. +//! `cargo xtask ci` lists the skips by need and reason in its summary and in +//! `target/ci/report.json`. A test that runs part of its checks (e.g. only the CPU half) records +//! the part it could not run the same way. +#![forbid(unsafe_code)] +#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] + +use std::fmt::Write as _; +use std::io::Write as _; +use std::path::Path; + +/// The environment variable naming the file skips are appended to (JSON lines). +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// What a skipped test needed and didn't find. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Need { + /// A usable GPU adapter (`lightcraft_gpu::available()`). + Gpu, + /// A file from the gitignored `corpus/` (`cargo xtask corpus --download`) or a private corpus. + Corpus, + /// Faces from craft-fonts, embedded only in builds with `CRAFT_FONTS_DIR`. + Fonts, + /// A model file named by an environment variable (SAM 3, YuNet, …). + Model, + /// An input file named by an environment variable (opt-in tests). + Input, + /// Something the operating system provides (system ICC profiles, …). + System, + /// Support this build doesn't have yet (a decoder that refuses a file variant, an optional + /// cargo feature that is off). + Feature, +} + +impl Need { + /// The name in the skip log and the CI summary. + pub fn as_str(self) -> &'static str { + match self { + Need::Gpu => "gpu", + Need::Corpus => "corpus", + Need::Fonts => "fonts", + Need::Model => "model", + Need::Input => "input", + Need::System => "system", + Need::Feature => "feature", + } + } +} + +/// Record that this test (or part of it) can't run here: `skip!(Gpu, "no GPU adapter ({why})")`. +/// The first argument names a [`Need`] variant; the rest is a `format!` message. Doesn't return +/// from the test: follow it with `return` (or `continue`) as the test needs. +#[macro_export] +macro_rules! skip { + ($need:ident, $($arg:tt)+) => { + $crate::record($crate::Need::$need, &::std::format!($($arg)+), ::std::file!(), ::std::line!()) + }; +} + +/// What [`skip!`] calls: print the note and append it to the skip log, if one is set. Never fails +/// the test: a log that can't be written loses the entry, not the run. +pub fn record(need: Need, reason: &str, file: &str, line: u32) { + eprintln!("skipped ({}): {reason}", need.as_str()); + let log = std::env::var_os(SKIP_LOG_ENV).filter(|p| !p.is_empty()); + if let Some(log) = log { + let thread = std::thread::current(); + let binary = std::env::current_exe().ok().and_then(|p| p.file_stem().map(|s| binary_name(&s.to_string_lossy()).to_owned())); + let entry = entry(need, reason, thread.name().unwrap_or(""), binary.as_deref().unwrap_or(""), file, line); + append(Path::new(&log), &entry); + } +} + +/// Append one line in a single write, so lines from parallel tests don't interleave. +fn append(path: &Path, line: &str) { + let file = std::fs::OpenOptions::new().create(true).append(true).open(path); + if let Err(e) = file.and_then(|mut f| f.write_all(line.as_bytes())) { + eprintln!("{SKIP_LOG_ENV}: can't append to {}: {e}", path.display()); + } +} + +/// A test binary's name without the hash cargo appends (`equivalence-0123abcd` → `equivalence`). +fn binary_name(stem: &str) -> &str { + match stem.rsplit_once('-') { + Some((name, hash)) if !name.is_empty() && hash.len() == 16 && hash.bytes().all(|b| b.is_ascii_hexdigit()) => name, + _ => stem, + } +} + +/// One skip-log line (JSON, newline-terminated). +fn entry(need: Need, reason: &str, test: &str, binary: &str, file: &str, line: u32) -> String { + let mut s = String::from("{"); + for (i, (key, value)) in [("need", need.as_str()), ("reason", reason), ("test", test), ("binary", binary)].into_iter().enumerate() { + if i > 0 { + s.push(','); + } + let _ = write!(s, "\"{key}\":\"{}\"", escape(value)); + } + let _ = writeln!(s, ",\"location\":\"{}:{line}\"}}", escape(&file.replace('\\', "/"))); + s +} + +/// A JSON string's contents. +fn escape(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + '\n' => out.push_str("\\n"), + '\r' => out.push_str("\\r"), + '\t' => out.push_str("\\t"), + c if u32::from(c) < 0x20 => { + let _ = write!(out, "\\u{:04x}", u32::from(c)); + } + c => out.push(c), + } + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn an_entry_is_one_json_line_with_its_text_escaped() { + let line = entry(Need::Corpus, "skip: C:\\corpus\\raw \"absent\"\n\u{1}", "corpus::decodes", "corpus", "crates\\raw\\tests\\corpus.rs", 28); + assert!(line.ends_with('\n')); + assert_eq!(line.matches('\n').count(), 1, "{line}"); + assert_eq!( + line, + "{\"need\":\"corpus\",\"reason\":\"skip: C:\\\\corpus\\\\raw \\\"absent\\\"\\n\\u0001\",\"test\":\"corpus::decodes\",\ + \"binary\":\"corpus\",\"location\":\"crates/raw/tests/corpus.rs:28\"}\n" + ); + } + + #[test] + fn binary_names_lose_cargos_hash_only() { + assert_eq!(binary_name("equivalence-0f3a9c2b1d4e5f60"), "equivalence"); + assert_eq!(binary_name("lightcraft_engine-0f3a9c2b1d4e5f60"), "lightcraft_engine"); + assert_eq!(binary_name("sparse_corpus"), "sparse_corpus"); + assert_eq!(binary_name("cr3-corpus"), "cr3-corpus"); + assert_eq!(binary_name("-0f3a9c2b1d4e5f60"), "-0f3a9c2b1d4e5f60"); + assert_eq!(binary_name("tool-cafe"), "tool-cafe"); + } + + #[test] + fn appended_lines_accumulate() { + let dir = std::env::temp_dir().join(format!("lightcraft-testkit-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let log = dir.join("skips.jsonl"); + let _ = std::fs::remove_file(&log); + let threads: Vec<_> = (0..8) + .map(|i| { + let log = log.clone(); + std::thread::spawn(move || append(&log, &entry(Need::Gpu, &format!("no GPU {i}"), "t", "b", "f.rs", i))) + }) + .collect(); + for t in threads { + t.join().unwrap(); + } + let text = std::fs::read_to_string(&log).unwrap(); + assert_eq!(text.lines().count(), 8); + assert!(text.lines().all(|l| l.starts_with("{\"need\":\"gpu\"") && l.ends_with('}'))); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn a_missing_log_directory_does_not_fail_the_test() { + append(Path::new("/nonexistent-lightcraft-dir/skips.jsonl"), "{}\n"); + } +} diff --git a/crates/tiff/README.md b/crates/tiff/README.md index c691dd641..e6a771288 100644 --- a/crates/tiff/README.md +++ b/crates/tiff/README.md @@ -18,6 +18,10 @@ later the TIFF/DNG exporters. - `image::ImageInfo` — width/height, bits, samples, compression, photometric, planar config, predictor, sample format and the strip or tile chunk list (`chunks()`, `chunk_bytes()`); missing byte counts are inferred. +- `directory_entries`, `entry_span`, `ParseOptions::budget_for` and `decode_value` are the reader's own rules + (directory layout and entry limit, which entries it reads, its value budget, value decoding), for callers that + must select entries exactly as `parse_ifd_at` would without decoding the whole directory (`lightcraft-raw`'s + Sony `SR2Private` lookup). - `TiffWriter` writes IFD chains with strips or tiles, `SubIFDs` and pointer IFDs, in either byte order, classic or BigTIFF; `writer::rational` / `srational` approximate floats as rationals. @@ -25,7 +29,9 @@ later the TIFF/DNG exporters. The reader never panics on malformed input (proptests feed random and mutated files): offsets are bounds-checked; out-of-range or unknown-type entries are skipped; IFD loops are detected; IFD count, -nesting depth and entries per IFD are limited; the total bytes of decoded values are capped at +nesting depth and entries per IFD are limited; a next-IFD pointer is followed only when every declared entry was +read, with the counts compared as 64-bit numbers (a BigTIFF declaring 4,294,967,297 entries but holding one can't +pass for a whole directory on a 32-bit target); the total bytes of decoded values are capped at `8 × input + 1 MiB` so a file cannot make us allocate far more than its own size. ## Sources diff --git a/crates/tiff/src/image.rs b/crates/tiff/src/image.rs index 2188ab502..ca3ff8b69 100644 --- a/crates/tiff/src/image.rs +++ b/crates/tiff/src/image.rs @@ -127,6 +127,13 @@ impl ImageInfo { let (cw, ch) = self.chunk_size(); let n = self.offsets.len().min(per_plane.saturating_mul(planes)); let mut out = Vec::with_capacity(n); + // the distinct offsets in order, for chunks without a byte count (sorted once, not scanned per chunk) + let mut sorted: Vec<u64> = Vec::new(); + if (0..n).any(|i| self.byte_counts.get(i).is_none_or(|&l| l == 0)) { + sorted = self.offsets.clone(); + sorted.sort_unstable(); + sorted.dedup(); + } for i in 0..n { let plane = i / per_plane.max(1); let j = i % per_plane.max(1); @@ -142,7 +149,7 @@ impl ImageInfo { Some(&l) if l > 0 => l, _ => { // infer: up to the next larger offset, else end of data - let next = self.offsets.iter().copied().filter(|&o| o > offset).min().unwrap_or(data_len); + let next = sorted.get(sorted.partition_point(|&o| o <= offset)).copied().unwrap_or(data_len); next.saturating_sub(offset) } }; @@ -162,3 +169,48 @@ pub fn chunk_bytes<'a>(data: &'a [u8], c: &Chunk) -> Option<&'a [u8]> { let end = (c.offset.saturating_add(c.len)).min(data.len() as u64) as usize; Some(&data[start..end]) } + +#[cfg(test)] +mod tests { + use super::*; + + fn strips(offsets: Vec<u64>, byte_counts: Vec<u64>) -> ImageInfo { + ImageInfo { + width: 1, + height: offsets.len() as u32, + bits_per_sample: vec![8], + samples_per_pixel: 1, + compression: 1, + photometric: 1, + planar: 1, + predictor: 1, + sample_format: 1, + new_subfile_type: 0, + layout: Layout::Strips { rows_per_strip: 1 }, + offsets, + byte_counts, + } + } + + #[test] + fn missing_byte_counts_run_to_the_next_larger_offset() { + let info = strips(vec![50, 10, 30, 10, 70], vec![0, 0, 5]); + let lens: Vec<u64> = info.chunks(100).iter().map(|c| c.len).collect(); + assert_eq!(lens, vec![20, 20, 5, 20, 30]); + } + + /// A million strips without byte counts (offsets in descending order) are measured in one sort, not one + /// scan of all offsets per strip. + #[test] + fn many_strips_without_byte_counts_are_fast() { + let n = 1_000_000u64; + let info = strips((0..n).rev().map(|i| 1000 + i * 2).collect(), Vec::new()); + let (tx, rx) = std::sync::mpsc::channel(); + std::thread::spawn(move || { + let chunks = info.chunks(1000 + n * 2); + let _ = tx.send(chunks.iter().map(|c| c.len).collect::<Vec<u64>>()); + }); + let lens = rx.recv_timeout(std::time::Duration::from_secs(20)).expect("chunks() took longer than 20 s"); + assert!(lens.iter().all(|&l| l == 2)); + } +} diff --git a/crates/tiff/src/lib.rs b/crates/tiff/src/lib.rs index bafa3eb5e..1d0b74d0d 100644 --- a/crates/tiff/src/lib.rs +++ b/crates/tiff/src/lib.rs @@ -25,7 +25,7 @@ mod reader; pub mod tags; pub mod writer; -pub use reader::{ParseOptions, parse_ifd_at}; +pub use reader::{DirectoryEntries, EntrySpan, ParseOptions, decode as decode_value, directory_entries, entry_span, parse_ifd_at}; pub use writer::{IfdBuilder, ImageData, TiffWriter}; use serde::{Deserialize, Serialize}; diff --git a/crates/tiff/src/reader.rs b/crates/tiff/src/reader.rs index a644159b7..5f38dfad2 100644 --- a/crates/tiff/src/reader.rs +++ b/crates/tiff/src/reader.rs @@ -26,6 +26,108 @@ impl Default for ParseOptions { } } +impl ParseOptions { + /// The value budget for `data_len` bytes of input: [`Self::value_budget`], else `8 × data_len + 1 MiB`. + pub fn budget_for(&self, data_len: usize) -> u64 { + self.value_budget.unwrap_or((data_len as u64).saturating_mul(8).saturating_add(1 << 20)) + } +} + +/// Where a directory's entries are and how many of them the reader reads, as [`directory_entries`] found them. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct DirectoryEntries { + /// Position of the first entry. + table: usize, + /// Entries read: the declared count, cut to the entries the data holds and to the `max_entries` limit. + read: usize, + /// The declared entry count. + declared: u64, + /// Bytes per entry: 12, or 20 in BigTIFF. + entry_size: usize, +} + +impl DirectoryEntries { + /// Positions of the entries read, in directory order. Checked arithmetic: the iteration ends rather than + /// overflow, whatever the layout. + pub fn positions(&self) -> impl Iterator<Item = usize> + use<> { + let (table, size) = (self.table, self.entry_size); + (0..self.read).map_while(move |i| i.checked_mul(size)?.checked_add(table)) + } + + /// The number of entries read. + pub fn read(&self) -> usize { + self.read + } + + /// The entry count the directory declares. + pub fn declared(&self) -> u64 { + self.declared + } +} + +/// The directory at `offset` as the reader walks it: its entry table and how many entries it reads. The same errors +/// as [`parse_ifd_at`] for a directory it can't read: past the data, empty, or without room for one entry. +pub fn directory_entries(data: &[u8], offset: u64, order: ByteOrder, bigtiff: bool, max_entries: usize) -> Result<DirectoryEntries> { + let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; + let (n, head, entry_size) = if bigtiff { + (order.read_u64(data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize) + } else { + (order.read_u16(data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12) + }; + let table = pos + head; + let fit = (data.len().saturating_sub(table) / entry_size) as u64; + let read = n.min(fit).min(max_entries as u64) as usize; + if n == 0 { + return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); + } + if read == 0 { + return Err(TiffError::Truncated(offset)); + } + Ok(DirectoryEntries { table, read, declared: n, entry_size }) +} + +/// A directory entry the reader can read: its tag, type and count, and where its values are. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct EntrySpan { + pub tag: u16, + pub field_type: FieldType, + pub count: u64, + /// Position of the values in the data: the entry's own value field when they fit it, else `base` plus the offset + /// stored there. + pub start: u64, + /// `count × size` bytes. + pub len: u64, +} + +/// The entry at `pos`, when the reader reads it: a known field type, `count × size` without overflow, and values +/// inside `data`. Nothing is decoded; [`parse_ifd_at`] also drops an entry whose values exceed what is left of its +/// value budget ([`ParseOptions::budget_for`]), which this leaves to the caller. +pub fn entry_span(data: &[u8], pos: usize, order: ByteOrder, bigtiff: bool, base: u64) -> Option<EntrySpan> { + let tag = order.read_u16(data, pos)?; + let field_type = FieldType::from_u16(order.read_u16(data, pos.checked_add(2)?)?)?; + let (count, field, inline) = if bigtiff { + (order.read_u64(data, pos.checked_add(4)?)?, pos.checked_add(12)?, 8u64) + } else { + (order.read_u32(data, pos.checked_add(4)?)? as u64, pos.checked_add(8)?, 4) + }; + let len = count.checked_mul(field_type.size() as u64)?; + let start = if len <= inline { + field as u64 + } else { + let rel = if bigtiff { order.read_u64(data, field)? } else { order.read_u32(data, field)? as u64 }; + base.checked_add(rel)? + }; + let end = start.checked_add(len)?; + (end <= data.len() as u64).then_some(EntrySpan { tag, field_type, count, start, len }) +} + +/// Whether a directory declaring `declared` entries was read whole (`read` entries), so that its next-IFD pointer +/// follows them. The counts compare as 64-bit numbers: narrowing the declared count to `usize` instead would, on a +/// 32-bit target, let a BigTIFF declaring 4,294,967,297 entries but holding one pass for a whole directory. +fn reads_every_entry(declared: u64, read: usize) -> bool { + u64::try_from(read).is_ok_and(|read| read == declared) +} + /// Parse the header: (byte order, bigtiff, first IFD offset). pub(crate) fn header(data: &[u8]) -> Result<(ByteOrder, bool, u64)> { header_with(data, true) @@ -97,7 +199,7 @@ struct Ctx<'a> { impl<'a> Ctx<'a> { fn new(data: &'a [u8], order: ByteOrder, big: bool, opts: &'a ParseOptions) -> Self { - let budget = opts.value_budget.unwrap_or((data.len() as u64).saturating_mul(8).saturating_add(1 << 20)); + let budget = opts.budget_for(data.len()); Self { data, order, big, opts, visited: HashSet::new(), budget } } @@ -116,34 +218,19 @@ impl<'a> Ctx<'a> { if !self.visited.insert(offset) { return Err(TiffError::Invalid(format!("IFD loop at {offset}"))); } - let pos = usize::try_from(offset).map_err(|_| TiffError::Truncated(offset))?; - let (n, head, entry_size, inline) = if self.big { - (self.order.read_u64(self.data, pos).ok_or(TiffError::Truncated(offset))?, 8usize, 20usize, 8usize) - } else { - (self.order.read_u16(self.data, pos).ok_or(TiffError::Truncated(offset))? as u64, 2, 12, 4) - }; - let table = pos + head; - let fit = (self.data.len().saturating_sub(table) / entry_size) as u64; - let n_read = n.min(fit).min(self.opts.max_entries as u64) as usize; - if n == 0 { - return Err(TiffError::Invalid(format!("empty IFD at {offset}"))); - } - if n_read == 0 { - return Err(TiffError::Truncated(offset)); - } - let mut entries = Vec::with_capacity(n_read); - for i in 0..n_read { - let p = table + i * entry_size; - if let Some(e) = self.entry(p, base, inline) { + let dir = directory_entries(self.data, offset, self.order, self.big, self.opts.max_entries)?; + let mut entries = Vec::with_capacity(dir.read); + for p in dir.positions() { + if let Some(e) = self.entry(p, base) { entries.push(e); } } entries.sort_by_key(|e| e.tag); entries.dedup_by_key(|e| e.tag); - // the next-IFD offset follows the whole table, so only a table read in full has one: a count far past the - // file (a BigTIFF's is 64-bit) must not reach the arithmetic (issue #1516) - let next = if n == n_read as u64 { - match n_read.checked_mul(entry_size).and_then(|len| table.checked_add(len)).and_then(|p| self.read_off(p)) { + // the next-IFD pointer follows the last entry, read only when every declared entry was (checked: a BigTIFF + // count can be anything up to u64::MAX) + let next = if reads_every_entry(dir.declared, dir.read) { + match dir.read.checked_mul(dir.entry_size).and_then(|n| n.checked_add(dir.table)).and_then(|p| self.read_off(p)) { Some(0) | None => 0, Some(v) => base.checked_add(v).unwrap_or(0), } @@ -181,33 +268,19 @@ impl<'a> Ctx<'a> { ifd.interop = interop; } - fn entry(&mut self, p: usize, base: u64, inline: usize) -> Option<Entry> { - let o = self.order; - let tag = o.read_u16(self.data, p)?; - let typ = FieldType::from_u16(o.read_u16(self.data, p + 2)?)?; - let (count, field) = if self.big { (o.read_u64(self.data, p + 4)?, p + 12) } else { (o.read_u32(self.data, p + 4)? as u64, p + 8) }; - let total = count.checked_mul(typ.size() as u64)?; - let start = if total <= inline as u64 { - field as u64 - } else { - let rel = self.read_off(field)?; - base.checked_add(rel)? - }; - let end = start.checked_add(total)?; - if end > self.data.len() as u64 { + fn entry(&mut self, p: usize, base: u64) -> Option<Entry> { + let span = entry_span(self.data, p, self.order, self.big, base)?; + if span.len > self.budget { return None; } - if total > self.budget { - return None; - } - self.budget -= total; - let bytes = &self.data[start as usize..end as usize]; - Some(Entry { tag, value: decode(o, typ, bytes), offset: start }) + self.budget -= span.len; + let bytes = self.data.get(usize::try_from(span.start).ok()?..usize::try_from(span.start + span.len).ok()?)?; + Some(Entry { tag: span.tag, value: decode(self.order, span.field_type, bytes), offset: span.start }) } } -/// Decode `bytes` (exactly `count × size` long) as values of type `typ`. -pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { +/// Decode `bytes` (exactly `count × size` long, or the first values of an entry) as values of type `typ`. +pub fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { fn arr<const N: usize>(c: &[u8]) -> [u8; N] { let mut a = [0u8; N]; a.copy_from_slice(c); @@ -237,3 +310,99 @@ pub(crate) fn decode(o: ByteOrder, typ: FieldType, b: &[u8]) -> Value { FieldType::SLong8 => Value::SLong8(b.as_chunks::<8>().0.iter().map(|c| o.u64(arr(c)) as i64).collect()), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A BigTIFF whose first IFD, at 80, declares `declared` entries but holds one, followed by a next-IFD pointer + /// `next`; a valid one-entry IFD sits at 16. + fn bigtiff_chain(order: ByteOrder, declared: u64, next: u64) -> Vec<u8> { + let ifd = |d: &mut Vec<u8>, count: u64, tag: u16, next: u64| { + order.put_u64(d, count); + order.put_u16(d, tag); + order.put_u16(d, 3); + order.put_u64(d, 1); + order.put_u16(d, 8); + d.extend([0; 6]); + order.put_u64(d, next); + }; + let mut d = if order == ByteOrder::Little { b"II".to_vec() } else { b"MM".to_vec() }; + order.put_u16(&mut d, 43); + order.put_u16(&mut d, 8); + order.put_u16(&mut d, 0); + order.put_u64(&mut d, 80); + ifd(&mut d, 1, 0x0101, 0); + d.resize(80, 0); + ifd(&mut d, declared, 0x0100, next); + d + } + + #[test] + fn only_a_directory_read_whole_has_a_next_pointer() { + // the comparison itself, on every target: a declared count past u32::MAX never matches a small read count + for (declared, read, whole) in [ + (0, 0, true), + (1, 1, true), + (8192, 8192, true), + (8193, 8192, false), + (1, 0, false), + ((1 << 32) + 1, 1, false), + ((2 << 32) + 1, 1, false), + ((1 << 63) + 1, 1, false), + (u64::MAX, 1, false), + ] { + assert_eq!(reads_every_entry(declared, read), whole, "{declared} declared, {read} read"); + } + } + + #[test] + fn a_bigtiff_count_past_u32_max_never_follows_the_next_pointer() { + // the whole chain: on 32-bit targets (wasm32) the old cast truncated these counts to 1 and followed the pointer + for order in [ByteOrder::Little, ByteOrder::Big] { + for declared in [(1u64 << 32) + 1, (2u64 << 32) + 1, (1u64 << 63) + 1] { + let d = bigtiff_chain(order, declared, 16); + let tiff = Tiff::parse(&d).unwrap(); + assert_eq!(tiff.ifds.len(), 1, "{order:?} {declared}"); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + assert_eq!(parse_ifd_at(&d, 80, order, 0, true, &ParseOptions::default()).unwrap().1, 0, "{order:?} {declared}"); + } + // control: the same file declaring its one entry follows the pointer to the IFD at 16 + let whole = Tiff::parse(&bigtiff_chain(order, 1, 16)).unwrap(); + assert_eq!(whole.ifds.len(), 2, "{order:?}"); + assert_eq!(whole.ifds[1].u64(0x0101), Some(8)); + } + } + + #[test] + fn directory_positions_never_overflow() { + // a hand-built layout the reader never produces: the iterator stops instead of overflowing + let hostile = DirectoryEntries { table: usize::MAX, read: 2, declared: 2, entry_size: 12 }; + assert_eq!(hostile.positions().collect::<Vec<_>>(), vec![usize::MAX]); + assert_eq!(hostile.positions().nth(1), None); + let wide = DirectoryEntries { table: usize::MAX - 30, read: 4, declared: 4, entry_size: 12 }; + assert_eq!(wide.positions().collect::<Vec<_>>(), vec![usize::MAX - 30, usize::MAX - 18, usize::MAX - 6]); + // a directory read from data lists its entries + let mut data = Vec::new(); + ByteOrder::Little.put_u16(&mut data, 2); + data.extend([0; 24]); + let dir = directory_entries(&data, 0, ByteOrder::Little, false, 8192).unwrap(); + assert_eq!(dir.positions().collect::<Vec<_>>(), vec![2, 14]); + } + + #[test] + fn a_bigtiff_directory_declaring_u64_max_entries_parses_without_overflow() { + // 44 bytes: BigTIFF header (first IFD at 16), an entry count of u64::MAX, one 20-byte entry + let mut data = b"II\x2b\x00\x08\x00\x00\x00".to_vec(); + data.extend(16u64.to_le_bytes()); + data.extend(u64::MAX.to_le_bytes()); + data.extend(0x0100u16.to_le_bytes()); + data.extend(3u16.to_le_bytes()); + data.extend(1u64.to_le_bytes()); + data.extend([8, 0, 0, 0, 0, 0, 0, 0]); + assert_eq!(data.len(), 44); + let tiff = Tiff::parse(&data).unwrap(); + assert_eq!(tiff.ifds.len(), 1); + assert_eq!(tiff.ifds[0].u64(0x0100), Some(8)); + } +} diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 74bc80058..1ae33a03d 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -20,6 +20,7 @@ lightcraft-pipeline = { workspace = true } lightcraft-catalog = { workspace = true } web-time = { workspace = true } lightcraft-engine = { workspace = true } +lightcraft-slideshow = { workspace = true } lightcraft-preview = { workspace = true } egui = { workspace = true } serde = { workspace = true } @@ -27,6 +28,8 @@ serde_json = { workspace = true } log = { workspace = true } [dev-dependencies] +# tests record why they skip (`cargo xtask ci` lists the skips) +lightcraft-testkit = { workspace = true } egui_kittest = { workspace = true, features = ["wgpu", "snapshot"] } lightcraft-denoise = { workspace = true, features = ["runtime"] } lightcraft-codecs = { workspace = true } diff --git a/crates/ui-egui/locales/cs-formats.json b/crates/ui-egui/locales/cs-formats.json new file mode 100644 index 000000000..8c3c97427 --- /dev/null +++ b/crates/ui-egui/locales/cs-formats.json @@ -0,0 +1,299 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} s několika podobnými soubory: použijte Najít umístění)", + " ({} in Recently Deleted)": " ({} v nedávno odstraněných)", + " · {:.0}% clipped": " · {:.0}% oříznutých tónů", + " · {failed} couldn't be rendered": " · {failed} se nepodařilo vykreslit", + " · {queued} waiting": " · {queued} čeká", + " · {skipped} skipped (file exists)": " · {skipped} přeskočeno (soubor existuje)", + " · {} duplicate{} skipped": " · přeskočené duplikáty: {}{:.0}", + " · {} failed: {}": " · {} neúspěšných: {}", + " · {} moved": " · {} přesunuto", + " · {} not readable": " · {} nečitelných", + " · {} original{} left at the source": " · originály ponechané ve zdroji: {}{:.0}", + " · {} restored from Recently Deleted": " · {} obnoveno z nedávno odstraněných", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One neimportuje seznam se znaky ; , < nebo > v klíčovém slově: {names}", + " — left out, as a keyword list can't hold them: {names}": " — vynecháno, protože seznam klíčových slov tyto údaje nemůže obsahovat: {names}", + " — not carried over: {}{}": " — nepřevzato: {}{}", + "+{n} more": "+{n} dalších", + "+{} more": "+{} dalších", + ", {failed} file{} not readable": ", nečitelné soubory: {failed}{:.0}", + ", {} label": ", štítek: {}", + ", {} star{}": ", hvězdičky: {}{:.0}", + "1 task is still running: {names}. Quitting stops it.": "Stále běží 1 úloha: {names}. Ukončení aplikace ji zastaví.", + "; {kept} already had a location": "; {kept} již mělo polohu", + "; {n} gained synonyms": "; {n} získalo synonyma", + "; {outside} outside its time range": "; {outside} mimo časový rozsah", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commitů, +{} / −{} řádků (Δ {}), +{} / −{} binárních souborů, {} – {}", + "Add {local_n} to My Photos": "Přidat {local_n} do mých fotografií", + "Add {} Photo{}": "Přidat fotografie: {}{:.0}", + "Add {} Photo{}, restore {}": "Přidat fotografie: {}{:.0}, obnovit: {}", + "Add “{}” to Favorites": "Přidat „{}“ do oblíbených", + "Added {n} keyword{}": "Přidaná klíčová slova: {n}{:.0}", + "Added {n} photo{} to My Photos": "Přidáno do mých fotografií: {n}{:.0}", + "Added {n} photo{} to “{}”": "Do „{1}“ přidáno fotografií: {n}{0:.0}", + "Added {what} to {name}": "Přidáno {what} do {name}", + "Added “{keyword}” to {n} photo{}": "K fotografiím přidáno „{keyword}“: {n}{:.0}", + "Adding photos… {} of {}": "Přidávání fotografií… {} z {}", + "All {n} selected photos move by the same amount.": "Všech {n} vybraných fotografií se posune o stejnou hodnotu.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Již v knihovně, v nedávno odstraněných: vybrané fotografie: {}{:.0} · pravé tlačítko → Obnovit, nebo Trvale odstranit pro nový import", + "Already in the library: {} photo{} selected in All Photos": "Již v knihovně: ve všech fotografiích vybráno: {}{:.0}", + "Auto ({})": "Automaticky ({})", + "Auto Import: added {} photo{}": "Automatický import: přidáno fotografií: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Automatická synchronizace: úpravy se použijí na fotografie: {n}", + "Automatic ({} MB)": "Automatické ({} MB)", + "Build 1:1 Previews ({scope})": "Vytvořit náhledy 1:1 ({scope})", + "Build Smart Previews ({scope})": "Vytvořit inteligentní náhledy ({scope})", + "Build Standard-Sized Previews ({scope})": "Vytvořit standardní náhledy ({scope})", + "Building previews for {} photos…": "Vytváření náhledů pro fotografie: {}…", + "Click {}:1": "Kliknutí {}:1", + "Couldn't open the editor: {e}": "Editor se nepodařilo otevřít: {e}", + "Create Keyword Tag Inside “{name}”…": "Vytvořit značku klíčového slova uvnitř „{name}“…", + "Create {n} Virtual Copies": "Vytvořit virtuální kopie: {n}", + "Creates {} stacks from {} of {scope}": "Vytvoří {} sad z {} v {scope}", + "Decoder: {reason}": "Dekodér: {reason}", + "Delete \"{label}\"": "Odstranit „{label}“", + "Delete \"{label}\" (and the mask)": "Odstranit „{label}“ (i masku)", + "Delete the keyword “{name}”?": "Odstranit klíčové slovo „{name}“?", + "Delete {n} Photos": "Odstranit fotografie: {n}", + "Delete “{current}”": "Odstranit „{current}“", + "Denoising {name}: {done} of {total} tiles{}": "Redukce šumu {name}: {done} z {total} dlaždic{}", + "Discard Smart Previews ({scope})": "Odstranit inteligentní náhledy ({scope})", + "Done ({what}): {done} photo{plural}": "Hotovo ({what}): fotografií: {done}{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Stahuje se z {host}. Po stažení a ověření jej LightCraft nainstaluje a {after}.", + "Duplicate \"{label}\"": "Duplikovat „{label}“", + "Example: {example}": "Příklad: {example}", + "Exported {n} keyword{}": "Exportovaná klíčová slova: {n}{:.0}", + "Exported {n} photo{}": "Exportováno fotografií: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Exportováno fotografií: {ok} z {total}{:.0}", + "Exported {} point curve preset{}": "Exportovaná přednastavení bodové křivky: {}{:.0}", + "Exported {} preset{}": "Exportovaná přednastavení: {}{:.0}", + "Exporting {} of {total}": "Exportování {} z {total}", + "Filter bar — {active} active filter{}": "Lišta filtrů — aktivní filtry: {active}{:.0}", + "Filtered: {label}": "Filtrováno: {label}", + "Focus {:.0}": "Zaostření {:.0}", + "Folder “{}” (and its subfolders)": "Složka „{}“ (a její podsložky)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Nalezené chybějící fotografie: {n}{:.0}; stále chybí: {left}{unsure}", + "Healed {n} dust spot{}": "Retušované prachové skvrny: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import byl zrušen · přidáno fotografií: {}{:.0}", + "Import failed: {e}": "Import selhal: {e}", + "Import warnings ({n}):\n{warnings}": "Varování importu ({n}):\n{warnings}", + "Imported {n} point curve preset{}": "Importovaná přednastavení bodové křivky: {n}{:.0}", + "Imported {n} preset{}": "Importovaná přednastavení: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Importovaná přednastavení: {n}{0:.0}, nečitelné soubory: {f}{1:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Importované profily: {profiles}{:.0} (Prohlížeč profilů → jejich skupiny)", + "Imported {} photo{}": "Importováno fotografií: {}{:.0}", + "In line for AI Denoise ({ahead} ahead)…": "Ve frontě na redukci šumu pomocí AI (před vámi: {ahead})…", + "Insert at the cursor (also written {})": "Vložit na pozici kurzoru (také se zapisuje {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Odebere se z fotografií: {count}{:.0}, včetně podřízených klíčových slov. Příkaz Zpět je obnoví.", + "Label Set: {name}": "Sada štítků: {name}", + "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 tuto variantu RAW zatím neumí dekódovat ({}). Upravujete vložený náhled JPEG fotoaparátu, který již obsahuje obrazový styl (např. monochromatický) a vyvážení bílé.", + "LightCraft could not open its window: {e}": "LightCraft nedokázal otevřít okno: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nedokázal otevřít okno: {e}\n\nPodrobnosti najdete v souboru protokolu: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Katalog Lightroom byl prozkoumán: fotografií: {photos}, kolekcí: {collections}, chybějících: {missing}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace narovnání perspektivy s vodítky není podporována.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace narovnání perspektivy není podporována.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometrie Lightroom pro {path} se nezachovala; uložená transformace se ignoruje, protože narovnání perspektivy je vypnuté.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometrie Lightroom pro {path} se nezachovala; {mode} se přepočítá.", + "Lightroom import complete: {imported} photos added": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}, neúspěšných: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom byl dokončen: přidaných fotografií: {imported}; varování indexu: {warning}", + "Lightroom import failed: {e}{warning}": "Import z Lightroom selhal: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Prohlížení katalogu Lightroom selhalo: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT NEBO Apache-2.0. Písma: {} (OFL). Ikony: původní tvorba.", + "Making the denoised picture… {}%": "Vytváření obrazu s redukovaným šumem… {}%", + "Matches: {}": "Shody: {}", + "Merging… {stage} {:.0}%": "Slučování… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Přesunout {what} do nedávno odstraněných?", + "Moved {} photo(s)": "Přesunuto fotografií: {}", + "Moved; {} photo(s) relinked": "Přesunuto; znovu propojeno fotografií: {}", + "Named ({named_n})": "Pojmenované ({named_n})", + "New Folder in “{name}”": "Nová složka v „{name}“", + "On {have} of {of} selected photos": "Na {have} z {of} vybraných fotografií", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Nanášení „{k}“: kliknutím na fotografie přidáte nebo odeberete slovo · Esc ukončí režim", + "Paste into {n} photo{} — only settings that were copied are pasted": "Vložit do fotografií: {n}{:.0} — vloží se pouze zkopírovaná nastavení", + "Preset: {name}": "Přednastavení: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Pouze náhled — úpravy vloženého JPEG z fotoaparátu ({})", + "Preview only — {}": "Pouze náhled — {}", + "Previews ready for {done} photo{plural}": "Připravené náhledy pro fotografie: {done}{plural:.0}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Kopie pro měkký nátisk ({})", + "Proof Preview · {}": "Měkký nátisk · {}", + "Put inside “{parent}”": "Vložit pod „{parent}“", + "Quick Develop · {n} photos": "Rychlé vyvolání · fotografií: {n}", + "Quick Develop: {name}": "Rychlé vyvolání: {name}", + "Rated {}": "Hodnocení: {}", + "Reading Lightroom catalog… {done} of {total}": "Načítání katalogu Lightroom… {done} z {total}", + "Reading photos… {done} of {total}": "Načítání fotografií… {done} z {total}", + "Reading photos… {} of {}": "Načítání fotografií… {} z {}", + "Redo {}": "Znovu {}", + "Remove “{name}”": "Odebrat „{name}“", + "Remove “{name}” and its {count} photo{} from the library?": "Odebrat „{name}“ a jeho fotografie ({count}{:.0}) z knihovny?", + "Remove “{}” from Favorites": "Odebrat „{}“ z oblíbených", + "Removed from {}": "Odebráno z {}", + "Removed {what} from {name}": "Odebráno {what} z {name}", + "Rename {n} Photos…": "Přejmenovat fotografie: {n}…", + "Rename “{name}”": "Přejmenovat „{name}“", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Přejmenuje „{from}“ u fotografií: {n}{:.0} (i podřízená klíčová slova). Úrovně oddělujte |, např. Cestování|Itálie. Pokud název již existuje, obě slova se sloučí.", + "Rendering on the CPU: {why}": "Vykreslování na CPU: {why}", + "Replace {} with:": "Nahradit {} za:", + "Reset {name}": "Obnovit {name}", + "Reset {} Channel": "Obnovit kanál {}", + "Restore {}": "Obnovit {}", + "Restore {} Photo{}": "Obnovit fotografie: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "Obnoveno z nedávno odstraněných fotografií: {}{:.0}", + "Running on the processor: {}.": "Běží na procesoru: {}.", + "Running on {}.": "Běží na {}.", + "Saved export preset “{}”": "Přednastavení exportu „{}“ bylo uloženo", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Vyhodnotí {scope} podle zaostření a expozice a najde podobné snímky pořízené v rozmezí několika sekund (série).", + "Show {hidden} hidden location{}": "Zobrazit skrytá umístění: {hidden}{:.0}", + "Showing {n} of {}": "Zobrazeno {n} z {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Zobrazí podmínky modelu, poté stáhne {} z {host}, nainstaluje jej a začne používat. Nic dalšího se neodesílá.", + "Smart album: {}": "Inteligentní album: {}", + "Tagged {n} photo{} from the tracklog": "Poloha doplněna ze záznamu trasy u fotografií: {n}{:.0}", + "The SAM 3 download failed: {e}": "Stažení SAM 3 selhalo: {e}", + "There is a keyword set “{name}” already": "Sada klíčových slov „{name}“ již existuje", + "There is a “{name}” at the top level already. Merge the two?": "Na nejvyšší úrovni již existuje „{name}“. Sloučit obě slova?", + "Trained on: {provenance}": "Trénováno na: {provenance}", + "Undo {}": "Zpět {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Neznámé značky{s:.0} {} zůstanou beze změny{:.0} (viz Značky)", + "Updated “{name}”": "Aktualizováno: „{name}“", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Verze {} — nezávisle vytvořená knihovna fotografií a nástroj pro vyvolání RAW v čistém Rustu.", + "Version: {}": "Verze: {}", + "Virtual copy “{name}” of {of}": "Virtuální kopie „{name}“ z {of}", + "Working on {what} for {} photos…": "Zpracování {what} pro fotografie: {}…", + "XMP adjustments were not imported for {path}: {fields}.": "Úpravy XMP se pro {path} neimportovaly: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}“ se pro {path} nepoužil; barvy se mohou lišit od Lightroom.", + "none (CPU rendering): {why}": "žádné (vykreslování na CPU): {why}", + "not added to Apple Photos: {e}": "nepřidáno do Apple Photos: {e}", + "the {n} selected photos": "vybrané fotografie: {n}", + "the {} photos in view": "fotografie v zobrazení: {}", + "these {count} photos": "tyto fotografie: {count}", + "{:.1} MB": "{:.1} MB", + "{count} smart preview{} · {:.1} MB": "Inteligentní náhledy: {count}{0:.0} · {1:.1} MB", + "{done} of {total}": "{done} z {total}", + "{done} of {total} MB": "{done} z {total} MB", + "{file} · {size}": "{file} · {size}", + "{f} folders (and their subfolders)": "složky: {f} (a jejich podsložky)", + "{label} Grading": "Tónování: {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} na všech vybraných fotografiích", + "{label} {index} of {n}": "{label} {index} z {n}", + "{label}, {n} photos": "{label}, fotografií: {n}", + "{matching} of {t} photos": "fotografií: {matching} z {t}", + "{matching} photos": "fotografií: {matching}", + "{mode} spot {} of {}": "{mode}: oblast {} z {}", + "{month}": "{month}", + "{month} {year}": "{month}. {year:04}", + "{msg} ({failed} failed)": "{msg} (neúspěšných: {failed})", + "{name} Label": "Štítek {name}", + "{name} Off": "{name} vypnuto", + "{name} On": "{name} zapnuto", + "{name} opened for editing; it is stacked with the original": "{name} otevřeno k úpravám; je v sadě s originálem", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — dvojklikem obnovíte", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Přeskočené místní fotografie: {n}{:.0}; alba obsahují fotografie z knihovny (nejprve přidat do mých fotografií)", + "{n} added to Apple Photos": "Přidáno do Apple Photos: {n}", + "{n} added to Apple Photos album “{album}”": "Přidáno do alba Apple Photos „{album}“: {n}", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Změny nelze zapsat na disk: {n}{0:.0}: {e}\nPokud nyní ukončíte aplikaci, tyto změny se ztratí.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Změny uložené v paměti, ale nezapsané na disk: {n}{0:.0}: {e}\nLightCraft zápis automaticky opakuje; při ukončení nyní se tyto změny ztratí.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Změny uložené v paměti, ale nezapsané na disk: {n}{:.0}: {e} — LightCraft zápis zopakuje", + "{n} found · {sel} selected": "Nalezeno: {n} · vybráno: {sel}", + "{n} photos": "fotografií: {n}", + "{n} photos in {} are hidden by the filters above": "Fotografie v {} skryté filtry nahoře: {n}", + "{n} photos opened for editing; each is stacked with its original": "Fotografie otevřené k úpravám: {n}; každá je v sadě se svým originálem", + "{name} opened in the other editor": "{name} otevřeno v druhém editoru", + "{n} originals opened in the other editor": "Originály otevřené v druhém editoru: {n}", + "· {in_source} identical copies": "· shodné kopie: {in_source}", + "{n} photo{}": "fotografií: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Odpovídající fotografie: {n}{:.0} · aktualizuje se automaticky při změnách fotografií", + "{n} photo{} now · updates automatically as photos change": "Aktuální fotografie: {n}{:.0} · aktualizuje se automaticky při změnách fotografií", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobné fotografie: {n}{:.0} · Zobrazení → Zrušit filtry zobrazí všechny", + "{n} tasks are still running: {names}. Quitting stops them.": "Stále běží úlohy: {n}: {names}. Ukončení aplikace je zastaví.", + "{n} {noun}": "{noun}: {n}", + "{pct} %": "{pct} %", + "{pid} (missing)": "{pid} (chybí)", + "{sel_n} selected · {counted}": "Vybráno: {sel_n} · {counted}", + "{size} GB": "{size} GB", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb}: fotografií: {n}{:.0}", + "{weekday}, {day}": "{weekday}, {day}.", + "{weekday}, {day} {month} {year}": "{weekday}, {day}. {month}. {year:04}", + "{what} merge added": "Přidáno sloučení {what}", + "{what} merge cancelled": "Sloučení {what} bylo zrušeno", + "{what} merge failed: {e}": "Sloučení {what} selhalo: {e}", + "{year}": "{year:04}", + "{{date:…}} directives: {}": "Direktivy {{date:…}}: {}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "{} and higher": "{} a vyšší", + "{} contributors · {} commits": "Přispěvatelů: {} · commitů: {}", + "{} failed": "{} neúspěšných", + "{} files": "Souborů: {}", + "{} files and {f} folder{}": "Souborů: {}, složek: {f}{:.0}", + "{} item{}": "Položek: {}{:.0}", + "{} label (click more labels to show any of them)": "Štítek {} (kliknutím na další štítky zobrazíte fotografie s kterýmkoli z nich)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} ze 4 vodítek — táhněte podél čar, které mají být svislé nebo vodorovné.", + "{} of {} from {host}": "{} z {} z {host}", + "{} photos": "Fotografií: {}", + "{} photos · exposures {} EV": "Fotografií: {} · expozice {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "Fotografií: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Nečitelné fotografie: {}{:.0}", + "{} sample{}": "Vzorků: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Retušované oblasti na této fotografii: {}", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Ukládají se ve složce „denoise“ knihovny; při dosažení limitu se nejprve odstraní nejstarší, které žádná fotografie nepoužívá.", + "{} — show their photos": "{} — zobrazit fotografie této osoby", + "· {dups} already in the library": "· {dups} již v knihovně", + "· {n} photo{}": "· fotografií: {n}{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} v nedávno odstraněných", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“ již obsahuje „{name}“. Sloučit obě slova?", + "• {assumption}": "• {assumption}", + "… and {more} more": "… a dalších {more}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "new, inside {parent}": "new, inside {parent}", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " — kept but not applied: {}": " — kept but not applied: {}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Effect: {name}": "Effect: {name}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "{n} preset{}": "{n} preset{}", + "{count} more warnings.": "{count} more warnings.", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "Applied {name}": "Applied {name}", + "Delete “{name}”?": "Delete “{name}”?", + "Updated {name}": "Updated {name}", + "Exported to {path}": "Exported to {path}", + "Saved template {name}": "Saved template {name}", + "Imported {name}": "Imported {name}", + "Skipped {problem}": "Skipped {problem}", + "…and {more} more": "…and {more} more", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} JPEG files", + "{s} s of AV1 video": "{s} s of AV1 video", + "Slideshow exported: {what}": "Slideshow exported: {what}", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Již v „{}“", + "Move {n} photo{} to “{}”": "Přesunout do „{1}“ fotografie: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Přesunout do „{1}“ fotografie: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Do „{1}“ přesunuto fotografií: {n}{0:.0}. Úpravy → Zpět je vrátí." +} diff --git a/crates/ui-egui/locales/cs.json b/crates/ui-egui/locales/cs.json new file mode 100644 index 000000000..1e84313ae --- /dev/null +++ b/crates/ui-egui/locales/cs.json @@ -0,0 +1,2067 @@ +{ + " (library default)": " (výchozí nastavení knihovny)", + " (inverted)": " (invertováno)", + " · best of its burst": " · nejlepší ze série", + " · in a burst": " · v sérii", + "% of all commits": "% všech commitů", + "& Containing": "& Obsahující", + "+ Group": "+ Skupina", + "+ Rule": "+ Pravidlo", + ", picked": ", vybráno", + ", rejected": ", odmítnuto", + "0: nothing is rejected.": "0: nic se neodmítá.", + "1 day": "1 den", + "2026-04 or 2026-04-12": "2026-04 nebo 2026-04-12", + "= exactly": "= přesně", + "A blank template keeps the original names.": "Prázdná šablona zachová původní názvy.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Fotografie v tomto zobrazení skrývá filtr: změňte jej nebo jej zrušte (Zobrazení → Zrušit filtry)", + "A folder is already being synchronized": "Již probíhá synchronizace složky", + "A folder is being synchronized": "Probíhá synchronizace složky", + "A keyword set needs a name": "Sada klíčových slov musí mít název", + "A one-time download of about": "Jednorázové stažení přibližně", + "AI Denoise": "Redukce šumu pomocí AI", + "AI Denoise Model": "Model pro redukci šumu pomocí AI", + "AI Denoise is enabled on the requesting photo": "Redukce šumu pomocí AI je u požadované fotografie zapnutá", + "AI Denoise is off: press Use on a model.": "Redukce šumu pomocí AI je vypnutá: u modelu klikněte na Použít.", + "AI Denoise needs a model. Install one in Settings.": "Redukce šumu pomocí AI vyžaduje model. Nainstalujte jej v Nastavení.", + "AI Mask Model Status": "Stav modelu pro maskování pomocí AI", + "About": "O aplikaci", + "About LightCraft": "O aplikaci LightCraft", + "Accept & Download": "Přijmout a stáhnout", + "Active": "Aktivní", + "Activity": "Aktivita", + "Actual size": "Skutečná velikost", + "Add": "Přidat", + "Add Catchlight": "Přidat odlesk do oka", + "Add Face Model…": "Přidat model rozpoznávání obličejů…", + "Add Folder to Local": "Přidat složku do místních souborů", + "Add Mask": "Přidat masku", + "Add Object Click": "Přidat bod výběru objektu", + "Add Point Color Sample": "Přidat vzorek bodové barvy", + "Add Red Eye Correction": "Přidat korekci červených očí", + "Add Remove Spot": "Přidat retušovanou oblast", + "Add Selected Photos": "Přidat vybrané fotografie", + "Add a rule (⌥-click: a group)": "Přidat pravidlo (⌥-kliknutí: skupina)", + "Add in place": "Přidat ze současného umístění", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Přidat ze současného umístění: knihovna odkazuje na soubory tam, kde jsou; nic se nekopíruje ani nepřesouvá.", + "Add keyword": "Přidat klíčové slovo", + "Add number": "Přidat číslo", + "Add to": "Přidat do", + "Add to Album": "Přidat do alba", + "Add to All Selected Photos": "Přidat ke všem vybraným fotografiím", + "Add to Apple Photos": "Přidat do Apple Photos", + "Add to Favorites": "Přidat do oblíbených", + "Add to Local": "Přidat do místních souborů", + "Add to My Photos": "Přidat do mých fotografií", + "Add to My Photos First": "Nejprve přidat do mých fotografií", + "Add to Selected Photos": "Přidat k vybraným fotografiím", + "Add to Target Album": "Přidat do cílového alba", + "Add to favorites": "Přidat mezi oblíbené", + "Add to this mask": "Přidat do této masky", + "Add/Subtract/Intersect Mask": "Přičíst/Odečíst/Průnik masky", + "Added to photos you import that don't already have it.": "Přidá se k importovaným fotografiím, které tento údaj ještě nemají.", + "Adding": "Přidávání", + "Adding Lightroom photos…": "Přidávání fotografií z Lightroom…", + "Adding to Apple Photos": "Přidávání do Apple Photos", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nebyl nalezen grafický procesor: LightCraft vykresluje na CPU, což je mnohem pomalejší. Podrobnosti najdete v nabídce Nápověda → Informace o systému.", + "On NixOS, start it from `nix develop`.": "V NixOS ho spusťte z `nix develop`.", + "Files with the same content as another file in this import: only the first is imported.": "Soubory se stejným obsahem jako jiný soubor v tomto importu: importuje se jen první z nich.", + "Adding to Apple Photos…": "Přidávání do Apple Photos…", + "Advanced search…": "Rozšířené hledání…", + "After": "Po", + "After 30 days": "Po 30 dnech", + "After 7 days": "Po 7 dnech", + "After 90 days": "Po 90 dnech", + "After a year": "Po roce", + "After installation, AI Denoise will turn on for the requesting photo.": "Po instalaci se u požadované fotografie zapne redukce šumu pomocí AI.", + "Album": "Album", + "Albums": "Alba", + "Albums hold library photos: Add to My Photos first": "Alba obsahují fotografie z knihovny: nejprve je přidejte do mých fotografií", + "All": "Vše", + "All Metadata": "Všechna metadata", + "All Metadata…": "Všechna metadata…", + "All Photos": "Všechny fotografie", + "All rights reserved": "Všechna práva vyhrazena", + "All the photos imported from this disk are included.": "Zahrnou se všechny fotografie importované z tohoto disku.", + "All types": "Všechny typy", + "Already installed. Remove it before installing a replacement.": "Již nainstalováno. Před instalací náhrady model odeberte.", + "Alt Text": "Alternativní text", + "Always": "Vždy", + "Amount": "Míra", + "Analyzing the photo for AI masks…": "Analýza fotografie pro masky pomocí AI…", + "Angle": "Úhel", + "Any Searchable Text": "Libovolný prohledávatelný text", + "Any camera": "Libovolný fotoaparát", + "Any keyword": "Libovolné klíčové slovo", + "Any lens": "Libovolný objektiv", + "Aperture": "Clona", + "Appearance": "Vzhled", + "Appearance Mode": "Režim vzhledu", + "Appearance Settings…": "Nastavení vzhledu…", + "Application": "Aplikace", + "Apply": "Použít", + "Apply Color Label Set": "Použít sadu barevných štítků", + "Apply Filter Preset": "Použít přednastavení filtru", + "Apply Metadata Preset": "Použít přednastavení metadat", + "Apply Point Curve Preset": "Použít přednastavení bodové křivky", + "Apply Preset": "Použít přednastavení", + "Apply Settings JSON": "Použít nastavení JSON", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Použít místní mapování tónů uložené v souboru RAW (Apple ProRAW), stejně jako při vykreslení fotoaparátem", + "Approved": "Schváleno", + "Aqua": "Tyrkysová", + "Aqua Hue": "Odstín tyrkysové", + "Aqua Luminance": "Jas tyrkysové", + "Aqua Saturation": "Sytost tyrkysové", + "Arrange Panel": "Uspořádat panel", + "ArtCraft Website": "Web ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — další kreativní aplikace", + "As Shot": "Podle snímku", + "As shot": "Podle pořízení", + "Ascending": "Vzestupně", + "Aspect": "Poměr stran", + "Aspect Ratio": "Poměr stran obrazu", + "Assisted Culling": "Asistované třídění", + "Assisted Culling…": "Asistované třídění…", + "Auto": "Automaticky", + "Auto Advance": "Automatický přechod", + "Auto Advance Off": "Vypnout automatický přechod", + "Auto Advance On": "Zapnout automatický přechod", + "Auto Advance after rating or flagging": "Automatický přechod po hodnocení nebo označení příznakem", + "Auto Advance: move to the next photo after rating or flagging": "Automatický přechod: po hodnocení nebo označení příznakem přejde na další fotografii", + "Auto Align": "Automaticky zarovnat", + "Auto B&W Mix": "Automatická černobílá směs", + "Auto Crop": "Automaticky oříznout", + "Auto Import": "Automatický import", + "Auto Import Now": "Spustit automatický import", + "Auto Import Settings": "Nastavení automatického importu", + "Auto Mask": "Automatická maska", + "Auto Select": "Automaticky vybrat", + "Auto Settings": "Automatické nastavení", + "Auto Straighten": "Automaticky narovnat", + "Auto Sync": "Automatická synchronizace", + "Auto settings applied": "Automatické nastavení bylo použito", + "Auto-Stack by Capture Time": "Automaticky seskupit podle času pořízení", + "Auto-Stack by Capture Time…": "Automaticky seskupit podle času pořízení…", + "Auto-Tag Photos from Tracklog": "Doplnit polohu fotografií ze záznamu trasy", + "Auto-Tag from Tracklog": "Doplnit polohu ze záznamu trasy", + "Auto-Tag from Tracklog…": "Doplnit polohu ze záznamu trasy…", + "Automatic": "Automatické", + "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.": "Automatický režim vykresluje detail podle velikosti zobrazení, nejvýše v rozlišení fotografie. Zvolením velikosti nastavíte limit: menší je rychlejší.", + "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.": "Automatický režim používá grafickou kartu tam, kde je rychlejší než procesor: při prvním zpracování fotografie se na tomto počítači změří obě možnosti.", + "Automatically Write Changes into XMP": "Automaticky zapisovat změny do XMP", + "Automatically write changes into XMP sidecars": "Automaticky zapisovat změny do přidružených souborů XMP", + "Autumn Gold": "Podzimní zlato", + "B&W": "Černobílé", + "B&W Mix": "Černobílá směs", + "B&W Mixer": "Černobílý směšovač", + "Back": "Zpět", + "Back Up Library…": "Zálohovat knihovnu…", + "Back to Edit": "Zpět k úpravám", + "Back to Edit (E)": "Zpět k úpravám (E)", + "Back to Grid": "Zpět do mřížky", + "Back to this person": "Zpět k této osobě", + "Background": "Pozadí", + "Balance": "Vyvážení", + "Basic": "Základní", + "Before": "Před", + "Before/After (Y)": "Před/Po (Y)", + "Before/After Settings": "Nastavení Před/Po", + "Before/After Split": "Rozdělené Před/Po", + "Before/After Split Top/Bottom": "Rozdělené Před/Po nahoře/dole", + "Before/After Top/Bottom": "Před/Po nahoře/dole", + "Begin Interaction": "Zahájit interakci", + "Best of Similar Shots": "Nejlepší z podobných snímků", + "Binary assets added": "Přidané binární soubory", + "Binary assets removed": "Odebrané binární soubory", + "Bit depth": "Bitová hloubka", + "Black & White": "Černobílá", + "Blacks": "Černé", + "Bleach Bypass": "Vynechání bělení", + "Bleached": "Vybělené", + "Blending": "Prolnutí", + "Blue": "Modrá", + "Blue Hour Boost": "Zvýraznění modré hodiny", + "Blue Hue": "Odstín modré", + "Blue Luminance": "Jas modré", + "Blue Primary": "Primární modrá", + "Blue Saturation": "Sytost modré", + "Blue channel": "Modrý kanál", + "Blue label": "Modrý štítek", + "Blue/Yellow Fringe": "Modrý/žlutý lem", + "Blurry photos below the score are flagged as rejects.": "Rozmazaným fotografiím se skóre pod limitem se nastaví příznak Odmítnuto.", + "Boundary Warp": "Deformace hranic", + "Bright & Airy": "Světlé a vzdušné", + "Bring All to Front": "Přenést všechna okna do popředí", + "Bring the library up to date with this folder and the folders inside it:": "Aktualizovat knihovnu podle této složky a jejích podsložek:", + "Browse Folder": "Procházet složku", + "Browse Folder…": "Procházet složku…", + "Browse Profiles": "Procházet profily", + "Browse…": "Procházet…", + "Brush": "Štětec", + "Brush Settings": "Nastavení štětce", + "Brush Stroke": "Tah štětce", + "Build Previews": "Vytvořit náhledy", + "Build Smart Previews": "Vytvořit inteligentní náhledy", + "Building previews": "Vytváření náhledů", + "Building smart previews": "Vytváření inteligentních náhledů", + "By Date": "Podle data", + "By day (YYYY/YYYY-MM-DD)": "Podle dne (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "Podle měsíce (YYYY/YYYY-MM)", + "CPU threads": "Vlákna CPU", + "Cache": "Mezipaměť", + "Calibration": "Kalibrace", + "Camera": "Fotoaparát", + "Camera Info": "Informace o fotoaparátu", + "Camera local tone mapping": "Místní mapování tónů fotoaparátu", + "Camera make and model": "Výrobce a model fotoaparátu", + "Cameras and Cards": "Fotoaparáty a karty", + "Cancel": "Zrušit", + "Cancel AI Mask Model Download": "Zrušit stahování modelu pro maskování pomocí AI", + "Cancel Denoise": "Zrušit redukci šumu", + "Cancel Denoise Model Download": "Zrušit stahování modelu pro redukci šumu", + "Cancel Download": "Zrušit stahování", + "Cancel Face Model Download": "Zrušit stahování modelu rozpoznávání obličejů", + "Cancel Interaction": "Zrušit interakci", + "Cancel Pending Photo Action": "Zrušit čekající akci s fotografií", + "Cancel Preview Build": "Zrušit vytváření náhledů", + "Cancel pending action": "Zrušit čekající akci", + "Candidate": "Kandidát", + "Caption": "Popisek", + "Capture Date": "Datum pořízení", + "Capture date and time in your own format (directives below)": "Datum a čas pořízení ve vlastním formátu (direktivy níže)", + "Capture date as YYYYMMDD": "Datum pořízení jako YYYYMMDD", + "Captured": "Pořízeno", + "Captured from": "Pořízeno od", + "Captured until": "Pořízeno do", + "Catalog Statistics": "Statistiky katalogu", + "Catchlight": "Odlesk v oku", + "Change": "Změnit", + "Change Component Mode": "Změnit režim součásti", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaky nepovolené v názvech souborů (/ \\ : * ? < > | a rovné dvojité uvozovky) se nahradí znakem -; existující název získá příponu -1, -2….", + "Charcoal": "Uhel", + "Check All": "Zaškrtnout vše", + "Check Output Path": "Zkontrolovat výstupní cestu", + "Check the files of this type": "Zaškrtnout soubory tohoto typu", + "Checking and installing the model…": "Kontrola a instalace modelu…", + "Checking names…": "Kontrola názvů…", + "Checking…": "Kontrola…", + "Choose Another Library…": "Vybrat jinou knihovnu…", + "Choose Denoise Model": "Vybrat model pro redukci šumu", + "Choose Edit Settings to Copy": "Vybrat nastavení úprav ke kopírování", + "Choose Edit Settings to Copy…": "Vybrat nastavení úprav ke kopírování…", + "Choose Face Recognition Model": "Vybrat model rozpoznávání obličejů", + "Choose Folder…": "Vybrat složku…", + "Choose Profile…": "Vybrat profil…", + "Choose a model below (Use) to start.": "Začněte výběrem modelu níže (Použít).", + "Choose a photo in the grid or filmstrip": "Vyberte fotografii v mřížce nebo filmovém pásu", + "Choose an album…": "Vybrat album…", + "Choose…": "Vybrat…", + "Cinematic": "Filmové", + "City": "Město", + "Clarity": "Zřetelnost", + "Clean Studio": "Čisté studio", + "Clear": "Vymazat", + "Clear Cache": "Vymazat mezipaměť", + "Clear Denoise Cache": "Vymazat mezipaměť redukce šumu", + "Clear Filters": "Zrušit filtry", + "Clear Guides": "Vymazat vodítka", + "Clear History": "Vymazat historii", + "Clear Preview Cache": "Vymazat mezipaměť náhledů", + "Clear Quick Collection": "Vyprázdnit rychlou kolekci", + "Clear Recent Libraries": "Vymazat nedávné knihovny", + "Clear Reference": "Zrušit referenční fotografii", + "Clear all": "Vymazat vše", + "Clear cache": "Vyprázdnit mezipaměť", + "Clear form": "Vymazat formulář", + "Clear rating": "Zrušit hodnocení", + "Clear rating and advance": "Zrušit hodnocení a přejít dál", + "Cleared History": "Historie byla vymazána", + "Click Zoom Ratio": "Poměr přiblížení po kliknutí", + "Click a shortcut, then press the new keys (Esc cancels).": "Klikněte na zkratku a stiskněte nové klávesy (Esc zruší změnu).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknutím vložíte značku. Příklady platí pro IMG_0042.CR3 pořízený 14. ledna 2026 v 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Kliknutím na fotografie v mřížce přidáte nebo odeberete klíčové slovo; Esc ukončí režim", + "Click the object to select it · ⌥-click leaves a part out": "Kliknutím vyberete objekt · ⌥-kliknutím vyloučíte část", + "Click the photo to pick a colour · ⇧-click adds more": "Kliknutím na fotografii vyberete barvu · ⇧-kliknutím přidáte další", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknutím na fotografii vyberete barvu; ⇧-kliknutím přidáte další (nejvýše 5)", + "Click to clear these filters": "Kliknutím zrušíte tyto filtry", + "Click to restore · double-click to rename": "Kliknutím obnovíte · dvojklikem přejmenujete", + "Click to type a value, drag to change it": "Kliknutím můžete zadat hodnotu, tažením ji změníte", + "Clone": "Klonovat", + "Close": "Zavřít", + "Close Panel": "Zavřít panel", + "Close Window": "Zavřít okno", + "Close all": "Zavřít vše", + "Close file": "Zavřít soubor", + "Close panel": "Zavřít boční panel", + "Cloudy": "Zataženo", + "Collapse": "Sbalit", + "Collapse All Stacks": "Sbalit všechny sady", + "Collapse Stack": "Sbalit sadu", + "Color": "Barva", + "Color Grading": "Barevné tónování", + "Color Label": "Barevný štítek", + "Color Label Names": "Názvy barevných štítků", + "Color Label Sets": "Sady barevných štítků", + "Color Mixer": "Směšovač barev", + "Color Noise Reduction": "Redukce barevného šumu", + "Color Priority": "Priorita barvy", + "Color Range": "Rozsah barev", + "Color label cleared": "Barevný štítek byl zrušen", + "Color space": "Barevný prostor", + "Color space: sRGB (AVIF)": "Barevný prostor: sRGB (AVIF)", + "Colors": "Barvy", + "Combine files…": "Sloučit soubory…", + "Command Journal": "Deník příkazů", + "Comments panel": "Panel komentářů", + "Commits": "Commity", + "Company": "Společnost", + "Compare": "Porovnat", + "Compare (Shift+C)": "Porovnat (Shift+C)", + "Compare Before and After": "Porovnat před a po", + "Compare needs at least two photos": "Porovnání vyžaduje alespoň dvě fotografie", + "Component options": "Možnosti součásti", + "Compression": "Komprese", + "Concrete Cool": "Chladný beton", + "Confirm before deleting": "Potvrdit před odstraněním", + "Confirm before moving photos to Recently Deleted": "Potvrdit před přesunem fotografií do nedávno odstraněných", + "Constrain Crop": "Omezit oříznutí", + "Content-Aware": "Podle obsahu", + "Continue": "Pokračovat", + "Continue Without Saving": "Pokračovat bez uložení", + "Contrast": "Kontrast", + "Contributors": "Přispěvatelé", + "Convert to DNG": "Převést do DNG", + "Cool Fade": "Chladné vyblednutí", + "Cool Morning": "Chladné ráno", + "Copy": "Kopírovat", + "Copy After's Settings to Before": "Kopírovat nastavení Po do Před", + "Copy Before to After": "Kopírovat Před do Po", + "Copy Before's Settings to After": "Kopírovat nastavení Před do Po", + "Copy Edit Settings": "Kopírovat nastavení úprav", + "Copy History Step Settings to Before": "Kopírovat nastavení kroku historie do Před", + "Copy Metadata": "Kopírovat metadata", + "Copy Name": "Název kopie", + "Copy as DNG": "Kopírovat jako DNG", + "Copy into the library (else use the files where they are)": "Kopírovat do knihovny (jinak použít soubory v jejich umístění)", + "Copy pages": "Kopírovat stránky", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopírovat soubory (do Originals/ v knihovně nebo do zvolené složky)", + "Copy to": "Kopírovat do", + "Copy to Clipboard": "Kopírovat do schránky", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopírovat: soubory se zkopírují do složky níže a knihovna používá kopie; originály zůstanou beze změny.", + "Copyright": "Autorská práva", + "Copyright Info URL": "URL informací o autorských právech", + "Copyright Status": "Stav autorských práv", + "Copyrighted": "Chráněno autorskými právy", + "Country": "Země", + "Create": "Vytvořit", + "Create Album": "Vytvořit album", + "Create Album…": "Vytvořit album…", + "Create Folder": "Vytvořit složku", + "Create Folder…": "Vytvořit složku…", + "Create Keyword Tag": "Vytvořit značku klíčového slova", + "Create New Mask": "Vytvořit novou masku", + "Create PDF from clipboard": "Vytvořit PDF ze schránky", + "Create PDF from file…": "Vytvořit PDF ze souboru…", + "Create PDF from images…": "Vytvořit PDF z obrázků…", + "Create Preset": "Vytvořit přednastavení", + "Create Preset…": "Vytvořit přednastavení…", + "Create Proof Copy": "Vytvořit kopii pro měkký nátisk", + "Create Smart Album": "Vytvořit inteligentní album", + "Create Smart Album from Filter…": "Vytvořit inteligentní album z filtru…", + "Create Smart Album…": "Vytvořit inteligentní album…", + "Create Stack": "Vytvořit sadu", + "Create Version": "Vytvořit verzi", + "Create Version from Step": "Vytvořit verzi z kroku", + "Create Virtual Copy": "Vytvořit virtuální kopii", + "Creator": "Autor", + "Creator metadata": "Metadata autora", + "Crisp Landscape": "Ostrá krajina", + "Crisp Vista": "Jasný výhled", + "Crop": "Oříznout", + "Crop & Rotate": "Oříznout a otočit", + "Crop & Rotate (C)": "Oříznout a otočit (C)", + "Crop Aspect": "Poměr stran ořezu", + "Crop, Rotate, Geometry": "Oříznutí, otočení, geometrie", + "Cropped": "Oříznuto", + "Cross Process": "Křížové vyvolání", + "Culling": "Třídění", + "Curve": "Křivka", + "Custom": "Vlastní", + "Custom template…": "Vlastní šablona…", + "Custom…": "Vlastní…", + "Cut": "Vyjmout", + "Cut pages": "Vyjmout stránky", + "Cycle Crop Overlay": "Přepnout překrytí ořezu", + "Cycle Crop Overlay Orientation": "Přepnout orientaci překrytí ořezu", + "Cycle Info Overlay": "Přepnout překrytí informací", + "Cycle Mask Overlay Color": "Přepnout barvu překrytí masky", + "Cycle Mask Overlay Mode": "Přepnout režim překrytí masky", + "Cylindrical": "Válcová", + "Dark & Moody": "Tmavé a náladové", + "Dark Mode": "Tmavý režim", + "Dark Theme": "Tmavý motiv", + "Darken": "Ztmavit", + "Darks": "Tmavé tóny", + "Date": "Datum", + "Date Groups": "Skupiny podle data", + "Day": "Den", + "Daylight": "Denní světlo", + "Decrease Brush Feather": "Zmenšit prolnutí štětce", + "Decrease Brush Size": "Zmenšit velikost štětce", + "Decrease Rating": "Snížit hodnocení", + "Deep Sky": "Hluboká obloha", + "Defringe": "Odstranit barevné lemy", + "Deghost Amount": "Míra odstranění duchů", + "Dehaze": "Odstranit opar", + "Delete": "Odstranit", + "Delete (⌫)": "Odstranit (⌫)", + "Delete Album": "Odstranit album", + "Delete All Masks": "Odstranit všechny masky", + "Delete Color Label Set": "Odstranit sadu barevných štítků", + "Delete Component": "Odstranit součást", + "Delete Export Preset": "Odstranit přednastavení exportu", + "Delete Filter Preset": "Odstranit přednastavení filtru", + "Delete Imported Profile": "Odstranit importovaný profil", + "Delete Keyword": "Odstranit klíčové slovo", + "Delete Keyword Set": "Odstranit sadu klíčových slov", + "Delete Keyword…": "Odstranit klíčové slovo…", + "Delete Mask": "Odstranit masku", + "Delete Metadata Preset": "Odstranit přednastavení metadat", + "Delete Permanently": "Trvale odstranit", + "Delete Photo": "Odstranit fotografii", + "Delete Photos": "Odstranit fotografie", + "Delete Point Color": "Odstranit bodovou barvu", + "Delete Point Color Sample": "Odstranit vzorek bodové barvy", + "Delete Point Curve Preset": "Odstranit přednastavení bodové křivky", + "Delete Preset": "Odstranit přednastavení", + "Delete Red Eye": "Odstranit červené oči", + "Delete Red Eye Correction": "Odstranit korekci červených očí", + "Delete Spot": "Odstranit retušovanou oblast", + "Delete Version": "Odstranit verzi", + "Delete all": "Odstranit vše", + "Delete all spots": "Odstranit všechny retušované oblasti", + "Delete mask": "Odstranit masku", + "Delete them": "Odstranit je", + "Denoise Model Downloads": "Stahování modelů pro redukci šumu", + "Denoise Models": "Modely pro redukci šumu", + "Denoise Photos": "Redukovat šum fotografií", + "Denoise Pump": "Zpracovat frontu redukce šumu", + "Denoise Settings": "Nastavení redukce šumu", + "Denoise Status": "Stav redukce šumu", + "Denoise all photos with an Amount": "Redukovat šum všech fotografií s nastavenou mírou", + "Density": "Hustota", + "Depth Range": "Rozsah hloubky", + "Descending": "Sestupně", + "Describe": "Popsat", + "Describe what to keep": "Popište, co zachovat", + "Describe what to leave out": "Popište, co vyloučit", + "Describe what to select": "Popište, co vybrat", + "Describe…": "Popsat…", + "Description": "Popis", + "Deselect": "Zrušit výběr", + "Deselect All": "Zrušit výběr všeho", + "Desert Heat": "Pouštní žár", + "Desert Warmth": "Pouštní teplo", + "Desktop": "Plocha", + "Detail": "Detail", + "Detail (D)": "Detail (D)", + "Details": "Podrobnosti", + "Detect Faces": "Rozpoznat obličeje", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Rozpoznávání obličejů (nabídka Fotografie) jej vyžaduje: stačí malé jednorázové stažení.", + "Develop": "Vyvolat", + "Diagonal": "Úhlopříčka", + "Discard Preview Cache": "Vymazat mezipaměť náhledů", + "Discarding smart previews": "Odstraňování inteligentních náhledů", + "Display P3": "Display P3", + "Display name": "Zobrazované jméno", + "Display theme": "Motiv zobrazení", + "Distortion": "Zkreslení", + "Document properties…": "Vlastnosti dokumentu…", + "Don't enlarge": "Nezvětšovat", + "Done": "Hotovo", + "Download": "Stáhnout", + "Download AI Mask Model": "Stáhnout model pro maskování pomocí AI", + "Download Denoise Model": "Stáhnout model pro redukci šumu", + "Download Face Model": "Stáhnout model rozpoznávání obličejů", + "Download Place Names (GeoNames)": "Stáhnout názvy míst (GeoNames)", + "Download the SAM 3 Model?": "Stáhnout model SAM 3?", + "Downloaded and checked. Installing…": "Staženo a ověřeno. Probíhá instalace…", + "Downloading model": "Stahování modelu", + "Downloading place names": "Stahování názvů míst", + "Downloads": "Stažené soubory", + "Drag Crop Handle": "Přetáhnout úchyt ořezu", + "Drag along the horizon; double-click for Auto": "Táhněte podél horizontu; dvojklik spustí automatické narovnání", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Přetáhněte přes oko na fotografii; zornice uvnitř se najde automaticky.", + "Draw Guides": "Nakreslit vodítka", + "Drop here for the top level": "Přetažením sem umístíte do nejvyšší úrovně", + "Duplicate": "Duplikovat", + "Duplicate Component": "Duplikovat součást", + "Duplicate Mask": "Duplikovat masku", + "Duplicate and Invert Mask": "Duplikovat a invertovat masku", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Každé / začíná další úroveň složek; značky se vyplňují pro každou fotografii zvlášť (úroveň s chybějícími metadaty je „neznámá“).", + "Edge": "Hrana", + "Edit": "Úpravy", + "Edit (E)": "Upravit (E)", + "Edit Capture Time": "Upravit čas pořízení", + "Edit Capture Time…": "Upravit čas pořízení…", + "Edit Color Label Names": "Upravit názvy barevných štítků", + "Edit Color Label Names…": "Upravit názvy barevných štítků…", + "Edit Copy for External Editor": "Upravit kopii pro externí editor", + "Edit Date": "Datum úprav", + "Edit Info": "Upravit informace", + "Edit Keyword Set": "Upravit sadu klíčových slov", + "Edit Keyword Tag": "Upravit značku klíčového slova", + "Edit Keyword Tag…": "Upravit značku klíčového slova…", + "Edit Label Names": "Upravit názvy štítků", + "Edit Label Names…": "Upravit názvy štítků…", + "Edit Mask": "Upravit masku", + "Edit Mask Component": "Upravit součást masky", + "Edit Sections": "Sekce úprav", + "Edit Set…": "Upravit sadu…", + "Edit Smart Album": "Upravit inteligentní album", + "Edit Smart Album…": "Upravit inteligentní album…", + "Edit Spot": "Upravit retušovanou oblast", + "Edit in External Editor": "Upravit v externím editoru", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "Upravit v HDR: světla nad bílou SDR pro export a displeje HDR", + "Edit in Other Editor": "Upravit v jiném editoru", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "Upravit v jiném editoru (⌥⌘E) otevře kopii v této aplikaci (název aplikace v macOS, jinde cesta k programu).", + "Edit in PhotoCraft": "Upravit v PhotoCraft", + "Edit settings copied": "Nastavení úprav bylo zkopírováno", + "Edited": "Upraveno", + "Edited or not": "S úpravami nebo bez nich", + "Effect": "Efekt", + "Effects": "Efekty", + "Empty Recently Deleted": "Vyprázdnit nedávno odstraněné", + "Enable Lens Corrections": "Zapnout korekce objektivu", + "Enable Profile Corrections": "Zapnout korekce podle profilu", + "End Interaction": "Ukončit interakci", + "English": "English", + "Enter Full Screen": "Přejít na celou obrazovku", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Zadejte datum jako YYYY-MM-DD HH:MM:SS", + "Erase": "Mazat", + "Evaluate Face Recognition": "Vyhodnotit rozpoznávání obličejů", + "Every EXIF, GPS and XMP field in the file": "Všechna pole EXIF, GPS a XMP v souboru", + "Existing previews": "Existující náhledy", + "Exit": "Ukončit", + "Expand": "Rozbalit", + "Expand All Stacks": "Rozbalit všechny sady", + "Expand Stack": "Rozbalit sadu", + "Expand/Collapse Stack": "Rozbalit/Sbalit sadu", + "Export": "Exportovat", + "Export Album with Preset": "Exportovat album s přednastavením", + "Export Album…": "Exportovat album…", + "Export As…": "Exportovat jako…", + "Export Containing Keywords": "Exportovat nadřazená klíčová slova", + "Export Group…": "Exportovat skupinu…", + "Export Keywords": "Exportovat klíčová slova", + "Export Keywords…": "Exportovat klíčová slova…", + "Export Now": "Exportovat nyní", + "Export Point Curve Presets": "Exportovat přednastavení bodové křivky", + "Export Point Curve Presets…": "Exportovat přednastavení bodové křivky…", + "Export Presets": "Exportovat přednastavení", + "Export Presets…": "Exportovat přednastavení…", + "Export Synonyms": "Exportovat synonyma", + "Export User Presets…": "Exportovat uživatelská přednastavení…", + "Export stopped unexpectedly": "Export byl neočekávaně přerušen", + "Export with Preset": "Exportovat s přednastavením", + "Export with Previous": "Exportovat s předchozím nastavením", + "Exporting": "Exportování", + "Exporting contact sheet": "Exportování přehledu snímků", + "Export…": "Exportovat…", + "Exposure": "Expozice", + "Exposure (shutter · aperture · ISO)": "Expozice (závěrka · clona · ISO)", + "Extended Description": "Rozšířený popis", + "External Editing": "Externí úpravy", + "External Editor": "Externí editor", + "Face Boxes": "Rámečky obličejů", + "Face Model Downloads": "Stahování modelů rozpoznávání obličejů", + "Face Models": "Modely rozpoznávání obličejů", + "Face Recognition On/Off": "Zapnout/Vypnout rozpoznávání obličejů", + "Face boxes": "Oblasti obličejů", + "Face detector": "Detektor obličejů", + "Face names written to XMP by Lightroom and other apps show up here.": "Zde se zobrazují jména u obličejů zapsaná do XMP aplikací Lightroom i jinými aplikacemi.", + "Face recognition": "Rozpoznávání obličejů", + "Faces": "Obličeje", + "Faces of a Person": "Obličeje osoby", + "Faded Matte": "Vybledlé matné", + "Faded Slide": "Vybledlý diapozitiv", + "Faded Urban": "Vybledlé město", + "Failed": "Nezdařilo se", + "Failed its last test": "Poslední test se nezdařil", + "Favorite Preset": "Přidat přednastavení mezi oblíbené", + "Favorite Profile": "Přidat profil mezi oblíbené", + "Favorites": "Oblíbené", + "Feather": "Prolnutí okrajů", + "File": "Soubor", + "File & date": "Soubor a datum", + "File Extension": "Přípona souboru", + "File Format": "Formát souboru", + "File Name": "Název souboru", + "File Path": "Cesta k souboru", + "File Size": "Velikost souboru", + "File Type": "Typ souboru", + "File name": "Název výstupního souboru", + "File types:": "Typy souborů:", + "Filename": "Jméno souboru", + "Fill": "Vyplnit", + "Fill Edges": "Vyplnit okraje", + "Fill Location": "Doplnit místo", + "Fill Location from GPS": "Doplnit místo podle GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Doplnit prázdné město, kraj a zemi podle GPS (názvy míst GeoNames offline)", + "Film": "Film", + "Filmstrip": "Filmový pás", + "Filmstrip (/)": "Filmový pás (/)", + "Filter": "Filtr", + "Filter Bar": "Lišta filtrů", + "Filter Keywords": "Filtrovat klíčová slova", + "Filter Presets": "Přednastavení filtrů", + "Filter bar": "Panel filtrů", + "Filter fields": "Pole filtru", + "Find Dust Spots": "Najít prachové skvrny", + "Find Missing Photos": "Najít chybějící fotografie", + "Find Missing Photos…": "Najít chybějící fotografie…", + "Find Similar Photos": "Najít podobné fotografie", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Najít fotografie přidané do této složky na disku nebo v ní chybějící a přidružené soubory XMP změněné jinými aplikacemi", + "Find tools and commands…": "Najít nástroje a příkazy…", + "Finding faces": "Hledání obličejů", + "Find…": "Najít…", + "First": "První", + "First commit": "První commit", + "Fit": "Přizpůsobit", + "Fit (F)": "Přizpůsobit (F)", + "Fit on Screen": "Přizpůsobit obrazovce", + "Fit to width": "Přizpůsobit šířce", + "Fit visible": "Přizpůsobit viditelné oblasti", + "Five stars": "Pět hvězdiček", + "Fix the marked rules to save this album.": "Chcete-li toto album uložit, opravte označená pravidla.", + "Fixed keys": "Pevné klávesy", + "Flag": "Příznak", + "Flag as Pick": "Nastavit příznak Vybráno", + "Flag as Reject": "Nastavit příznak Odmítnuto", + "Flagged as Pick": "Nastaven příznak Vybráno", + "Flagged as Reject": "Nastaven příznak Odmítnuto", + "Flash": "Blesk", + "Flip H": "Převrátit vodorovně", + "Flip Horizontal": "Převrátit horizontálně", + "Flip Overlay (⇧O)": "Převrátit překrytí (⇧O)", + "Flip V": "Převrátit svisle", + "Flip Vertical": "Převrátit vertikálně", + "Float panel": "Uvolnit panel", + "Flow": "Tok", + "Fluorescent": "Zářivka", + "Focal Length": "Ohnisková vzdálenost", + "Focal length": "Ohnisko", + "Focus (assisted culling)": "Zaostření (asistované třídění)", + "Folder": "Složka", + "Folder name (renamed on disk; its photos follow)": "Název složky (přejmenuje se na disku; její fotografie se přesunou s ní)", + "Folders": "Složky", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Složky a {date} používají čas pořízení; fotografie bez něj použije dnešní datum.", + "Food": "Jídlo", + "Forget Unchanged Local Photos": "Zapomenout nezměněné místní fotografie", + "Forget unchanged photos": "Zapomenout nezměněné fotografie", + "Form fields panel": "Panel polí formuláře", + "Format": "Formát", + "Forward": "Vpřed", + "Found automatically": "Nalezeno automaticky", + "Fr": "Pá", + "Frame time": "Doba snímku", + "Frame update": "Aktualizace snímku", + "Free": "Volné", + "Fresh & Bright": "Svěží a jasné", + "Friday": "Pátek", + "Full": "Plné", + "Full Screen Preview": "Náhled na celé obrazovce", + "Full screen mode": "Režim celé obrazovky", + "Full size": "Plná velikost", + "GPS Track Log": "Záznam trasy GPS", + "GPU Rendering": "Vykreslování na GPU", + "GPU rendering": "Vykreslení na grafické kartě", + "General": "Obecné", + "Generated demo photo": "Generovaná ukázková fotografie", + "Geometry": "Geometrie", + "Get Develop Settings": "Získat nastavení vyvolání", + "Global Hue": "Celkový odstín", + "Global Luminance": "Celkový jas", + "Global Saturation": "Celková sytost", + "Glossy": "Lesklé", + "Go back to listing them by name": "Vrátit se k seznamu podle jmen", + "Go to History Step": "Přejít na krok historie", + "Golden": "Zlaté", + "Golden Glow": "Zlatá záře", + "Golden Hour": "Zlatá hodina", + "Grab bag": "Různorodé", + "Grain": "Zrnitost", + "Graphic": "Grafika", + "Graphics card": "Grafická karta", + "Green": "Zelená", + "Green Amount": "Míra zelené", + "Green Hue": "Odstín zelené", + "Green Hue High": "Horní mez odstínu zelené", + "Green Hue Low": "Dolní mez odstínu zelené", + "Green Luminance": "Jas zelené", + "Green Primary": "Primární zelená", + "Green Saturation": "Sytost zelené", + "Green channel": "Zelený kanál", + "Green label": "Zelený štítek", + "Grid": "Mřížka", + "Grid Info": "Informace v mřížce", + "Gritty Street": "Drsná ulice", + "Group": "Skupina", + "Group by Date: Automatic": "Seskupit podle data: automaticky", + "Group by Day": "Seskupit podle dne", + "Group by Month": "Seskupit podle měsíce", + "Group by Year": "Seskupit podle roku", + "Group by date": "Seskupování podle data", + "Group into Stack": "Seskupit do sady", + "Group name": "Název skupiny", + "Guided": "S vodítky", + "Guided Upright": "Narovnání perspektivy s vodítky", + "Guided Upright Guides": "Vodítka narovnání perspektivy", + "HDR": "HDR", + "HDR Merge": "Sloučit do HDR", + "HDR Merge Preview": "Náhled sloučení do HDR", + "HDR Panorama Merge": "Sloučit do panoramatu HDR", + "HDR Panorama Merge Preview": "Náhled sloučení do panoramatu HDR", + "HDR Panorama with Last Settings": "Panorama HDR s posledním nastavením", + "HDR Panoramas": "Panoramata HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panoramata HDR", + "HDR with Last Settings": "HDR s posledním nastavením", + "HDR…": "HDR…", + "Has Edits": "Obsahuje úpravy", + "Has GPS": "Obsahuje GPS", + "Heal": "Retušovat", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Retušuje drobné měkké tmavé skvrny, které prach na snímači zanechává v hladkých oblastech", + "Height": "Výška", + "Height (px)": "Výška (px)", + "Help": "Nápověda", + "Hide LightCraft": "Skrýt LightCraft", + "Hide Mask": "Skrýt masku", + "Hide Others": "Skrýt ostatní", + "Hide This Section": "Skrýt tuto sekci", + "Hides this shortcut only; the folder and its photos stay as they are": "Skryje pouze tohoto zástupce; složka a její fotografie zůstanou beze změny", + "High": "Vysoká", + "High Contrast B&W": "Vysoce kontrastní černobílá", + "Highlight": "Zvýraznit", + "Highlights": "Světla", + "Highlights Hue": "Odstín světel", + "Highlights Luminance": "Jas světel", + "Highlights Saturation": "Sytost světel", + "Highlights split": "Rozdělení světel", + "Histogram": "Histogram", + "History": "Historie", + "History & Activity (Y)": "Historie a aktivita (Y)", + "Home": "Domovská složka", + "Horizontal": "Vodorovně", + "Horizontal inset (% of short edge)": "Vodorovné odsazení (% kratší hrany)", + "Horizontal text": "Vodorovný text", + "Hours": "Hodiny", + "How to install the model": "Jak nainstalovat model", + "Hue": "Odstín", + "Hue Range": "Rozsah odstínu", + "I have read these terms and accept them for my own use": "Tyto podmínky jsem si přečetl(a) a přijímám je pro vlastní použití", + "ISO Speed": "Citlivost ISO", + "ISO speed": "ISO citlivost", + "If file exists": "Pokud soubor existuje", + "Image": "Obrázek", + "Import": "Importovat", + "Import Date": "Datum importu", + "Import Keywords": "Importovat klíčová slova", + "Import Keywords…": "Importovat klíčová slova…", + "Import Lightroom Catalog": "Importovat katalog Lightroom", + "Import Lightroom Catalog…": "Importovat katalog Lightroom…", + "Import Photos": "Importovat fotografie", + "Import Photos…": "Importovat fotografie…", + "Import Point Curve Presets": "Importovat přednastavení bodové křivky", + "Import Point Curve Presets…": "Importovat přednastavení bodové křivky…", + "Import Presets": "Importovat přednastavení", + "Import Presets…": "Importovat přednastavení…", + "Import Profiles": "Importovat profily", + "Import Profiles & Presets…": "Importovat profily a přednastavení…", + "Import as new": "Importovat jako nové", + "Import from Device": "Importovat ze zařízení", + "Import from Folder…": "Importovat ze složky…", + "Import new photos": "Importovat nové fotografie", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importujte fotografie přes Soubor → Importovat fotografie… (Cmd+Shift+I) nebo je přetáhněte sem", + "Imported": "Importováno", + "Imported Photos": "Importované fotografie", + "Importing": "Importování", + "Importing Lightroom Catalog": "Importování katalogu Lightroom", + "Importing Lightroom catalog": "Import katalogu 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).": "Import hledá ve zdroji fotografie; nepřidává složku do místních souborů (pro práci ve složce bez importu použijte Místní soubory → Procházet složku…).", + "In Color": "Barevně", + "In Photo Grid and Square Grid: Flag as Pick and advance": "V mřížce fotografií a čtvercové mřížce: nastavit příznak Vybráno a přejít dál", + "In Photo Grid and Square Grid: expand or collapse the stack": "V mřížce fotografií a čtvercové mřížce: rozbalit nebo sbalit sadu", + "In Recently Deleted": "V nedávno odstraněných", + "In Use": "Používá se", + "In a Stack": "V sadě", + "In library": "V knihovně", + "In memory — nothing is saved": "V paměti — nic není uloženo", + "In use": "Používané", + "In-memory session: these settings last until LightCraft quits.": "Relace v paměti: tato nastavení platí do ukončení LightCraft.", + "Include on Export": "Zahrnout do exportu", + "Include subfolders": "Zahrnout podsložky", + "Increase Brush Feather": "Zvětšit prolnutí štětce", + "Increase Brush Size": "Zvětšit velikost štětce", + "Increase Rating": "Zvýšit hodnocení", + "Index Faces": "Indexovat obličeje", + "Index Faces in the Background": "Indexovat obličeje na pozadí", + "Info": "Informace", + "Info (I)": "Informace (I)", + "Info Overlay Off": "Vypnout překrytí informací", + "Info Overlay: Exposure": "Překrytí informací: expozice", + "Info Overlay: File": "Překrytí informací: soubor", + "Info overlay": "Zobrazení informací přes snímek", + "Insert at the cursor": "Vložit na pozici kurzoru", + "Inspect Denoise Model": "Prozkoumat model pro redukci šumu", + "Inspect Face Model File": "Prozkoumat soubor modelu obličejů", + "Inspect Lightroom Catalog": "Prozkoumat katalog Lightroom", + "Inspect Photo": "Prozkoumat fotografii", + "Inspecting Lightroom Catalog": "Prohlížení katalogu Lightroom", + "Install Denoise Model": "Nainstalovat model pro redukci šumu", + "Install Face Model": "Nainstalovat model rozpoznávání obličejů", + "Install a denoise model to enable AI Denoise on this photo": "Chcete-li u této fotografie zapnout redukci šumu pomocí AI, nainstalujte model", + "Install from file…": "Nainstalovat ze souboru…", + "Install the SAM 3 Model": "Nainstalovat model SAM 3", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Nainstalujte vlastní model ONNX se souborem denoise-model.json vedle něj. Toto sestavení nenabízí stahování modelů.", + "Instant ’70s": "Instantní sedmdesátky", + "Intent:": "Záměr:", + "Interface": "Rozhraní", + "Interface language": "Jazyk rozhraní", + "Intersect": "Průnik", + "Intersect with": "Průnik s", + "Into one folder": "Do jedné složky", + "Invert": "Invertovat", + "Invert Component": "Invertovat součást", + "Invert Mask": "Invertovat masku", + "It tests this album, so testing it back would loop": "Testuje toto album, takže zpětný test vytvoří smyčku", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Obraz s redukovaným šumem ještě není vytvořen a obrazy se nevytvářejí automaticky (Nastavení, Redukce šumu pomocí AI).", + "JPEG (Large)": "JPEG (velký)", + "JPEG (Small)": "JPEG (malý)", + "Join the ArtCraft Discord": "Připojit se k Discordu ArtCraft", + "Join the ArtCraft Discord…": "Připojit se k Discordu ArtCraft…", + "Join the ArtCraft community on Discord": "Připojit se ke komunitě ArtCraft na Discordu", + "Keep in Local": "Ponechat v místních souborech", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Uložte je na disk s dostatkem místa: inteligentní náhledy zabírají přibližně 1 MB na fotografii. Mezipaměť miniatur zůstává v knihovně.", + "Keyboard Shortcuts": "Klávesové zkratky", + "Keyboard shortcuts": "Zkratky klávesnice", + "Keyword": "Klíčové slovo", + "Keyword Count": "Počet klíčových slov", + "Keyword List": "Seznam klíčových slov", + "Keyword Lists": "Seznamy klíčových slov", + "Keyword Name": "Název klíčového slova", + "Keyword Painter": "Malování klíčových slov", + "Keyword Set": "Sada klíčových slov", + "Keyword Sets": "Sady klíčových slov", + "Keyword Suggestions": "Návrhy klíčových slov", + "Keyword Tag Options": "Možnosti značky klíčového slova", + "Keyword to paint": "Klíčové slovo k nanášení", + "Keywords": "Klíčová slova", + "Keywords & People": "Klíčová slova a lidé", + "Keywords (K)": "Klíčová slova (K)", + "Keywords (⌥1–⌥9)": "Klíčová slova (⌥1–⌥9)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Zde se zobrazují přidaná klíčová slova; ⌥1–⌥9 je přiřadí.", + "LUT-based": "Na základě LUT", + "Label": "Štítek", + "Label Set": "Sada štítků", + "Landscape": "Krajina", + "Landscape (wide)": "Na šířku", + "Language": "Jazyk", + "Last": "Poslední", + "Last GPU fallback": "Poslední přechod z GPU na CPU", + "Last commit": "Poslední commit", + "Last loupe render": "Poslední vykreslení lupy", + "Last view": "Poslední zobrazení", + "Lateral Chromatic Aberration": "Příčná chromatická aberace", + "Layer": "Vrstva", + "Layers": "Vrstvy", + "Leave them": "Ponechat je", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "Vlevo: standardní rozsah (SDR) až po bílou SDR. Vpravo: rozsah HDR, jeden pruh pro každý expoziční stupeň nad bílou SDR (oranžová čára: rezerva HDR). Při vizualizaci rozsahu HDR přebírají pruhy její barvy.", + "Lens": "Objektiv", + "Lens Blur": "Rozostření objektivu", + "Lens Corrections": "Korekce objektivu", + "Level": "Úroveň", + "Library": "Knihovna", + "Library Folders": "Složky knihovny", + "Library Info": "Informace o knihovně", + "Library Originals": "Originály knihovny", + "Library Preferences": "Předvolby knihovny", + "Library State": "Stav knihovny", + "Library name": "Název knihovny", + "Library saved": "Knihovna byla uložena", + "Licence:": "Licence:", + "Light": "Světlo", + "Light Mode": "Světlý režim", + "Light Theme": "Světlý motiv", + "LightCraft Default": "Výchozí LightCraft", + "LightCraft Help": "Nápověda LightCraft", + "LightCraft Preset": "Přednastavení LightCraft", + "LightCraft Presets": "Přednastavení LightCraft", + "LightCraft Website": "Webové stránky LightCraft", + "LightCraft could not start": "LightCraft se nepodařilo spustit", + "LightCraft couldn't find where to keep your library.": "LightCraft nedokázal najít místo pro uložení knihovny.", + "LightCraft couldn't open the library at": "LightCraft nedokázal otevřít knihovnu v umístění", + "LightCraft does not know this model, so it assumed:": "LightCraft tento model nezná, a proto předpokládá:", + "LightCraft on GitHub": "LightCraft na GitHubu", + "LightCraft website": "Web LightCraft", + "LightCraft — Second Window": "LightCraft — druhé okno", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom import cancelled": "Import z Lightroom byl zrušen", + "Lightroom inspection cancelled": "Prohlížení katalogu Lightroom bylo zrušeno", + "Lights": "Světlé tóny", + "Lights Out": "Ztlumit okolí", + "Limit": "Limit", + "Limit file size (KB, 0 = off)": "Omezit velikost souboru (KB, 0 = vypnuto)", + "Line delta": "Změna počtu řádků", + "Linear": "Lineární", + "Linear Gradient": "Lineární přechod", + "Lines +/−": "Řádky +/−", + "Lines added": "Přidané řádky", + "Lines deleted": "Odstraněné řádky", + "List Albums": "Vypsat alba", + "List Develop Controls": "Vypsat ovládací prvky vyvolání", + "List History": "Vypsat historii", + "List Presets": "Vypsat přednastavení", + "List Profiles": "Vypsat profily", + "List this folder in Local from now on": "Od nynějška zobrazovat tuto složku v místních souborech", + "Loading preview…": "Načítání náhledu…", + "Local": "Místní soubory", + "Local folders": "Místní složky", + "Local library — no cloud account needed": "Místní knihovna — bez nutnosti cloudového účtu", + "Locate Missing File…": "Najít chybějící soubor…", + "Locate Missing Files…": "Najít chybějící soubory…", + "Locate Photo": "Najít umístění fotografie", + "Location": "Místo", + "Lock": "Uzamknout", + "Lock Crop Aspect": "Uzamknout poměr stran ořezu", + "Locked": "Uzamčeno", + "Long Edge": "Delší hrana", + "Looking for changes": "Hledání změn", + "Looking for photos…": "Hledání fotografií…", + "Looking up places": "Vyhledávání míst", + "Lossless": "Bezeztrátové", + "Low": "Nízká", + "Luminance": "Jas", + "Luminance Range": "Rozsah jasu", + "Lush Greens": "Svěží zeleň", + "Magenta": "Purpurová", + "Magenta Hue": "Odstín purpurové", + "Magenta Luminance": "Jas purpurové", + "Magenta Saturation": "Sytost purpurové", + "Make Candidate the Select": "Nastavit kandidáta jako vybraný snímek", + "Make it now": "Vytvořit nyní", + "Make the denoised picture of the photos I am looking at": "Vytvořit obraz s redukovaným šumem u právě zobrazených fotografií", + "Manual": "Ručně", + "Marquee zoom": "Přiblížení výběrem oblasti", + "Mask Adjustment": "Úprava masky", + "Masking": "Maskování", + "Masking (M)": "Maskování (M)", + "Masks": "Masky", + "Match": "Shoda", + "Match Total Exposures": "Sjednotit celkovou expozici", + "Matrix/TRC": "Matice/TRC", + "Matte": "Matné", + "Matte Soft": "Jemné matné", + "Med": "Střední", + "Medium Contrast": "Střední kontrast", + "Megapixels": "Megapixely", + "Memory Budget": "Paměťový limit", + "Memory Usage": "Využití paměti", + "Memory budget": "Rozpočet paměti", + "Memory for caches": "Paměť pro mezipaměti", + "Menu": "Nabídka", + "Merge": "Sloučit", + "Merge Keywords": "Sloučit klíčová slova", + "Merge into…": "Sloučit do…", + "Merged": "Sloučeno", + "Merged PRs": "Sloučené PR", + "Merging": "Slučování", + "Merging…": "Slučování…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Jde o podmínky společnosti Meta, nikoli LightCraft. Stažením je přijímáte.", + "Metadata": "Metadata", + "Metadata Preset": "Přednastavení metadat", + "Metadata Presets": "Přednastavení metadat", + "Metadata preset": "Přednastavení metadat", + "Metadata read": "Načtená metadata", + "Midnight": "Půlnoc", + "Midpoint": "Střed", + "Midtones": "Střední tóny", + "Midtones Hue": "Odstín středních tónů", + "Midtones Luminance": "Jas středních tónů", + "Midtones Saturation": "Sytost středních tónů", + "Midtones split": "Rozdělení středních tónů", + "Minimize": "Minimalizovat", + "Missing Photos": "Chybějící fotografie", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Chybějící metadata ({camera}, {title}…) zanechají prázdné místo; pokud vyjde celý název prázdný, zachová se původní název.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Chybějící fotografie se přesunou do nedávno odstraněných a lze je obnovit; soubory na disku se nemění.", + "Misty Morning": "Mlžné ráno", + "Mo": "Po", + "Mode": "Režim", + "Model": "Model", + "Models": "Modely", + "Modified Date": "Datum změny", + "Monday": "Pondělí", + "Monitor profile": "Profil monitoru", + "Mono Red Filter": "Černobílé s červeným filtrem", + "Mono Rich": "Syté černobílé", + "Mono Soft": "Jemný monochromatický", + "Monochrome": "Monochromatické", + "Month": "Měsíc", + "Moody": "Náladové", + "Moody Portrait": "Náladový portrét", + "More": "Další", + "More preset options": "Další možnosti přednastavení", + "Move": "Přesunout", + "Move Album": "Přesunout album", + "Move Down": "Přesunout dolů", + "Move Down in Stack": "Přesunout v sadě dolů", + "Move Folder": "Přesunout složku", + "Move Folder To…": "Přesunout složku do…", + "Move Mask": "Přesunout masku", + "Move Preset to Group": "Přesunout přednastavení do skupiny", + "Move Preset to New Group": "Přesunout přednastavení do nové skupiny", + "Move Selected Photos Here": "Přesunout vybrané fotografie sem", + "Move Sidebar Section": "Přesunout sekci postranního panelu", + "Move Up": "Přesunout nahoru", + "Move Up in Stack": "Přesunout v sadě nahoru", + "Move in Stack": "Přesunout v sadě", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Přesunout soubory (do Originals/ v knihovně nebo do zvolené složky) a odstranit je ze zdroje", + "Move them": "Přesunout je", + "Move to": "Přesunout do", + "Move to Folder": "Přesunout do složky", + "Move to Folder…": "Přesunout do složky…", + "Move to Group": "Přesunout do skupiny", + "Move to group": "Přemístit do skupiny", + "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.": "Přesunout: soubory se přesunou do složky níže; každý originál (i přidružený soubor XMP) se ze zdroje odstraní až po ověření kopie. Duplikáty a soubory, u kterých operace selže, zůstanou na místě.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Přesune všechny fotografie importované z tohoto disku do nedávno odstraněných; žádný soubor se nemění", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Přesune soubory a jejich přidružené XMP do této složky; nikdy nepřepisuje", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Přesune fotografie importované z této složky do nedávno odstraněných; žádný soubor se nemění", + "Muted": "Tlumené", + "Muted Film": "Tlumený film", + "My Photos": "Moje fotografie", + "My Photos Panel": "Panel mých fotografií", + "Name": "Název", + "Name (A–Z)": "Název (A–Z)", + "Name Face": "Pojmenovat obličej", + "Name Faces": "Pojmenovat obličeje", + "Name of the folder the original is in": "Název složky obsahující originál", + "Named": "Pojmenované", + "Named People": "Pojmenované osoby", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Názvy se zobrazují v nabídce štítků, liště filtrů a panelu Informace a zapisují se do XMP. Prázdné = název barvy.", + "Navigator": "Navigátor", + "Needs Work": "Vyžaduje úpravy", + "Neon Dusk": "Neonový soumrak", + "Neon Night": "Neonová noc", + "Nested group": "Vnořená skupina", + "Neutral": "Neutrální", + "Never": "Nikdy", + "New": "Nové", + "New Album": "Nové album", + "New Album…": "Nové album…", + "New Folder on Disk": "Nová složka na disku", + "New Folder on Disk…": "Nová složka na disku…", + "New Folder…": "Nová složka…", + "New Group…": "Nová skupina…", + "New Library": "Nová knihovna", + "New Library: Choose Where to Put It": "Nová knihovna: vyberte umístění", + "New Library…": "Nová knihovna…", + "New Smart Album": "Nové inteligentní album", + "New Smart Album from Filter…": "Nové inteligentní album z filtru…", + "New Smart Album…": "Nové inteligentní album…", + "New album": "Vytvořit nové album", + "New album…": "Vytvořit nové album…", + "New blank PDF": "Nové prázdné PDF", + "New name": "Nový název", + "New time": "Nový čas", + "New…": "Nový…", + "Next Appearance Mode": "Další režim vzhledu", + "Next Photo": "Další fotografie", + "Next up for AI Denoise…": "Další na řadě pro redukci šumu pomocí AI…", + "Night Blue": "Noční modř", + "No": "Ne", + "No Camera or Card Found": "Nenalezen fotoaparát ani karta", + "No Date Groups": "Žádné skupiny podle data", + "No Label": "Bez štítku", + "No Recent Libraries": "Žádné nedávné knihovny", + "No Stars": "Bez hvězdiček", + "No Tool": "Žádný nástroj", + "No albums match": "Žádné album neodpovídá", + "No albums yet": "Zatím žádná alba", + "No automatic versions.": "Žádné automatické verze.", + "No camera": "Žádný fotoaparát", + "No contributor data was built into this copy.": "Do této kopie nebyly vloženy údaje o přispěvatelích.", + "No date": "Bez data", + "No dust spots found": "Nenalezeny žádné prachové skvrny", + "No edits yet.": "Zatím žádné úpravy.", + "No exposure information": "Žádné informace o expozici", + "No filter presets yet": "Zatím žádná přednastavení filtrů", + "No folders to browse": "Žádné složky k procházení", + "No imported folders yet": "Zatím žádné importované složky", + "No keywords are exported": "Neexportují se žádná klíčová slova", + "No keywords match": "Žádné klíčové slovo neodpovídá", + "No keywords yet": "Zatím žádná klíčová slova", + "No lens data embedded in this file (DNG lens opcodes only).": "V tomto souboru nejsou vložena data objektivu (pouze operační kódy objektivu DNG).", + "No masks yet. Choose a mask type above.": "Zatím žádné masky. Vyberte typ masky nahoře.", + "No matching commands": "Žádné odpovídající příkazy", + "No matching photos": "Žádné odpovídající fotografie", + "No metadata found": "Nebyla nalezena metadata", + "No model credits were built into this copy.": "Do této kopie nebyly vloženy kredity modelů.", + "No named people in these photos": "Na těchto fotografiích nejsou pojmenované osoby", + "No named people yet": "Zatím žádné pojmenované osoby", + "No new presets": "Žádná nová přednastavení", + "No photo has it; the keywords below it go too.": "Není přiřazeno žádné fotografii; odstraní se i podřízená klíčová slova.", + "No photo selected": "Není vybrána fotografie", + "No photos": "Žádné fotografie", + "No photos found": "Nebyly nalezeny fotografie", + "No photos in this folder": "V této složce nejsou fotografie", + "No photos match the active filters": "Žádné fotografie neodpovídají aktivním filtrům", + "No photos match the filters (View → Clear Filters)": "Žádné fotografie neodpovídají filtrům (Zobrazení → Zrušit filtry)", + "No photos yet": "Zatím žádné fotografie", + "No raw photos yet: cameras appear here once their photos are in the library.": "Zatím žádné fotografie RAW: fotoaparáty se zde zobrazí, jakmile budou jejich fotografie v knihovně.", + "No reference photo": "Žádná referenční fotografie", + "No usable GPU found: rendering on the CPU": "Nebylo nalezeno použitelné GPU: vykreslování na CPU", + "No versions yet. Create one to keep this look.": "Zatím žádné verze. Vytvořením verze zachováte tento vzhled.", + "Noise": "Šum", + "Noise Reduction": "Redukce šumu", + "Non-raw photos": "Fotografie mimo RAW", + "None": "Žádné", + "None (the display is treated as sRGB)": "Žádný (displej se považuje za sRGB)", + "Not Now": "Teď ne", + "Not saved": "Neuloženo", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Dokud podmínky nepřijmete, nic se nestahuje. Model zůstane pouze na tomto počítači; LightCraft jej nikdy neodesílá ani nesdílí.", + "Nothing to compare": "Není co porovnat", + "Nothing to survey": "Není co prohlížet", + "Nudge Develop Value": "Posunout hodnotu vyvolání", + "OK": "OK", + "ONNX model": "Model ONNX", + "Object": "Objekt", + "Object Mask": "Maska objektu", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Masky Objekt a Popsat používají SAM 3, segmentační model společnosti Meta. Není součástí LightCraft a vše ostatní funguje i bez něj.", + "October 2026": "Říjen 2026", + "Off": "Vypnuto", + "Offset X": "Posun X", + "Offset Y": "Posun Y", + "Only": "Pouze", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vytvářejí se pouze fotografie s mírou redukce šumu pomocí AI nad 0. Při exportu se chybějící obraz vždy vytvoří.", + "Opacity": "Krytí", + "Opacity (%)": "Krytí (%)", + "Open Library": "Otevřít knihovnu", + "Open Library…": "Otevřít knihovnu…", + "Open Log Folder": "Otevřít složku protokolů", + "Open Recent Library": "Otevřít nedávnou knihovnu", + "Open in": "Otevřít v", + "Open in Detail": "Otevřít v detailu", + "Open page": "Otevřít stránku", + "Open the model's page": "Otevřít stránku modelu", + "Open the place in OpenStreetMap": "Otevřít místo v OpenStreetMap", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otevře vlastní stránku modelu v prohlížeči, kde si o něm můžete přečíst nebo sami stáhnout soubor.", + "Open…": "Otevřít…", + "Optics": "Optika", + "Optimize Library": "Optimalizovat knihovnu", + "Optional name": "Volitelný název", + "Options": "Možnosti", + "Orange": "Oranžová", + "Orange Hue": "Odstín oranžové", + "Orange Luminance": "Jas oranžové", + "Orange Saturation": "Sytost oranžové", + "Orientation": "Orientace", + "Original": "Originál", + "Original + Settings": "Originál a nastavení", + "Original extension without the dot": "Původní přípona bez tečky", + "Original file name without its extension": "Původní název souboru bez přípony", + "Original files": "Původní soubory", + "Original offline · editing the smart preview": "Originál není dostupný · úpravy inteligentního náhledu", + "Original offline · no smart preview": "Originál není dostupný · bez inteligentního náhledu", + "Originals": "Originály", + "Other editor": "Jiný editor", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Ostatní obrázky (JPEG, PNG, TIFF, HEIC…)", + "Other…": "Jiné…", + "Overlay": "Překrytí", + "Overwrite": "Přepsat", + "Pages": "Stránky", + "Paint": "Malovat", + "Paint only areas like the one under the brush": "Malovat pouze oblasti podobné té pod štětcem", + "Paint over a distraction on the photo to remove it.": "Přemalujte rušivý prvek na fotografii, aby byl odstraněn.", + "Paint overlay": "Překrytí malby", + "Panels": "Panely", + "Panorama": "Panorama", + "Panorama Merge": "Sloučit do panoramatu", + "Panorama Merge Preview": "Náhled sloučení do panoramatu", + "Panorama with Last Settings": "Panorama s posledním nastavením", + "Panoramas": "Panoramata", + "Panorama…": "Panorama…", + "Paper": "Papír", + "Parametric curve": "Parametrická křivka", + "Paste": "Vložit", + "Paste Edit Settings": "Vložit nastavení úprav", + "Paste Metadata": "Vložit metadata", + "Paste Selected Settings": "Vložit vybraná nastavení", + "Paste Selected Settings…": "Vložit vybraná nastavení…", + "Paste Settings": "Vložit nastavení", + "Paste Settings from Previous": "Vložit nastavení z předchozí fotografie", + "Paste from Previous": "Vložit z předchozí fotografie", + "Paste pages": "Vložit stránky", + "Pastel Haze": "Pastelový opar", + "Pending photo action cancelled": "Čekající akce s fotografií byla zrušena", + "Pending photo action cancelled because its library or photo changed": "Čekající akce byla zrušena, protože se změnila knihovna nebo fotografie", + "People": "Lidé", + "People Count": "Počet osob", + "Percent": "Procenta", + "Percentage": "Procentuálně", + "Perceptual": "Percepční", + "Performance": "Výkon", + "Person": "Osoba", + "Perspective": "Perspektiva", + "Pet Eye": "Oko zvířete", + "Pet Eye Catchlight": "Odlesk v oku zvířete", + "Photo": "Fotografie", + "Photo Grid": "Mřížka fotografií", + "Photo Grid (G)": "Mřížka fotografií (G)", + "Photo Merge": "Sloučení fotografií", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "Fotografie → Upravit v → Upravit v PhotoCraft (⇧⌘E nebo pravé tlačítko na fotografii) vykreslí kopii s vašimi úpravami, přidá ji do sady s originálem a otevře v PhotoCraft. Po uložení se kopie zde aktualizuje. Do PSD ukládá PhotoCraft přímo. Umístění ponechte prázdné, pokud nepoužíváte přenosnou kopii.", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Fotografie → Upravit v externím editoru (⇧⌘E) vykreslí 16bitovou kopii TIFF, přidá ji do sady s originálem a otevře v této aplikaci (název aplikace v macOS, jinde cesta k programu).", + "PhotoCraft": "PhotoCraft", + "Photos": "Fotografie", + "Photos are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "Fotografie se zobrazují přes ICC profil monitoru, aby byly barvy správné na kalibrovaných displejích i displejích se širokým gamutem. Detail využívá celý gamut displeje. Histogram, mezipaměti náhledů ani export se nemění.", + "Photos per bracket (0 = auto)": "Fotografií na expoziční řadu (0 = automaticky)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotografie prohlížené v místních souborech, které nebyly přidány ani změněny, se odeberou z katalogu, pokud se jejich složka po tuto dobu neprocházela (kontroluje se při otevření knihovny). Soubory zůstanou na disku; procházením složky se znovu zobrazí.", + "Photos stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "Fotografie zůstanou ve všech fotografiích. Odstraněním složky odstraníte i její alba. Akci lze vrátit zpět.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Fotografie přicházející do této složky (snímání s připojeným fotoaparátem, skener, synchronizace) se přidají, jakmile jsou úplné.", + "Pick": "Vybrat", + "Pick Flag": "Příznak Vybráno", + "Pick a colour on the photo with the eyedropper.": "Vyberte barvu na fotografii kapátkem.", + "Pick a date": "Vybrat datum", + "Pick the sharpest photo of each burst": "Vybrat nejostřejší fotografii z každé série", + "Picked": "Vybráno", + "Picks": "Vybrané snímky", + "Pictures": "Obrazy", + "Pixels": "Pixely", + "Place Names Status": "Stav názvů míst", + "Place names download cancelled": "Stahování názvů míst bylo zrušeno", + "Place names downloaded": "Názvy míst byly staženy", + "Point Color": "Bodová barva", + "Point Curve": "Bodová křivka", + "Point Curve Presets": "Přednastavení bodové křivky", + "Point curve": "Křivka s body", + "Portrait": "Portrét", + "Portrait (tall)": "Na výšku", + "Position": "Pozice", + "Preferences": "Předvolby", + "Preferences…": "Předvolby…", + "Prepare AI Masks": "Připravit masky pomocí AI", + "Preset": "Přednastavení", + "Preset name": "Název přednastavení", + "Presets": "Přednastavení", + "Presets & Profiles": "Přednastavení a profily", + "Presets (Shift+P)": "Přednastavení (Shift+P)", + "Presets ▾": "Přednastavení ↓", + "Press keys…": "Stiskněte klávesy…", + "Preview": "Náhled", + "Preview Build Progress": "Průběh vytváření náhledů", + "Preview only": "Pouze náhled", + "Preview only: editing the camera's embedded JPEG": "Pouze náhled: úpravy vloženého JPEG z fotoaparátu", + "Preview size": "Velikost náhledu", + "Preview unavailable": "Náhled není dostupný", + "Previews": "Náhledy", + "Previous Photo": "Předchozí fotografie", + "Primaries (x, y)": "Primární barvy (x, y)", + "Print": "Tisk", + "Print…": "Tisk…", + "Processor": "Procesor", + "Profile": "Profil", + "Profile Browser": "Prohlížeč profilů", + "Profile Distortion": "Zkreslení podle profilu", + "Profile Menu": "Nabídka profilů", + "Profile Vignetting": "Vinětace podle profilu", + "Profile:": "Profil:", + "Profiles": "Profily", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "Profily přiřazené v nastavení barev vašeho prostředí obvykle najdete v ~/.local/share/icc.", + "Progressive": "Progresivní", + "Projection": "Projekce", + "Proof copy created": "Kopie pro měkký nátisk byla vytvořena", + "Properties": "Vlastnosti", + "Public Domain": "Volné dílo", + "Public domain": "Volné dílo", + "Pupil Size": "Velikost zornice", + "Purge Unused Keywords": "Odstranit nepoužívaná klíčová slova", + "Purple": "Fialová", + "Purple Amount": "Míra fialové", + "Purple Hue": "Odstín fialové", + "Purple Hue High": "Horní mez odstínu fialové", + "Purple Hue Low": "Dolní mez odstínu fialové", + "Purple Luminance": "Jas fialové", + "Purple Saturation": "Sytost fialové", + "Put New Keywords Inside This Keyword": "Vkládat nová klíčová slova pod toto klíčové slovo", + "Put the locations you removed from Local back (no files change)": "Vrátit místa odebraná z místních souborů (žádné soubory se nemění)", + "Quality": "Kvalita", + "Query Photos": "Vyhledat fotografie dotazem", + "Quick Collection": "Rychlá kolekce", + "Quick Develop": "Rychlé vyvolání", + "Quiet Green": "Klidná zeleň", + "Quit": "Ukončit", + "Quit Anyway": "Přesto ukončit", + "Quit LightCraft": "Ukončit LightCraft", + "Quit while tasks are running?": "Ukončit aplikaci při běžících úlohách?", + "Quit with unsaved changes?": "Ukončit aplikaci s neuloženými změnami?", + "Radial": "Kruhový", + "Radial Gradient": "Kruhový přechod", + "Radius": "Poloměr", + "Random": "Náhodně", + "Range": "Rozsah", + "Rate ★": "Ohodnotit ★", + "Rate ★ and advance": "Ohodnotit ★ a přejít dál", + "Rate ★★": "Ohodnotit ★★", + "Rate ★★ and advance": "Ohodnotit ★★ a přejít dál", + "Rate ★★★": "Ohodnotit ★★★", + "Rate ★★★ and advance": "Ohodnotit ★★★ a přejít dál", + "Rate ★★★★": "Ohodnotit ★★★★", + "Rate ★★★★ and advance": "Ohodnotit ★★★★ a přejít dál", + "Rate ★★★★★": "Ohodnotit ★★★★★", + "Rate ★★★★★ and advance": "Ohodnotit ★★★★★ a přejít dál", + "Rating": "Hodnocení", + "Rating cleared": "Hodnocení bylo zrušeno", + "Ratings & flags": "Hodnocení a příznaky", + "Raw": "RAW", + "Raw default": "Výchozí nastavení RAW", + "Raw defaults": "Výchozí nastavení souborů RAW", + "Raw files": "Soubory RAW", + "Raw photos": "Fotografie RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Fotografie RAW jako DNG s vloženými úpravami. Volby velikosti, barev a výstupu se nepoužijí.", + "Read Metadata from File": "Načíst metadata ze souboru", + "Read metadata updates from XMP sidecars": "Načíst změny metadat z přidružených souborů XMP", + "Read mode": "Režim čtení", + "Read the SAM License": "Přečíst licenci SAM", + "Reading": "Načítání", + "Reading Lightroom catalog": "Načítání katalogu Lightroom", + "Reading Lightroom catalog…": "Načítání katalogu Lightroom…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Načtení přidruženého souboru nahradí hodnocení, štítky, metadata a úpravy fotografie údaji z tohoto souboru.", + "Reading folder": "Načítání složky", + "Reading folder…": "Načítání složky…", + "Real name": "Skutečné jméno", + "Recent": "Nedávné", + "Recent Keywords": "Nedávná klíčová slova", + "Recent Keywords is built in: choose another name": "Nedávná klíčová slova je vestavěná sada: zvolte jiný název", + "Recent Libraries": "Nedávné knihovny", + "Recently Added": "Nedávno přidané", + "Recently Deleted": "Nedávno odstraněné", + "Recognise faces (experimental)": "Rozpoznávat obličeje (experimentální)", + "Recognition models": "Modely rozpoznávání", + "Red": "Červená", + "Red Eye": "Červené oči", + "Red Hue": "Odstín červené", + "Red Luminance": "Jas červené", + "Red Primary": "Primární červená", + "Red Saturation": "Sytost červené", + "Red channel": "Červený kanál", + "Red label": "Červený štítek", + "Red/Cyan Fringe": "Červený/azurový lem", + "Redo": "Znovu", + "Reference": "Reference", + "Reference View": "Referenční zobrazení", + "Reference the files where they are": "Odkazovat na soubory v jejich umístění", + "Refine": "Zpřesnit", + "Refine AI Mask Detail": "Zpřesnit detaily masky AI", + "Refine Edges": "Zpřesnit okraje", + "Refine Mask Edges": "Zpřesnit okraje masky", + "Refine Saturation": "Zpřesnit sytost", + "Refining the mask's detail…": "Zpřesňování detailů masky…", + "Refresh Source": "Obnovit zdroj", + "Refresh Source (/)": "Obnovit zdroj (/)", + "Reject": "Odmítnout", + "Reject below focus": "Odmítnout pod limitem zaostření", + "Rejected": "Odmítnuto", + "Rejects": "Odmítnuté snímky", + "Relative": "Relativní", + "Relink Photo": "Znovu propojit fotografii", + "Relink photos whose file was renamed or moved": "Znovu propojit fotografie s přejmenovaným nebo přesunutým souborem", + "Relinked": "Znovu propojeno", + "Reload Changed Files": "Znovu načíst změněné soubory", + "Reload from Disk": "Znovu načíst z disku", + "Remove": "Odebrat", + "Remove (H)": "Odstranit (H)", + "Remove CA": "Odstranit chromatickou aberaci", + "Remove Chromatic Aberration": "Odstranit chromatickou aberaci", + "Remove Denoise Model": "Odebrat model pro redukci šumu", + "Remove Disk from Library": "Odebrat disk z knihovny", + "Remove Disk from Library…": "Odebrat disk z knihovny…", + "Remove Face Box": "Odebrat rámeček obličeje", + "Remove Face Model": "Odebrat model rozpoznávání obličejů", + "Remove Folder from Library": "Odebrat složku z knihovny", + "Remove a filter above, or choose Clear all": "Odeberte filtr nahoře nebo zvolte Vymazat vše", + "Remove from Album": "Odebrat z alba", + "Remove from Favorites": "Odebrat z oblíbených", + "Remove from Library…": "Odebrat z knihovny…", + "Remove from Local": "Odebrat z místních souborů", + "Remove from Photo": "Odebrat z fotografie", + "Remove from Selected Photos": "Odebrat z vybraných fotografií", + "Remove from Stack": "Odebrat ze sady", + "Remove from Target Album": "Odebrat z cílového alba", + "Remove from favorites": "Odebrat z oblíbených", + "Remove from survey": "Odebrat z přehledu", + "Remove location info": "Odebrat informace o poloze", + "Remove missing photos from the library": "Odebrat chybějící fotografie z knihovny", + "Remove shortcut": "Odebrat zkratku", + "Remove this face box (undo with Edit ▸ Undo)": "Odebrat tento rámeček obličeje (vrátit přes Úpravy → Zpět)", + "Remove this filter": "Odebrat tento filtr", + "Remove this group": "Odebrat tuto skupinu", + "Remove this rule": "Odebrat toto pravidlo", + "Removed": "Odebráno", + "Rename": "Přejmenovat", + "Rename Album": "Přejmenovat album", + "Rename Component": "Přejmenovat součást", + "Rename Folder": "Přejmenovat složku", + "Rename Folder…": "Přejmenovat složku…", + "Rename Keyword": "Přejmenovat klíčové slovo", + "Rename Keyword…": "Přejmenovat klíčové slovo…", + "Rename Mask": "Přejmenovat masku", + "Rename Photos": "Přejmenovat fotografie", + "Rename Photos…": "Přejmenovat fotografie…", + "Rename Preset": "Přejmenovat přednastavení", + "Rename Preview": "Náhled přejmenování", + "Rename Template Tags": "Značky šablony přejmenování", + "Rename Version": "Přejmenovat verzi", + "Rename…": "Přejmenovat…", + "Rendering": "Vykreslování", + "Rendering…": "Vykreslování…", + "Reorder Album": "Změnit pořadí alba", + "Reorder Masks": "Změnit pořadí masek", + "Reset": "Obnovit", + "Reset All": "Obnovit vše", + "Reset All Curves": "Obnovit všechny křivky", + "Reset All Keyboard Shortcuts": "Obnovit všechny klávesové zkratky", + "Reset Before to Import State": "Obnovit Před do stavu při importu", + "Reset Control": "Obnovit ovládací prvek", + "Reset Crop": "Obnovit oříznutí", + "Reset Edits": "Obnovit úpravy", + "Reset Panel Layout": "Obnovit rozložení panelů", + "Reset Parametric Curve": "Obnovit parametrickou křivku", + "Reset Section": "Obnovit sekci", + "Reset Sidebar Sections": "Obnovit sekce postranního panelu", + "Reset Tone Curve": "Obnovit tónovou křivku", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Obnoví všechny křivky: bodové (všechny kanály) i parametrickou (dvojklik na kanál obnoví pouze tento kanál)", + "Reset all edits (Cmd+Shift+R)": "Obnovit všechny úpravy (Cmd+Shift+R)", + "Reshuffle": "Znovu promíchat", + "Resize Face Box": "Změnit velikost rámečku obličeje", + "Resize panel window": "Změnit velikost okna panelu", + "Resize panels": "Změnit velikost panelů", + "Resize to": "Změnit velikost na", + "Resolution (ppi)": "Rozlišení (ppi)", + "Restore": "Obnovit", + "Restore (no shortcut)": "Obnovit (bez zkratky)", + "Restore Library from Backup…": "Obnovit knihovnu ze zálohy…", + "Restore Version": "Obnovit verzi", + "Restore keeps their edits; Import as new deletes the old record first.": "Obnovení zachová úpravy; import jako nové nejprve odstraní starý záznam.", + "Restore them": "Obnovit je", + "Retouch": "Retušování", + "Reverse the order": "Obrátit pořadí", + "Revert": "Vrátit změny", + "Review": "Zkontrolovat", + "Review Import": "Zkontrolovat import", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Pravé tlačítko na fotografii → Nastavit jako referenční fotografii, pak Zobrazení → Referenční zobrazení (Shift+R)", + "Right-click a preset to delete it": "Přednastavení odstraníte kliknutím pravým tlačítkem", + "Rights Usage Terms": "Podmínky použití práv", + "Rotate": "Otočit", + "Rotate Crop": "Otočit ořez", + "Rotate Crop Aspect": "Otočit poměr stran ořezu", + "Rotate Left": "Otočit doleva", + "Rotate Right": "Otočit doprava", + "Roughness": "Drsnost", + "Roundness": "Kulatost", + "Rules": "Pravidla", + "SAM 3 download stopped: it resumes where it left off next time.": "Stahování SAM 3 bylo zastaveno: příště naváže tam, kde skončilo.", + "SDR": "SDR", + "Sa": "So", + "Same as raw default": "Stejné jako výchozí nastavení RAW", + "Sample Mask Color": "Odebrat vzorek barvy masky", + "Sample a colour on the photo": "Vyberte vzorek barvy na fotografii", + "Saturation": "Sytost", + "Saturation Range": "Rozsah sytosti", + "Saturday": "Sobota", + "Save": "Uložit", + "Save As…": "Uložit jako…", + "Save Color Label Set": "Uložit sadu barevných štítků", + "Save Current Filter as Preset…": "Uložit aktuální filtr jako přednastavení…", + "Save Current Keywords as Set…": "Uložit aktuální klíčová slova jako sadu…", + "Save Export Preset": "Uložit přednastavení exportu", + "Save Filter Preset": "Uložit přednastavení filtru", + "Save Keyword Set": "Uložit sadu klíčových slov", + "Save Metadata Preset": "Uložit přednastavení metadat", + "Save Metadata Preset…": "Uložit přednastavení metadat…", + "Save Metadata to File": "Uložit metadata do souboru", + "Save Point Curve Preset": "Uložit přednastavení bodové křivky", + "Save Point Curve…": "Uložit bodovou křivku…", + "Save as Preset": "Uložit jako přednastavení", + "Save as Set…": "Uložit jako sadu…", + "Save as Smart Album…": "Uložit jako inteligentní album…", + "Save as a new set": "Uložit jako novou sadu", + "Save as set": "Uložit jako sadu", + "Save as…": "Uložit jako…", + "Save preset": "Uložit přednastavení", + "Saving Lightroom import index…": "Ukládání indexu importu z Lightroom…", + "Scale": "Měřítko", + "Scan failed": "Prohledávání selhalo", + "Scanning": "Prohledávání", + "Scanning folder": "Prohledávání složky", + "Scanning…": "Prohledávání…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Skóre zůstávají u fotografií: můžete filtrovat nebo tvořit inteligentní alba podle zaostření a nejlepších z podobných snímků.", + "Screen": "Obrazovka", + "Scroll menu down": "Posunout nabídku dolů", + "Scroll menu up": "Posunout nabídku nahoru", + "Search": "Hledat", + "Search Photos": "Hledat fotografie", + "Search albums": "Hledat alba", + "Search commands or keys": "Hledat příkazy nebo klávesy", + "Search…": "Hledat…", + "Seasons": "Roční období", + "Second Window": "Druhé okno", + "Select": "Vybrat", + "Select All": "Vybrat vše", + "Select Luminance Range": "Vybrat rozsah jasu", + "Select Mask": "Vybrat masku", + "Select Photos": "Vybrat fotografie", + "Select Spot": "Vybrat retušovanou oblast", + "Select a photo to edit": "Vyberte fotografii k úpravám", + "Select by": "Vybrat podle", + "Select by Flag, Rating or Label": "Vybrat podle příznaku, hodnocení nebo štítku", + "Select photos to give them keywords.": "Vyberte fotografie, kterým chcete přiřadit klíčová slova.", + "Select photos, then choose View → Survey (N)": "Vyberte fotografie a zvolte Zobrazení → Přehled (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Vyberte dvě fotografie a zvolte Zobrazení → Porovnat (Shift+C)", + "Selected": "Vybrané", + "Selecting…": "Vybírání…", + "Selenium Tone": "Selenové tónování", + "Send Feedback…": "Odeslat zpětnou vazbu…", + "Separated by commas": "Oddělené čárkami", + "Sepia Tone": "Sépiové tónování", + "Sequence number zero-padded to N digits (1–9)": "Pořadové číslo doplněné nulami na N číslic (1–9)", + "Sequence number, counting from the start number": "Pořadové číslo od počáteční hodnoty", + "Services": "Služby", + "Set": "Nastavit", + "Set Album Cover": "Nastavit obálku alba", + "Set Before": "Nastavit Před", + "Set Color Label": "Nastavit barevný štítek", + "Set Crop": "Nastavit oříznutí", + "Set Develop Values": "Nastavit hodnoty vyvolání", + "Set Flag": "Nastavit příznak", + "Set Image Zoom and Pan": "Nastavit přiblížení a posun obrázku", + "Set Keyboard Shortcut": "Nastavit klávesovou zkratku", + "Set Mask Adjustments": "Nastavit úpravy masky", + "Set Point Curve": "Nastavit bodovou křivku", + "Set Profile": "Nastavit profil", + "Set Rating": "Nastavit hodnocení", + "Set Top of Stack": "Nastavit vrchní fotografii sady", + "Set as Album Cover": "Nastavit jako obálku alba", + "Set as Before": "Nastavit jako Před", + "Set as Reference Photo": "Nastavit jako referenční fotografii", + "Set as Target Album": "Nastavit jako cílové album", + "Set as Target Album (B adds to it)": "Nastavit jako cílové album (B do něj přidává)", + "Set as Top of Stack": "Nastavit jako vrchní fotografii sady", + "Set date & time": "Nastavit datum a čas", + "Set name": "Název sady", + "Set the mix from the photo's colours": "Nastavit směs podle barev fotografie", + "Set up AI Denoise…": "Nastavit redukci šumu pomocí AI…", + "Settings": "Nastavení", + "Settings file problem": "Problém souboru nastavení", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Výchozí nastavení nových fotografií RAW. Jejich změna neovlivní fotografie, které již jsou v knihovně.", + "Settings pasted": "Nastavení bylo vloženo", + "Settings to include": "Zahrnout nastavení", + "Settings…": "Nastavení…", + "Shade": "Ve stínu", + "Shadow": "Stín", + "Shadows": "Stíny", + "Shadows Hue": "Odstín stínů", + "Shadows Luminance": "Jas stínů", + "Shadows Saturation": "Sytost stínů", + "Shadows Tint": "Zabarvení stínů", + "Shadows split": "Rozdělení stínů", + "Sharpen": "Doostřit", + "Sharpening": "Doostření", + "Sharpness": "Ostrost", + "Shift": "Posun", + "Shift by": "Posunout o", + "Short Edge": "Kratší hrana", + "Show": "Zobrazit", + "Show All": "Zobrazit vše", + "Show Clipping": "Zobrazit ořezání tónů", + "Show Deghost Overlay": "Zobrazit překrytí odstranění duchů", + "Show Hidden Local Locations": "Zobrazit skrytá místní umístění", + "Show Luminance Map": "Zobrazit mapu jasu", + "Show Mask": "Zobrazit masku", + "Show Mask Overlay": "Zobrazit překrytí masky", + "Show All Mask Overlays": "Zobrazit všechna překrytí masek", + "Show all overlays": "Zobrazit všechna překrytí", + "Show Mask Pins": "Zobrazit body masek", + "Show Original": "Zobrazit originál", + "Show Person": "Zobrazit osobu", + "Show Photo Counts": "Zobrazit počty fotografií", + "Show Photos with Keyword": "Zobrazit fotografie s klíčovým slovem", + "Show Pins": "Zobrazit body", + "Show Sidebar Section": "Zobrazit sekci postranního panelu", + "Show Source": "Zobrazit zdroj", + "Show Thumbnails": "Zobrazit miniatury", + "Show commands without a shortcut": "Zobrazit příkazy bez zkratky", + "Show destination gamut warning": "Zobrazit varování gamutu cíle", + "Show display gamut warning": "Zobrazit varování gamutu displeje", + "Show file names": "Zobrazit názvy souborů", + "Show in Explorer": "Zobrazit v Průzkumníku", + "Show in File Manager": "Zobrazit ve správci souborů", + "Show in Finder": "Zobrazit ve Finderu", + "Show less": "Zobrazit méně", + "Show on Map": "Zobrazit na mapě", + "Show overlay (O)": "Zobrazit překrytí (O)", + "Show ratings, flags and edit badges": "Zobrazit hodnocení, příznaky a odznaky úprav", + "Show the Navigator while zoomed in": "Zobrazit navigátor při přiblížení", + "Show the template tags; click one to insert it": "Zobrazit značky šablony; kliknutím jednu vložíte", + "Show/Hide Mask": "Zobrazit/Skrýt masku", + "Show/hide My Photos panel": "Zobrazit/Skrýt panel mých fotografií", + "Shutter Speed": "Čas závěrky", + "Shutter speed": "Expoziční čas", + "Side panels": "Boční panely", + "Sidebar Sections": "Sekce postranního panelu", + "Sidecar names": "Názvy přidružených souborů", + "Silver": "Stříbrné", + "Simulate Paper & Ink": "Simulovat papír a inkoust", + "Size": "Velikost", + "Size (% of short edge)": "Velikost (% kratší hrany)", + "Size limit": "Limit velikosti", + "Skip": "Přeskočit", + "Skipped: files already in the library elsewhere": "Přeskočeno: soubory již v knihovně na jiném místě", + "Skipped: files that can't be read": "Přeskočeno: soubory, které nelze načíst", + "Sky": "Obloha", + "Slideshow": "Prezentace", + "Slideshow paused": "Prezentace byla pozastavena", + "Slideshow resumed": "Prezentace pokračuje", + "Slideshow · Space pauses · Esc ends": "Prezentace · mezerník pozastaví · Esc ukončí", + "Smart Album": "Inteligentní album", + "Smart Album Rule Fields": "Pole pravidel inteligentního alba", + "Smart Preview Status": "Stav inteligentních náhledů", + "Smart Previews Location": "Umístění inteligentních náhledů", + "Smart preview available": "Inteligentní náhled je dostupný", + "Smart previews": "Inteligentní náhledy", + "Smart previews folder changed": "Složka inteligentních náhledů byla změněna", + "Smoothness": "Plynulost", + "Soft B&W": "Jemné černobílé", + "Soft Pastel": "Jemné pastelové", + "Soft Proofing": "Měkký nátisk", + "Soft Skin": "Jemná pleť", + "Some rules need fixing (Edit Smart Album…):": "Některá pravidla je třeba opravit (Upravit inteligentní album…):", + "Sort": "Seřadit", + "Sort Albums A–Z": "Seřadit alba A–Z", + "Sort Albums by Name": "Seřadit alba podle názvu", + "Sort Contents A–Z": "Seřadit obsah A–Z", + "Sort by": "Řadit podle", + "Source": "Zdroj", + "Source code on GitHub": "Zdrojový kód na GitHubu", + "Speed": "Rychlost", + "Spherical": "Sférická", + "Spiral": "Spirála", + "Split Stack": "Rozdělit sadu", + "Spring Fresh": "Jarní svěžest", + "Square": "Čtverec", + "Square Grid": "Čtvercová mřížka", + "Square Grid (G toggles)": "Čtvercová mřížka (G přepíná)", + "Square Grid: show file names and formats": "Čtvercová mřížka: zobrazit názvy souborů a formáty", + "Stack": "Sada", + "Stack photos taken within this time of each other:": "Seskupit fotografie pořízené v tomto časovém odstupu:", + "Stacks": "Sady", + "Standard": "Standardní", + "Star rating (0–5)": "Hodnocení hvězdičkami (0–5)", + "Start at": "Začít od", + "Start number": "Počáteční číslo", + "Starting": "Spouštění", + "Starting…": "Spouštění…", + "Startup": "Spuštění", + "State / Province": "Kraj / provincie", + "Stop": "Zastavit", + "Stop Building Previews": "Zastavit vytváření náhledů", + "Stop Using as Target Album": "Přestat používat jako cílové album", + "Stopped": "Zastaveno", + "Stopped by an error (see the log)": "Zastaveno kvůli chybě (viz protokol)", + "Stopping…": "Zastavování…", + "Straighten": "Narovnat", + "Straighten Tool": "Nástroj narovnání", + "Strong Contrast": "Silný kontrast", + "Style": "Styl", + "Su": "Ne", + "Subfolder": "Podsložka", + "Subfolder name": "Název podsložky", + "Subject": "Subjekt", + "Subtract": "Odečíst", + "Subtract from this mask": "Odečíst z této masky", + "Suggest Names for Faces": "Navrhnout jména obličejů", + "Suggestions": "Návrhy", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Navrhuje osoby na fotografii podle obličejů, které jste pojmenovali. Vše zůstává na vašem počítači.", + "Summer Haze": "Letní opar", + "Sunday": "Neděle", + "Survey": "Přehled", + "Survey (N)": "Přehled (N)", + "Swap": "Prohodit", + "Swap Aspect (X)": "Prohodit poměr stran (X)", + "Swap Before and After": "Prohodit Před a Po", + "Swap Before and After Settings": "Prohodit nastavení Před a Po", + "Swap Compare Photos": "Prohodit porovnávané fotografie", + "Switch light / dark theme": "Přepnout světlý/tmavý motiv", + "Sync Settings": "Synchronizovat nastavení", + "Sync with System": "Synchronizovat se systémem", + "Synchronize": "Synchronizovat", + "Synchronize Folder": "Synchronizovat složku", + "Synchronize Folder…": "Synchronizovat složku…", + "Synchronizing folder": "Synchronizace složky", + "Synchronizing…": "Synchronizace…", + "Synonyms": "Synonyma", + "System": "Systém", + "System Info": "Informace o systému", + "System Info…": "Informace o systému…", + "System default": "Výchozí systémové nastavení", + "Table": "Tabulka", + "Tags": "Značky", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Značky: viz Značky vedle šablony. Soubory se přejmenují na disku (s přidruženými XMP); existující názvy získají -1, -2…", + "Take a snapshot": "Pořídit snímek stavu", + "Targeted Adjustment": "Cílená úprava", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Cílená úprava: tažením nahoru/dolů po fotografii upravíte barvy v daném místě", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Cílená úprava: tažením nahoru/dolů po fotografii upravíte křivku v daném místě", + "Teal & Amber": "Petrolejová a jantarová", + "Teal & Orange": "Petrolejová a oranžová", + "Temp": "Teplota", + "Temperature": "Teplota", + "Template": "Šablona", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Dočasná relace — vaše knihovna není otevřená a nic, co zde uděláte, se neukládá.", + "Test Denoise Model": "Otestovat model pro redukci šumu", + "Test Face Model": "Otestovat model rozpoznávání obličejů", + "Tested and works on this computer": "Otestováno a funkční na tomto počítači", + "Text": "Text", + "Text direction": "Směr textu", + "Texture": "Textura", + "Th": "Čt", + "That includes the photos in the folders inside it.": "Zahrnuje i fotografie v podsložkách.", + "That key can't be used as a shortcut": "Tuto klávesu nelze použít jako zkratku", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 je nainstalovaný: masky Objekt a Popsat jsou připravené.", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model pro redukci šumu je nainstalovaný a používá se: posuvník Míra redukce šumu pomocí AI v Detailu nyní funguje", + "The download didn't work:": "Stažení se nezdařilo:", + "The first time, macOS asks whether LightCraft may control Photos.": "Při prvním použití se macOS zeptá, zda LightCraft smí ovládat Photos.", + "The graphics card is set up when the next photo is made.": "Grafická karta se nastaví při zpracování další fotografie.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Import načte katalog bez jeho změny a přidá fotografie, úpravy, klíčová slova a kolekce do právě otevřené knihovny.", + "The library is up to date with this folder.": "Knihovna odpovídá aktuálnímu obsahu této složky.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model zůstane pouze na tomto počítači. LightCraft jej nikdy neodesílá ani nesdílí.", + "The number at the end of the original name (IMG_0042 → 0042)": "Číslo na konci původního názvu (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Původní přípona se vždy přidá; {ext} ji navíc vloží i do názvu.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Původní soubory beze změny, každý s přidruženým XMP obsahujícím úpravy.", + "The photo has these settings": "Fotografie má tato nastavení", + "The processor does the work.": "Zpracování provádí procesor.", + "Their photos and the keywords below them come together under one keyword.": "Jejich fotografie i podřízená klíčová slova se spojí pod jedním klíčovým slovem.", + "Theme": "Motiv", + "There is no model file to install": "Není k dispozici soubor modelu k instalaci", + "They can be restored from Recently Deleted until it is emptied.": "Lze je obnovit z nedávno odstraněných, dokud se tato sekce nevyprázdní.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Přesunou se do nedávno odstraněných a lze je obnovit; soubory na disku se nemění.", + "Thirds": "Třetiny", + "This Computer": "Tento počítač", + "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.": "Toto sestavení zatím neumí model stáhnout. Pro ruční instalaci vložte model.safetensors, vocab.json a merges.txt z facebook/sam3 společnosti Meta do složky výše: Objekt a Popsat budou fungovat, jakmile tam soubory budou.", + "This build cannot run denoise models.": "Toto sestavení neumí spouštět modely pro redukci šumu.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Toto sestavení neumí spouštět modely rozpoznávání: lze je přidat a vybrat, ale nelze je použít.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Toto sestavení nemá kam ukládat modely pro redukci šumu, takže redukce šumu pomocí AI není dostupná. (Desktopová aplikace tuto možnost má.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Toto sestavení nemá kam ukládat modely obličejů, takže seznam modelů není dostupný. (Desktopová aplikace tuto možnost má.)", + "This file cannot be used yet": "Tento soubor zatím nelze použít", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Tato složka není dostupná (odpojený disk?). Inteligentní náhledy se nevytvářejí ani nepoužívají, dokud nebude znovu dostupná nebo nezvolíte jinou složku.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Tato složka neexistuje: byla přesunuta či přejmenována nebo její disk není připojený.", + "This group is empty: add a rule or remove it.": "Tato skupina je prázdná: přidejte pravidlo nebo ji odeberte.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Toto je katalog Lightroom, nikoli knihovna LightCraft. Chcete jej místo toho importovat do knihovny LightCraft?", + "This is the album you're editing": "Toto je album, které právě upravujete", + "This photo can't be opened": "Tuto fotografii nelze otevřít", + "Threshold": "Práh", + "Thumbnail cache": "Mezipaměť miniatur", + "Thursday": "Čtvrtek", + "Tick the box to accept the model's terms first": "Nejprve zaškrtnutím přijměte podmínky modelu", + "Time zone": "Časové pásmo", + "Tint": "Odstín", + "Title": "Titul", + "Title metadata": "Metadata titulu", + "Toggle Keyword from Set": "Přepnout klíčové slovo ze sady", + "Toggle Mask": "Přepnout masku", + "Toggle Section": "Přepnout sekci", + "Toggle Zoom": "Přepnout přiblížení", + "Tone Curve": "Tónová křivka", + "Tools": "Nástroje", + "Top Level": "Nejvyšší úroveň", + "Transfer": "Přenos", + "Treatment": "Zpracování", + "Triangle": "Trojúhelník", + "Try Again": "Zkusit znovu", + "Try Saving Again": "Zkusit uložit znovu", + "Try again": "Opakovat pokus", + "Try the graphics card again": "Znovu zkusit grafickou kartu", + "Tu": "Út", + "Tuesday": "Úterý", + "Tungsten": "Žárovka", + "Turn Off": "Vypnout", + "Turn off": "Vypnout", + "Turn on Include subfolders, or pick another folder under Local": "Zapněte Zahrnout podsložky nebo vyberte jinou složku v místních souborech", + "Type": "Typ", + "Uncheck All": "Zrušit zaškrtnutí všeho", + "Uncheck the files of this type": "Zrušit zaškrtnutí souborů tohoto typu", + "Undo": "Zpět", + "Unedited": "Bez úprav", + "Unedited raws": "Neupravené soubory RAW", + "Unflag": "Zrušit příznak", + "Unflagged": "Bez příznaku", + "Ungroup Stack": "Zrušit seskupení sady", + "Unknown": "Neznámé", + "Unknown Date": "Neznámé datum", + "Unknown licence": "Neznámá licence", + "Unknown tags stay as typed — check the preview for a {typo}.": "Neznámé značky zůstanou tak, jak jsou napsané — zkontrolujte náhled, zda neobsahuje {typo}.", + "Unnamed Faces": "Nepojmenované obličeje", + "Unrated": "Bez hodnocení", + "Unreadable": "Nečitelné", + "Update": "Aktualizovat", + "Update Mask Shape": "Aktualizovat tvar masky", + "Update Preset with Current Settings": "Aktualizovat přednastavení podle aktuálního nastavení", + "Update Rules from Current Filter": "Aktualizovat pravidla podle aktuálního filtru", + "Update Version": "Aktualizovat verzi", + "Update Version with Current Settings": "Aktualizovat verzi podle aktuálního nastavení", + "Update with Current Settings": "Aktualizovat podle aktuálního nastavení", + "Updated the edits saved in the external editor": "Úpravy uložené v externím editoru byly aktualizovány", + "Upright": "Narovnání perspektivy", + "Urban": "Městská krajina", + "Use": "Použít", + "Use Keyword Set": "Použít sadu klíčových slov", + "Use Library Folder": "Použít složku knihovny", + "Use Screen Profile": "Použít profil obrazovky", + "Use camera-specific defaults": "Použít výchozí nastavení pro konkrétní fotoaparát", + "Use sRGB": "Použít sRGB", + "Use the GPU for rendering": "Použít GPU pro vykreslování", + "User Presets": "Uživatelská přednastavení", + "Username": "Uživatelské jméno", + "Value": "Hodnota", + "Variance": "Rozptyl", + "Version": "Verze", + "Version name": "Název verze", + "Versions": "Verze", + "Versions (Shift+V)": "Verze (Shift+V)", + "Vertical": "Svisle", + "Vertical inset (% of short edge)": "Svislé odsazení (% kratší hrany)", + "Vertical text": "Svislý text", + "Vibrance": "Živost", + "Video": "Video", + "Video Duration": "Délka videa", + "Videos": "Videa", + "View": "Zobrazení", + "Vignette": "Viněta", + "Vignetting": "Vinětace", + "Vintage": "Retro", + "Virtual Copy": "Virtuální kopie", + "Visible": "Viditelné", + "Visualize Spots": "Zvýraznit retušované oblasti", + "Visualize Spots (A)": "Zvýraznit retušované oblasti (A)", + "Visualize range": "Zobrazit rozsah", + "Vivid": "Živé", + "Vivid Pop": "Živé zvýraznění", + "Wait for the import to finish": "Počkejte na dokončení importu", + "Waiting to start…": "Čekání na spuštění…", + "Warm Film": "Teplý film", + "Warm Glow": "Teplá záře", + "Warm Print": "Teplý tisk", + "Warm Table": "Teplý stůl", + "Watched folder": "Sledovaná složka", + "Watermark": "Vodoznak", + "We": "St", + "Wednesday": "Středa", + "What's New": "Co je nového", + "When rated or hovered": "Při hodnocení nebo najetí myší", + "White Balance": "Vyvážení bílé", + "White Balance Selector": "Kapátko vyvážení bílé", + "White Balance Selector (W)": "Kapátko vyvážení bílé (W)", + "Whites": "Bílé", + "Width": "Šířka", + "Width (% of photo)": "Šířka (% fotografie)", + "Width (px)": "Šířka (px)", + "Width × Height": "Šířka × výška", + "Will Export": "Bude exportováno", + "Will be replaced": "Bude nahrazeno", + "Will be restored": "Bude obnoveno", + "Window": "Okno", + "Winter Blue": "Zimní modř", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funguje se soubory RAW z fotoaparátů se standardním snímačem (Bayer). Ostatní soubory si zachovají běžnou redukci šumu.", + "XMP Sidecar Preferences": "Předvolby přidružených souborů XMP", + "XMP sidecars": "Přidružené soubory XMP", + "Year": "Rok", + "Yellow": "Žlutá", + "Yellow Hue": "Odstín žluté", + "Yellow Luminance": "Jas žluté", + "Yellow Saturation": "Sytost žluté", + "Yellow label": "Žlutý štítek", + "Yes": "Ano", + "You can close this: the download continues, and resumes if interrupted.": "Toto okno můžete zavřít: stahování pokračuje a po přerušení naváže.", + "Your Name": "Vaše jméno", + "Your library couldn't be opened": "Knihovnu se nepodařilo otevřít", + "Zoom": "Přiblížení", + "Zoom 100%": "Přiblížení 100%", + "Zoom In": "Přiblížit", + "Zoom Out": "Oddálit", + "Zoom to Fit": "Přizpůsobit přiblížení", + "Zoom to page level": "Přiblížit na úroveň stránky", + "a literal %": "literální znak %", + "a removed photo": "odebraná fotografie", + "all": "všechna", + "and": "a", + "any": "libovolné", + "are empty": "jsou prázdné", + "aren't empty": "nejsou prázdné", + "blue": "modrá", + "cached picture": "Obrazy v mezipaměti", + "cached pictures": "Obrazy v mezipaměti", + "can't be tested that way": "nelze takto testovat", + "can't match any rating from 0 to 5": "neodpovídá žádnému hodnocení od 0 do 5", + "can't test a folder; choose an album in it": "nelze testovat složku; vyberte album v ní", + "comma, separated": "oddělené, čárkou", + "commercial use allowed": "komerční použití povoleno", + "Reading photos…": "Načítání fotografií…", + "Adding photos…": "Přidávání fotografií…", + "commercial use subject to GPL conditions": "komerční použití podle podmínek GPL", + "contains": "obsahuje", + "copyrighted": "chráněno autorskými právy", + "day 01–31": "den 01–31", + "days": "dny", + "denoiseModel.accept": "denoiseModel.accept", + "discarding smart previews": "odstraňování inteligentních náhledů", + "doesn't contain": "neobsahuje", + "e.g. sky · the red car · car, road": "např. obloha · červené auto · auto, silnice", + "ends with": "končí na", + "false": "nepravda", + "green": "zelená", + "has the first date after the second": "má první datum po druhém", + "hour 00–23": "hodina 00–23", + "hours": "hodiny", + "image": "obrázek", + "in use": "používá se", + "includes the folders inside it": "zahrnuje podsložky", + "installed": "nainstalováno", + "is": "je", + "is <": "je <", + "is >": "je >", + "is after": "je po", + "is before": "je před", + "is between": "je mezi", + "is empty": "je prázdné", + "is in the last": "je v posledních", + "is unknown": "je neznámé", + "is yes or no": "je ano nebo ne", + "is ≤": "je ≤", + "is ≥": "je ≥", + "isn't": "není", + "isn't a field rules know": "není známé pole pravidel", + "isn't empty": "není prázdné", + "isn't in the last": "není v posledních", + "isn't one of the choices": "není jednou z možností", + "keep names — or e.g. {date}_{seq:3}": "zachovat názvy — nebo např. {date}_{seq:3}", + "latitude, longitude": "zeměpisná šířka, délka", + "makes AI Denoise available": "zpřístupní redukci šumu pomocí AI", + "memory only": "pouze v paměti", + "minute": "minuta", + "month 01–12": "měsíc 01–12", + "months": "měsíce", + "names an album that no longer exists": "odkazuje na album, které již neexistuje", + "needs a date like 2026, 2026-08 or 2026-08-14": "vyžaduje datum jako 2026, 2026-08 nebo 2026-08-14", + "needs a number": "vyžaduje číslo", + "needs a number more than 0": "vyžaduje číslo větší než 0", + "needs a time like 1/250 or 2": "vyžaduje čas jako 1/250 nebo 2", + "needs an album": "vyžaduje album", + "needs hours, days, weeks, months or years": "vyžaduje hodiny, dny, týdny, měsíce nebo roky", + "needs something to look for (or use “are empty”)": "vyžaduje hledaný text (nebo použijte „jsou prázdné“)", + "needs something to look for (or use “aren't empty”)": "vyžaduje hledaný text (nebo použijte „nejsou prázdné“)", + "needs something to look for (or use “is empty”)": "vyžaduje hledaný text (nebo použijte „je prázdné“)", + "needs something to look for (or use “isn't empty”)": "vyžaduje hledaný text (nebo použijte „není prázdné“)", + "needs text": "vyžaduje text", + "needs two values": "vyžaduje dvě hodnoty", + "non-commercial use only": "pouze nekomerční použití", + "none": "žádné", + "none (CPU rendering)": "žádné (vykreslování na CPU)", + "none of": "žádné z", + "not added to Apple Photos (cancelled)": "nepřidáno do Apple Photos (zrušeno)", + "not available here": "zde nedostupné", + "of the following rules:": "z následujících pravidel:", + "or": "nebo", + "photo has its denoised picture.": "Fotografie s dokončenou redukcí šumu", + "photo waiting": "Čekající fotografie", + "photos have their denoised picture.": "Fotografie s dokončenou redukcí šumu", + "photos waiting": "Čekající fotografie", + "pick": "vybrat", + "previews": "náhledy", + "publicDomain": "publicDomain", + "purple": "fialová", + "raw": "RAW", + "red": "červená", + "reject": "odmítnout", + "saved in": "uloženo v", + "second": "sekunda", + "smart album value|No": "Ne", + "smart album value|Yes": "Ano", + "smart previews": "inteligentní náhledy", + "starts using it for AI Denoise": "začne jej používat pro redukci šumu pomocí AI", + "starts with": "začíná na", + "terms unclear": "nejasné podmínky", + "the graphics card": "grafická karta", + "the graphics card is not used": "grafická karta se nepoužívá", + "the photos in view": "fotografie v zobrazení", + "the selected photos": "vybrané fotografie", + "this photo": "tato fotografie", + "this screen's profile": "profil této obrazovky", + "true": "pravda", + "unknown": "neznámé", + "video": "video", + "weeks": "týdny", + "would make this album include itself through other smart albums": "způsobí zahrnutí tohoto alba do sebe sama prostřednictvím jiných inteligentních alb", + "year, 2 digits": "rok, 2 číslice", + "year, 4 digits": "rok, 4 číslice", + "years": "roky", + "yellow": "žlutá", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} je čas pořízení; u fotografie bez něj se použije čas importu.", + "© 2026 Your Name": "© 2026 Vaše jméno", + "© Your Name": "© Vaše jméno", + "− harder border · + softer border": "− ostřejší okraj · + měkčí okraj", + "≤ at most": "≤ nejvýše", + "≥ at least": "≥ nejméně", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Show photos": "Zobrazit fotografie", + "Their photos in the grid": "Fotografie této osoby v mřížce", + "Click to name this face": "Kliknutím pojmenujete tento obličej", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Po potvrzení vyberte cílové umístění PDF. PDF vytiskněte ze svého prohlížeče.", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Prohlížeče, které nečtou blok PNG cICP, zobrazí hodnoty PQ jako sRGB: obraz je plochý a vybledlý. Pro správné zobrazení všude použijte JPEG (mapa zisku).", + "Apple Photos Imports": "Importy do Apple Photos", + "Background Tasks": "Úlohy na pozadí", + "Cancel Background Task": "Zrušit úlohu na pozadí", + "Contact Sheet PDF…": "Přehled snímků PDF…", + "Create Keyword": "Vytvořit klíčové slovo", + "Display": "Zobrazení", + "Display Profile": "Profil displeje", + "Edit Keyword": "Upravit klíčové slovo", + "Export Contact Sheet PDF": "Exportovat přehled snímků PDF", + "Find Blemishes": "Najít nedokonalosti", + "Keyword Info": "Informace o klíčovém slově", + "Move Keyword": "Přesunout klíčové slovo", + "Put New Keywords Inside": "Vkládat nová klíčová slova dovnitř", + "Scan Folder for Changes": "Prohledat změny ve složce", + "Set Folder Color Label": "Nastavit barevný štítek složky", + "Remove Folder Color Label": "Odebrat barevný štítek složky", + "Show in Compare": "Zobrazit v porovnání", + "Update to Current Process": "Aktualizovat na aktuální proces", + "HDR Headroom": "Rezerva HDR", + "SDR Brightness": "Jas SDR", + "SDR Clarity": "Zřetelnost SDR", + "SDR Contrast": "Kontrast SDR", + "SDR Highlights": "Světla SDR", + "SDR Shadows": "Stíny SDR", + "SDR Whites": "Bílé SDR", + "HDR TIFF needs 32-bit float.": "HDR TIFF vyžaduje 32bitové hodnoty s plovoucí řádovou čárkou.", + "Color space: Rec. 2020 HLG (HDR AVIF)": "Barevný prostor: Rec. 2020 HLG (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Barevný prostor: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Barevný prostor: Rec. 2020 PQ, 16 bitů (HDR PNG)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Barevný prostor: Rec. 2020 PQ (HDR JPEG XL)", + "or drop a .onnx file on the window": "nebo přetáhněte soubor .onnx do okna", + "Already installed. Installing again is harmless.": "Již nainstalováno. Opětovná instalace ničemu nevadí.", + "Enter to confirm, Esc to cancel": "Enter potvrdí, Esc zruší", + "Add a model file…": "Přidat soubor modelu…", + "Open Settings": "Otevřít nastavení", + "Select none": "Zrušit celý výběr", + "Select all": "Vybrat vše", + "Visualize HDR": "Zobrazit rozsah HDR" +} diff --git a/crates/ui-egui/locales/de-formats.json b/crates/ui-egui/locales/de-formats.json index dff2ddb75..42245407d 100644 --- a/crates/ui-egui/locales/de-formats.json +++ b/crates/ui-egui/locales/de-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Standardvorschauen erstellen ({scope})", "Building previews for {} photos…": "Vorschauen für {} Fotos werden erstellt…", "Click {}:1": "Klick {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Farbraum: Rec. 2020 {curve} (HDR-AVIF)", "Couldn't open the editor: {e}": "Editor konnte nicht geöffnet werden: {e}", "Create {n} Virtual Copies": "{n} virtuelle Kopien erstellen", "Creates {} stacks from {} of {scope}": "Erstellt {} Stapel aus {} von {scope}", @@ -61,6 +62,7 @@ "Import cancelled · {} photo{} added": "Import abgebrochen · {} Foto{} hinzugefügt", "Import failed: {e}": "Import fehlgeschlagen: {e}", "Import warnings ({n}):\n{warnings}": "Importwarnungen ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Die XMP-Begleitdateien von {n} Fotos konnten nicht geschrieben werden (ist ihr Ordner offline?). LightCraft versucht es weiter.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "XMP-Profil \"{profile}\" wurde für {path} nicht angewendet; Farben können von Lightroom abweichen.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Lightroom-Geometrie wurde für {path} nicht beibehalten; {mode} wird neu berechnet.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Lightroom-Geometrie für {path} wurde nicht beibehalten; gespeicherte Transformation wird ignoriert, weil Upright deaktiviert ist.", @@ -161,6 +163,7 @@ "{n} photo{}": "{n} Foto{}", "{n} photo{} match · updates automatically as photos change": "Passende Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", "{n} photo{} now · updates automatically as photos change": "Aktuelle Fotos: {n}{:.0} · wird bei Änderungen an Fotos automatisch aktualisiert", + "{n} selected photos · {pages} pages": "{n} ausgewählte Fotos · {pages} Seiten", "{pid} (missing)": "{pid} (fehlt)", "{sel_n} selected · {counted}": "{sel_n} ausgewählt · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -197,23 +200,23 @@ "{} label (click more labels to show any of them)": "Markierung {} (weitere Markierungen auswählen, um alle passenden Fotos zu zeigen)", ", {} star{}": ", Sterne: {}{:.0}", ", {} label": ", Markierung: {}", - "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Lädt von {host} herunter. Sobald es angekommen und geprüft ist, installiert LightCraft es und {after}.", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "Läuft auf dem Prozessor: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Im Ordner „denoise“ der Bibliothek gespeichert; bei Erreichen der Grenze werden zuerst die ältesten entfernt, die kein Foto verwendet.", + "Denoising {name}: {done} of {total} tiles{}": "{name} wird entrauscht: {done} von {total} Kacheln{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{} von {} von {host}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "Entrauschtes Bild wird berechnet… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "Trainiert mit: {provenance}", + " · {queued} waiting": " · {queued} wartend", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Zeigt die Bedingungen des Modells, lädt dann {} von {host} herunter, installiert es und verwendet es. Sonst wird nichts gesendet.", + "In line for AI Denoise ({ahead} ahead)…": "In der Warteschlange für KI-Entrauschen ({ahead} davor)…", + "Running on {}.": "Läuft auf {}.", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "In „{parent}“ einfügen", @@ -235,6 +238,10 @@ "{done} of {total} MB": "{done} von {total} MB", "{pct} %": "{pct} %", "+{n} more": "+{n} weitere", + "Create the keyword “{name}”": "Stichwort „{name}“ erstellen", + "new, inside {parent}": "neu, in {parent}", + "Keywords of {n} selected photos": "Stichwörter von {n} ausgewählten Fotos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "„{name}“ von {n} Fotos entfernt. Bearbeiten → Rückgängig stellt es wieder her.", "1 task is still running: {names}. Quitting stops it.": "1 Aufgabe läuft noch: {names}. Beenden bricht sie ab.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} Aufgaben laufen noch: {names}. Beenden bricht sie ab.", "{what} merge cancelled": "{what}-Zusammenfügung abgebrochen", @@ -245,7 +252,48 @@ "{n} added to Apple Photos album “{album}”": "{n} zum Apple-Fotos-Album „{album}“ hinzugefügt", "{n} added to Apple Photos": "{n} zu Apple Fotos hinzugefügt", "{n} photos opened for editing; each is stacked with its original": "{n} Fotos zur Bearbeitung geöffnet; jedes ist mit seinem Original gestapelt", + " in {g} group{}": " in {g} Gruppe(n){:.0}", + " · kept, not applied: {}": " · behalten, nicht angewendet: {}", + " — kept but not applied: {}": " — behalten, aber nicht angewendet: {}", + "Apply to “{mask}” on Selected Photos": "Auf „{mask}“ in den ausgewählten Fotos anwenden", + "Delete the effect preset “{name}”?": "Effektvorgabe „{name}“ löschen?", + "Delete these {n} effect presets?": "Diese {n} Effektvorgaben löschen?", + "Delete {n} effect preset{}? This can't be undone.": "{n} Effektvorgabe(n) löschen?{:.0} Dies kann nicht rückgängig gemacht werden.", + "Effect: {name}": "Effekt: {name}", + "Exported {} effect preset{}": "Exportierte Effektvorgaben: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} Maskeneffekt(e) importiert{:.0} (Maskieren → Effekt)", + "Update “{name}” with Current Settings": "„{name}“ mit aktuellen Einstellungen aktualisieren", + "{n} preset{}": "{n} Vorgabe(n){:.0}", "{name} opened in the other editor": "{name} im anderen Editor geöffnet", "{n} originals opened in the other editor": "{n} Originale im anderen Editor geöffnet", - "· {in_source} identical copies": "· {in_source} identische Kopien" + "· {in_source} identical copies": "· {in_source} identische Kopien", + "{count} more warnings.": "{count} weitere Warnungen.", + "{photos} catalog records processed; {collections} collections.": "{photos} Katalogeinträge verarbeitet; {collections} Sammlungen.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} Originale fehlen. Ihre Katalogeinträge sind verfügbar; verwende „Fehlende Fotos suchen“, um sie neu zu verknüpfen.", + "Applied {name}": "{name} angewendet", + "Delete “{name}”?": "„{name}“ löschen?", + "Updated {name}": "{name} aktualisiert", + "Exported to {path}": "Exportiert nach {path}", + "Saved template {name}": "Vorlage {name} gespeichert", + "Imported {name}": "{name} importiert", + "Skipped {problem}": "Übersprungen: {problem}", + "…and {more} more": "…und {more} weitere", + "Template preview: {name}": "Vorlagenvorschau: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} Fotos in der Ansicht · {slides} Folien · {time}", + "{n} pages": "{n} Seiten", + "{n} JPEG files": "{n} JPEG-Dateien", + "{s} s of AV1 video": "{s} s AV1-Video", + "Slideshow exported: {what}": "Diashow exportiert: {what}", + " · {n} skipped (could not be rendered)": " · {n} übersprungen (nicht darstellbar)", + "{n} raws can now be developed from their raw data": "{n} RAW-Dateien können jetzt aus ihren Rohdaten entwickelt werden", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft wurde unerwartet beendet: {e}\n\nEinzelheiten stehen in der Protokolldatei: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft wurde unerwartet beendet: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Benennt {n} Gesichter in der Bibliothek um. Die Dateien werden nicht geändert.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "„{to}“ ist bereits eine Person. Zusammenführen ergibt eine Person; Rückgängig trennt sie wieder.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}-Zusammenfügung mit {mp} hinzugefügt: die volle Größe, {full}, braucht mehr Arbeitsspeicher, als dieser Computer hat", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}-Zusammenfügung mit {mp} hinzugefügt, der größten möglichen Panoramagröße (volle Größe: {full})", + "Already in “{}”": "Bereits in „{}“", + "Move {n} photo{} to “{}”": "{n} Foto{0} nach „{1}“ verschieben", + "Move {n} photo{} to “{}”?": "{n} Foto{0} nach „{1}“ verschieben?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} Foto{0} nach „{1}“ verschoben. Bearbeiten → Rückgängig holt sie zurück." } diff --git a/crates/ui-egui/locales/de.json b/crates/ui-egui/locales/de.json index 25c411ce0..000aea2d4 100644 --- a/crates/ui-egui/locales/de.json +++ b/crates/ui-egui/locales/de.json @@ -14,6 +14,7 @@ "0: nothing is rejected.": "0: Nichts wird abgelehnt.", "1 day": "1 Tag", "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 auf studio-mac. Beide Programme können nicht gleichzeitig schreiben und gegenseitig Bearbeitungen verlieren. Ein Absturz lässt die", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · aktuelle Bearbeitungen · eingepasst ohne Beschnitt", "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 großen Vorschau. Metadaten werden importiert: Aufnahmezeit, Kamera, Objektiv, Belichtung, GPS und XMP. Die RAW-Daten", "2026-04 or 2026-04-12": "2026-04 oder 2026-04-12", "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 neue eingebaute Vorgaben: Porträt, Landschaft, Stadt, Essen, Jahreszeiten, Vintage und Schwarzweißtonungen.", @@ -25,6 +26,7 @@ "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Ein Filter blendet die Fotos dieser Ansicht aus: Ändere ihn oder lösche ihn (Ansicht → Filter löschen)", "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Eine Bibliothek ist nur in einem Programm gleichzeitig geöffnet (Issue #99). Wenn LightCraft oder `lightcraft-cli` sie bereits geöffnet hat,", "A one-time download of about": "Ein einmaliger Download von etwa", + "A pen's pressure sets the size and flow of the brush": "Der Druck eines Stifts bestimmt Größe und Fluss des Pinsels", "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Ein teilweise fehlgeschlagener Speichervorgang durch einen vollen Datenträger oder eine getrennte Netzwerkfreigabe hinterlässt keinen scheinbar beschädigten Katalog", "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Ein schlafendes NAS, eine getrennte Netzwerkfreigabe oder ein anlaufendes USB-Laufwerk blockieren das Fenster nicht mehr (Issue #104).", "AI Mask Model Status": "Status des KI-Maskenmodells", @@ -53,6 +55,9 @@ "Add to Favorites": "Zu Favoriten hinzufügen", "Add to Local": "Zu „Lokal“ hinzufügen", "Add to My Photos": "Zu „Meine Fotos“ hinzufügen", + "Can't be moved here": "Hierher nicht verschiebbar", + "This row covers more than one disk: drop on a folder inside it": "Diese Zeile umfasst mehr als ein Laufwerk: auf einem Ordner darin ablegen", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Lokale Fotos sind noch nicht in der Bibliothek: erst zu „Meine Fotos“ hinzufügen, dann verschieben", "Add to Selected Photos": "Zu ausgewählten Fotos hinzufügen", "Add to Target Album": "Zu Zielalbum hinzufügen", "Add to this mask": "Zu dieser Maske hinzufügen", @@ -218,6 +223,7 @@ "Choose Edit Settings to Copy": "Zu kopierende Bearbeitungseinstellungen auswählen", "Choose Edit Settings to Copy…": "Zu kopierende Bearbeitungseinstellungen auswählen…", "Choose Folder…": "Ordner auswählen…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Nach dem Bestätigen den Speicherort des PDFs wählen. Das PDF aus dem Anzeigeprogramm drucken.", "Choose a photo in the grid or filmstrip": "Wähle ein Foto im Raster oder Filmstreifen", "Choose…": "Auswählen…", "Cinematic": "Filmisch", @@ -269,6 +275,9 @@ "Color Range": "Farbbereich", "Color label cleared": "Farbmarkierung entfernt", "Color space": "Farbraum", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Farbraum: Rec. 2020 PQ (HDR-AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Farbraum: Rec. 2020 PQ (HDR-JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Farbraum: Rec. 2020 PQ, 16 Bit (HDR-PNG)", "Color space: sRGB (AVIF)": "Farbraum: sRGB (AVIF)", "Colors": "Farben", "Colour-label filter with several labels at once; expandable folder tree in Local.": "Farbmarkierungsfilter mit mehreren Markierungen gleichzeitig; aufklappbarer lokaler Ordnerbaum.", @@ -288,7 +297,14 @@ "Concrete Cool": "Kühler Beton", "Confirm before deleting": "Vor dem Löschen bestätigen", "Confirm before moving photos to Recently Deleted": "Vor dem Verschieben nach „Zuletzt gelöscht“ bestätigen", + "Move Photos": "Fotos verschieben", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Ihre Dateien und XMP-Filialdateien werden auf dem Datenträger verschoben. Bearbeiten → Rückgängig holt sie zurück.", + "Don't ask again": "Nicht mehr fragen", + "Another library was opened since the photos were dropped: nothing was moved": "Seit dem Ablegen der Fotos wurde eine andere Bibliothek geöffnet: Es wurde nichts verschoben", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Einstellungen → „Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen“ schaltet es wieder ein", + "Confirm before moving photos to another folder on disk": "Vor dem Verschieben in einen anderen Ordner auf dem Datenträger bestätigen", "Constrain Crop": "Zuschnitt beschränken", + "Contact Sheet PDF": "Kontaktabzug-PDF", "Continue": "Fortfahren", "Continue Without Saving": "Ohne Speichern fortfahren", "Contrast": "Kontrast", @@ -522,6 +538,7 @@ "File name": "Dateiname", "File types:": "Dateitypen:", "Filename": "Dateiname", + "Filename captions": "Dateinamen als Bildunterschrift", "Fill": "Ausfüllen", "Fill Edges": "Ränder füllen", "Film": "Film", @@ -581,6 +598,10 @@ "Friday": "Freitag", "Full": "Vollständig", "Full Screen Preview": "Vollbildvorschau", + "Hold View Until Refreshed": "Ansicht bis zum Aktualisieren beibehalten", + "HDR TIFF needs 32-bit float.": "HDR-TIFF braucht 32-Bit-Gleitkomma.", + "HDR output": "HDR-Ausgabe", + "Letter": "Letter", "Lights Out": "Licht aus", "Full screen mode": "Vollbildmodus", "Full size": "Volle Größe", @@ -636,6 +657,7 @@ "HDR panorama": "HDR-Panorama", "HDR panoramas": "HDR-Panoramen", "HDR with Last Settings": "HDR mit letzten Einstellungen", + "HDR for Each Bracket Set": "HDR für jede Belichtungsreihe", "HDR…": "HDR…", "Has Edits": "Enthält Bearbeitungen", "Has GPS": "Enthält GPS", @@ -945,7 +967,7 @@ "No edits yet.": "Noch keine Bearbeitungen.", "No exposure information": "Keine Belichtungsinformationen", "No filter presets yet": "Noch keine Filtervorgaben", - "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", + "No lens data embedded in this file.": "Keine Objektivdaten in dieser Datei eingebettet.", "No masks yet. Choose a mask type above.": "Noch keine Masken. Wähle oben einen Maskentyp.", "No matching commands": "Keine passenden Befehle", "No matching photos": "Keine passenden Fotos", @@ -956,6 +978,11 @@ "No new presets": "Keine neuen Vorgaben", "No photo selected": "Kein Foto ausgewählt", "No photos": "Keine Fotos", + "Your library is empty": "Deine Bibliothek ist leer", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Importierte Fotos erscheinen hier. Nichts wird kopiert oder verschoben, außer du wählst es.", + "or drop photos and folders into this window": "oder ziehe Fotos und Ordner in dieses Fenster", + "Import photos to get started": "Importiere Fotos, um loszulegen", + "or drop photos into this window": "oder ziehe Fotos in dieses Fenster", "No photos found": "Keine Fotos gefunden", "No photos in this folder": "Keine Fotos in diesem Ordner", "No photos match the active filters": "Keine Fotos entsprechen den aktiven Filtern", @@ -1032,6 +1059,7 @@ "Paste from Previous": "Vom vorherigen Foto einfügen", "Paste pages": "Seiten einfügen", "Pastel Haze": "Pastelldunst", + "Pen Pressure": "Stiftdruck", "People": "Personen", "Percent": "Prozent", "Percentage": "Prozentsatz", @@ -1047,6 +1075,7 @@ "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "Fotomenü. Auch der Filmstreifen besitzt das Fotokontextmenü. Wird eine Datei erneut hinzugefügt, die unter „Zuletzt gelöscht“ liegt,", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → In externem Editor bearbeiten (⇧⌘E) rendert eine 16-Bit-TIFF-Kopie, stapelt sie mit dem Original und öffnet sie hier (App-Name unter macOS, sonst Programmpfad).", "Photos": "Fotos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "In HDR bearbeitete Fotos: JPEG mit HDR-Gain-Map (sieht überall richtig aus, hellere Lichter auf HDR-Bildschirmen), AVIF mit 10 Bit, PNG und JPEG XL mit 16 Bit Rec. 2020 PQ; TIFF braucht 32-Bit-Gleitkomma", "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Fotos unter „Zuletzt gelöscht“ können in der App wiederhergestellt werden: Rechtsklick → Wiederherstellen oder Endgültig löschen, auch im", "Photos per bracket (0 = auto)": "Fotos pro Belichtungsreihe (0 = automatisch)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Fotos, die unter „Lokal“ angezeigt, aber nie hinzugefügt oder geändert wurden, verlassen den Katalog, wenn ihr Ordner so lange nicht durchsucht wurde (Prüfung beim Öffnen der Bibliothek). Dateien bleiben auf dem Datenträger; beim Durchsuchen erscheinen sie wieder.", @@ -1173,6 +1202,7 @@ "Refining the mask's detail…": "Maskendetails werden verfeinert…", "Refresh Source": "Quelle aktualisieren", "Refresh Source (/)": "Quelle aktualisieren (/)", + "Refresh View": "Ansicht aktualisieren", "Reject": "Ablehnen", "Reject below focus": "Unter Schärfegrenzwert ablehnen", "Rejected": "Abgelehnt", @@ -1241,7 +1271,7 @@ "Reset Section": "Bereich zurücksetzen", "Reset Tone Curve": "Gradationskurve zurücksetzen", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Alle Kurven zurücksetzen: Punktkurven (alle Kanäle) und parametrische Kurve (Doppelklick auf einen Kanal setzt nur diesen zurück)", - "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", + "Reset all edits": "Alle Bearbeitungen zurücksetzen", "Reshuffle": "Neu mischen", "Resize Face Box": "Gesichtsrahmengröße ändern", "Resize panel window": "Größe des Bereichsfensters ändern", @@ -1402,6 +1432,7 @@ "Show overlay (O)": "Überlagerung anzeigen (O)", "Show ratings, flags and edit badges": "Bewertungen, Markierungen und Bearbeitungssymbole anzeigen", "Show the Navigator while zoomed in": "Navigator beim Hineinzoomen anzeigen", + "Show the import review when a camera or card is connected": "Importfenster öffnen, wenn eine Kamera oder Karte angeschlossen wird", "Show the template tags; click one to insert it": "Vorlagenplatzhalter anzeigen; zum Einfügen anklicken", "Show/Hide Mask": "Maske ein-/ausblenden", "Show/hide My Photos panel": "Bereich „Meine Fotos“ ein-/ausblenden", @@ -1432,6 +1463,7 @@ "Soft Pastel": "Sanftes Pastell", "Soft Proofing": "Softproof", "Soft Skin": "Weiche Haut", + "Solo Mode": "Solo-Modus", "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Sony-ARWs von vor etwa 2017, RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7, A7 II, A7R II und verwandte", "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Verkleinerte verlustfreie ARW-Dateien der Sony ILCE-7M4 werden jetzt aus unterabgetasteten YCbCr-Kacheln in lineares RGB decodiert.", "Sort": "Sortieren", @@ -1583,6 +1615,7 @@ "Vibrance": "Dynamik", "Videos": "Videos", "View": "Ansicht", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Programme, die den cICP-Block des PNGs nicht lesen, zeigen PQ-Werte als sRGB: flau und blass. Für ein Bild, das überall richtig aussieht, JPEG (Gain-Map) verwenden.", "Viewing and sharing": "Ansicht und Weitergabe", "Vignette": "Vignette", "Vignetting": "Vignettierung", @@ -1617,6 +1650,7 @@ "Window": "Fenster", "Winter Blue": "Winterblau", "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Ohne Speichern fortfahren und Beenden (Issue #100). Sie startet keine unbemerkte Demo-Sitzung mehr, die wie eine zurückgesetzte Bibliothek wirkte und", + "Wrote the XMP sidecars that were waiting": "Ausstehende XMP-Begleitdateien geschrieben", "XMP Sidecar Preferences": "Einstellungen für XMP-Begleitdateien", "XMP sidecars": "XMP-Begleitdateien", "Year": "Jahr", @@ -1819,99 +1853,99 @@ "Open Log Folder": "Protokollordner öffnen", "Reading photos…": "Fotos werden gelesen…", "Adding photos…": "Fotos werden hinzugefügt…", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "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.": "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.", + "Back to this person": "Zurück zu dieser Person", + "AI Denoise": "KI-Entrauschen", + "AI Denoise Model": "KI-Entrauschungsmodell", + "AI Denoise is off: press Use on a model.": "KI-Entrauschen ist aus: Klicke bei einem Modell auf „Verwenden“.", + "AI Denoise needs a model. Install one in Settings.": "KI-Entrauschen benötigt ein Modell. Installiere eines in den Einstellungen.", + "Accept & Download": "Akzeptieren und herunterladen", + "Already installed. Remove it before installing a replacement.": "Bereits installiert. Entferne es, bevor du einen Ersatz installierst.", + "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.": "Automatisch verwendet die Grafikkarte, wo sie schneller als der Prozessor ist: Beide werden beim ersten berechneten Foto auf diesem Computer gemessen.", "Cache": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "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 does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "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.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "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 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.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "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 build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Checking and installing the model…": "Modell wird geprüft und installiert…", + "Checking…": "Wird geprüft…", + "Clear cache": "Cache leeren", + "Denoise Models": "Entrauschungsmodelle", + "Denoise Settings": "Entrauschungseinstellungen", + "Denoise all photos with an Amount": "Alle Fotos mit einer Stärke entrauschen", + "Downloaded and checked. Installing…": "Heruntergeladen und geprüft. Wird installiert…", + "Failed its last test": "Letzter Test fehlgeschlagen", + "Graphics card": "Grafikkarte", + "I have read these terms and accept them for my own use": "Ich habe diese Bedingungen gelesen und akzeptiere sie für meine eigene Nutzung", + "Inspect Denoise Model": "Entrauschungsmodell prüfen", + "Install Denoise Model": "Entrauschungsmodell installieren", + "Install from file…": "Aus Datei installieren…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installiere ein eigenes ONNX-Modell mit einer denoise-model.json daneben. Diese Version bietet keine Modelldownloads an.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Sein entrauschtes Bild ist noch nicht berechnet, und Bilder werden nicht automatisch berechnet (Einstellungen, KI-Entrauschen).", + "LightCraft does not know this model, so it assumed:": "LightCraft kennt dieses Modell nicht und hat daher angenommen:", + "Limit": "Grenze", + "Make it now": "Jetzt berechnen", + "Make the denoised picture of the photos I am looking at": "Entrauschtes Bild der angezeigten Fotos berechnen", + "Next up for AI Denoise…": "Als Nächstes beim KI-Entrauschen…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nichts wird geladen, bevor du zustimmst. Das Modell bleibt nur auf diesem Computer; LightCraft lädt es nie hoch und gibt es nie weiter.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Nur Fotos mit einer KI-Entrauschungsstärke über 0 werden berechnet. Exporte berechnen ihres immer, wenn es fehlt.", + "Open page": "Seite öffnen", + "Open the model's page": "Seite des Modells öffnen", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Öffnet die Seite des Modells im Browser, um mehr darüber zu lesen oder die Datei selbst herunterzuladen.", + "Processor": "Prozessor", + "Set up AI Denoise…": "KI-Entrauschen einrichten…", + "Speed": "Geschwindigkeit", + "Starting…": "Wird gestartet…", + "Tested and works on this computer": "Getestet, funktioniert auf diesem Computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Das Entrauschungsmodell ist installiert und in Gebrauch: Der Regler für die KI-Entrauschungsstärke unter „Detail“ funktioniert jetzt", + "The graphics card is set up when the next photo is made.": "Die Grafikkarte wird eingerichtet, wenn das nächste Foto berechnet wird.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Das Modell bleibt nur auf diesem Computer. LightCraft lädt es nie hoch und gibt es nie weiter.", + "The processor does the work.": "Der Prozessor erledigt die Arbeit.", + "There is no model file to install": "Es gibt keine Modelldatei zum Installieren", + "This build cannot run denoise models.": "Diese Version kann keine Entrauschungsmodelle ausführen.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Diese Version hat keinen Speicherort für Entrauschungsmodelle, daher ist KI-Entrauschen nicht verfügbar. (Die Desktop-App schon.)", + "This file cannot be used yet": "Diese Datei kann noch nicht verwendet werden", + "Tick the box to accept the model's terms first": "Setze zuerst das Häkchen, um die Bedingungen des Modells zu akzeptieren", + "Try again": "Erneut versuchen", + "Try the graphics card again": "Grafikkarte erneut versuchen", + "Turn off": "Ausschalten", + "Unknown licence": "Unbekannte Lizenz", + "Use": "Verwenden", + "Waiting to start…": "Wartet auf Start…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funktioniert mit Raw-Dateien von Kameras mit Standardsensor (Bayer). Andere Dateien behalten ihre normale Rauschreduzierung.", + "cached picture": "zwischengespeichertes Bild", + "cached pictures": "zwischengespeicherte Bilder", + "commercial use allowed": "kommerzielle Nutzung erlaubt", "denoiseModel.accept": "denoiseModel.accept", - "in use": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "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", + "in use": "in Gebrauch", + "installed": "installiert", + "makes AI Denoise available": "macht KI-Entrauschen verfügbar", + "non-commercial use only": "nur nichtkommerzielle Nutzung", + "photo has its denoised picture.": "Foto hat sein entrauschtes Bild.", + "photo waiting": "Foto wartet", + "photos have their denoised picture.": "Fotos haben ihr entrauschtes Bild.", + "photos waiting": "Fotos warten", + "starts using it for AI Denoise": "verwendet es dann für KI-Entrauschen", + "terms unclear": "Bedingungen unklar", + "the graphics card": "die Grafikkarte", + "the graphics card is not used": "die Grafikkarte wird nicht verwendet", + "Cancel Denoise": "Entrauschen abbrechen", + "Cancel Denoise Model Download": "Download des Entrauschungsmodells abbrechen", + "Choose Denoise Model": "Entrauschungsmodell wählen", + "Clear Denoise Cache": "Entrauschungs-Cache leeren", + "Denoise Model Downloads": "Downloads von Entrauschungsmodellen", + "Denoise Photos": "Fotos entrauschen", + "Denoise Pump": "Entrauschungs-Warteschlange", + "Denoise Status": "Entrauschungsstatus", + "Download Denoise Model": "Entrauschungsmodell herunterladen", + "Remove Denoise Model": "Entrauschungsmodell entfernen", + "Test Denoise Model": "Entrauschungsmodell testen", + "AI Denoise is enabled on the requesting photo": "KI-Entrauschen ist für das anfragende Foto aktiviert", + "After installation, AI Denoise will turn on for the requesting photo.": "Nach der Installation wird KI-Entrauschen für das anfragende Foto eingeschaltet.", + "Cancel pending action": "Ausstehende Aktion abbrechen", + "Install a denoise model to enable AI Denoise on this photo": "Installiere ein Entrauschungsmodell, um KI-Entrauschen für dieses Foto zu aktivieren", + "Pending photo action cancelled": "Ausstehende Fotoaktion abgebrochen", + "Pending photo action cancelled because its library or photo changed": "Ausstehende Fotoaktion abgebrochen, weil sich die Bibliothek oder das Foto geändert hat", + "ONNX model": "ONNX-Modell", + "commercial use subject to GPL conditions": "kommerzielle Nutzung unter GPL-Bedingungen", "Decrease Rating": "Bewertung verringern", "Increase Rating": "Bewertung erhöhen", - "Click to type a value": "Klicken, um einen Wert einzugeben", + "Click to type a value, drag to change it": "Klicken, um einen Wert einzugeben, ziehen, um ihn zu ändern", "Angle": "Winkel", "Video": "Video", "Copyrighted": "Urheberrechtlich geschützt", @@ -1975,6 +2009,15 @@ "Keyword List": "Stichwortliste", "Filter Keywords": "Stichwörter filtern", "No keywords yet": "Noch keine Stichwörter", + "Show fewer": "Weniger anzeigen", + "Whole names": "Vollständige Namen", + "Grouped by parent": "Nach übergeordnetem Stichwort gruppiert", + "Keywords of the selection": "Stichwörter der Auswahl", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Gruppiert: Stichwörter im selben Stichwort teilen sich eine Zeile mit dessen Namen; lange Listen und die Vorschläge werden gekürzt.", + "Show every keyword of the selected photos": "Alle Stichwörter der ausgewählten Fotos anzeigen", + "new keyword": "neues Stichwort", + "Return creates this keyword and gives it to the selected photos": "Die Eingabetaste erstellt dieses Stichwort und weist es den ausgewählten Fotos zu", + "Separate several keywords with commas. Return gives them to the selected photos.": "Mehrere Stichwörter durch Kommas trennen. Die Eingabetaste weist sie den ausgewählten Fotos zu.", "No keywords match": "Keine passenden Stichwörter", "Keyword Name": "Stichwortname", "Synonyms": "Synonyme", @@ -2116,7 +2159,187 @@ "not added to Apple Photos (cancelled)": "nicht zu Apple Fotos hinzugefügt (abgebrochen)", "The first time, macOS asks whether LightCraft may control Photos.": "Beim ersten Mal fragt macOS, ob LightCraft Fotos steuern darf.", "Adding to Apple Photos": "Wird zu Apple Fotos hinzugefügt", + "Add Face Model…": "Gesichtsmodell hinzufügen…", + "Cancel Face Model Download": "Download des Gesichtsmodells abbrechen", + "Cancel Pending Photo Action": "Ausstehende Fotoaktion abbrechen", + "Choose Face Recognition Model": "Gesichtserkennungsmodell wählen", + "Choose a model below (Use) to start.": "Wähle unten ein Modell („Verwenden“), um zu beginnen.", + "Detect Faces": "Gesichter erkennen", + "Detect Faces (Photo menu) needs it: a small one-time download.": "„Gesichter erkennen“ (Menü „Foto“) benötigt es: ein kleiner, einmaliger Download.", + "Download Face Model": "Gesichtsmodell herunterladen", + "Drag Crop Handle": "Freistellungsgriff ziehen", + "Evaluate Face Recognition": "Gesichtserkennung bewerten", + "Face Model Downloads": "Downloads von Gesichtsmodellen", + "Face Models": "Gesichtsmodelle", + "Face Recognition On/Off": "Gesichtserkennung ein/aus", + "Face detector": "Gesichtsdetektor", + "Face recognition": "Gesichtserkennung", + "Faces": "Gesichter", + "Faces of a Person": "Gesichter einer Person", + "Go back to listing them by name": "Wieder nach Namen auflisten", + "Index Faces": "Gesichter indizieren", + "Index Faces in the Background": "Gesichter im Hintergrund indizieren", + "Inspect Face Model File": "Gesichtsmodelldatei prüfen", + "Install Face Model": "Gesichtsmodell installieren", + "Library Folders": "Bibliotheksordner", + "Name Face": "Gesicht benennen", + "Name Faces": "Gesichter benennen", + "Recognise faces (experimental)": "Gesichter erkennen (experimentell)", + "Recognition models": "Erkennungsmodelle", + "Remove Face Model": "Gesichtsmodell entfernen", + "Reorder Album": "Album umordnen", + "Show Person": "Person anzeigen", + "Sort Albums A–Z": "Alben A–Z sortieren", + "Sort Albums by Name": "Alben nach Namen sortieren", + "Sort Contents A–Z": "Inhalt A–Z sortieren", + "Suggest Names for Faces": "Namen für Gesichter vorschlagen", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Schlägt anhand der von dir benannten Gesichter vor, wer auf einem Foto zu sehen ist. Alles bleibt auf deinem Computer.", + "Test Face Model": "Gesichtsmodell testen", + "This build cannot run recognition models: they can be added and chosen, not used.": "Diese Version kann keine Erkennungsmodelle ausführen: Sie lassen sich hinzufügen und auswählen, aber nicht verwenden.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Diese Version hat keinen Speicherort für Gesichtsmodelle, daher ist die Modellliste nicht verfügbar. (Die Desktop-App schon.)", + "Unnamed Faces": "Unbenannte Gesichter", + "smart album value|No": "Nein", + "smart album value|Yes": "Ja", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Kein Grafikprozessor gefunden: LightCraft rendert auf der CPU, was viel langsamer ist. Details unter Hilfe → Systeminformationen.", "On NixOS, start it from `nix develop`.": "Unter NixOS starten Sie es aus `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert." + "Files with the same content as another file in this import: only the first is imported.": "Dateien mit demselben Inhalt wie eine andere Datei dieses Imports: Nur die erste wird importiert.", + "Lightroom Catalog Import": "Lightroom-Katalogimport", + "Destination library": "Zielbibliothek", + "Show in Library": "In Bibliothek anzeigen", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom-Kataloge werden in die geöffnete Bibliothek importiert; es wird kein eigener Katalogordner erstellt.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Diese Fotos befinden sich nur im Arbeitsspeicher. Öffne eine Bibliothek auf dem Datenträger und importiere den Katalog erneut, um sie zu speichern.", + "Playback": "Wiedergabe", + "Layout": "Layout", + "Link all margins": "Alle Ränder verknüpfen", + "Margins": "Ränder", + "Left": "Links", + "Right": "Rechts", + "Top": "Oben", + "Bottom": "Unten", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Ränder sind ein Anteil der kürzeren Seite der Folie, damit Folien auf jedem Bildschirm gleich aussehen.", + "Show guides": "Hilfslinien anzeigen", + "Aspect preview": "Seitenverhältnis-Vorschau", + "Zoom to fill frame": "Zoomen, um Rahmen zu füllen", + "Stroke border": "Konturlinie", + "Colour": "Farbe", + "Cast shadow": "Schlagschatten", + "Offset": "Versatz", + "Backdrop": "Hintergrund", + "Background colour": "Hintergrundfarbe", + "Colour wash": "Farbverlauf", + "Background image": "Hintergrundbild", + "(not in the library)": "(nicht in der Bibliothek)", + "Use selected photo": "Ausgewähltes Foto verwenden", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Wählen Sie ein Foto aus und schalten Sie dies ein, um die Folie hinter jedem Foto damit zu füllen.", + "Reset layout": "Layout zurücksetzen", + "Select a photo to preview its slide": "Wählen Sie ein Foto, um seine Folie in der Vorschau zu sehen", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Das ausgewählte Foto als Folie. Die Wiedergabe füllt den Bildschirm; die Seitenverhältnis-Vorschau ändert nur diese Vorschau.", + "Overlays": "Überlagerungen", + "Identity plate": "Erkennungstafel", + "Your name or studio": "Ihr Name oder Studio", + "Anchored to": "Verankert an", + "Slide": "Folie", + "Render behind image": "Hinter dem Bild rendern", + "Rating stars": "Sternebewertung", + "Text overlays": "Textüberlagerungen", + "Add text overlay": "Textüberlagerung hinzufügen", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Token: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Anderer Text erscheint wie eingegeben.", + "Use export watermark": "Export-Wasserzeichen verwenden", + "The export watermark, drawn on the photo and sized relative to it.": "Das Export-Wasserzeichen, auf das Foto gezeichnet und relativ zu ihm skaliert.", + "Titles": "Titel", + "Intro screen": "Intro-Bildschirm", + "Ending screen": "Abschlussbildschirm", + "Shows": "Zeigt", + "Colour only": "Nur Farbe", + "Override colour": "Farbe überschreiben", + "Own duration (off: the slide duration)": "Eigene Dauer (aus: die Dia-Dauer)", + "Duration": "Dauer", + "Title text": "Titeltext", + "Intro": "Intro", + "Ending": "Abschluss", + "End": "Ende", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Das Intro läuft einmal vor dem ersten Foto, der Abschluss nach dem letzten Foto, wenn Wiederholen aus ist. Die Erkennungstafel ist die unter Überlagerungen eingestellte. Titel werden wie Dias überblendet, und die zufällige Reihenfolge verschiebt sie nie.", + "This screen is off: turn it on under Titles to play it.": "Dieser Bildschirm ist aus: Schalten Sie ihn unter Titel ein, um ihn abzuspielen.", + "The intro screen, before the first photo.": "Der Intro-Bildschirm, vor dem ersten Foto.", + "The ending screen, after the last photo.": "Der Abschlussbildschirm, nach dem letzten Foto.", + "Templates": "Vorlagen", + "Built-in": "Integriert", + "Your templates": "Eigene Vorlagen", + "None yet: save the current settings as a template below.": "Noch keine: Speichern Sie die aktuellen Einstellungen unten als Vorlage.", + "Before the last template": "Vor der letzten Vorlage", + "Previous settings": "Vorherige Einstellungen", + "New template name": "Name der neuen Vorlage", + "Save as Template": "Als Vorlage speichern", + "Import…": "Importieren…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Eigene Vorlagen brauchen einen Einstellungsordner, den diese Version nicht hat; die integrierten funktionieren.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Klicken Sie auf eine Vorlage, um sie anzuwenden; zeigen Sie auf eine, um sie am ausgewählten Foto in der Vorschau zu sehen. Rechtsklicken Sie auf eigene, um sie zu aktualisieren, umzubenennen, zu exportieren oder zu löschen.", + "Import Slideshow Templates": "Diashow-Vorlagen importieren", + "Export Slideshow Template": "Diashow-Vorlage exportieren", + "Slideshow Templates": "Diashow-Vorlagen", + "Plain Black": "Schlicht schwarz", + "Gallery Wall": "Galeriewand", + "Edge to Edge": "Randlos", + "Cinema Wide": "Kino-Breitbild", + "Shooting Data": "Aufnahmedaten", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Jedes Foto so groß wie möglich auf Schwarz, mit kurzer Überblendung. Nichts darüber gezeichnet.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Eine hellgraue Wand mit weißem Passepartout und weichem Schatten; Bildunterschrift und Bewertung unter dem Foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Jedes Foto auf den ganzen Bildschirm beschnitten, mit langsamen Überblendungen.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Ein 16:9-Bild mit schwarzen Balken oben und unten, dem Titel in der Ecke, Blenden über Schwarz und einer Abschlusstafel.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Ein dunkler Schieferhintergrund mit Belichtung, ISO und Brennweite, darunter Kamera und Objektiv, unter jedem Foto.", + "Exporting slideshow": "Diashow wird exportiert", + "Export Slideshow…": "Diashow exportieren…", + "Export Slideshow": "Diashow exportieren", + "Export Slideshow: folder for the JPEG files": "Diashow exportieren: Ordner für die JPEG-Dateien", + "Frame rate": "Bildrate", + "Container": "Container", + "Export PDF…": "PDF exportieren…", + "Export JPEG…": "JPEG exportieren…", + "Export Video…": "Video exportieren…", + "Automatically show full screen": "Automatisch im Vollbild anzeigen", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Eine Seite pro Folie (lange Seite in Pixeln = Punkte), mit Foliendauer und Überblendung für den Präsentationsmodus des Betrachters.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Nummerierte JPEG-Dateien (name-001.jpg…) in einem neuen Ordner, der im nächsten Schritt benannt wird.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1-Video, jede Folie für ihre Dauer mit ihren Überblendungen. Kein Ton: Musik wird noch nicht exportiert.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Die Folien verwenden Layout, Überlagerungen sowie Intro- und Abspannbild der Diashow, in der Reihenfolge der Ansicht.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Die Fotos in der Ansicht als Folien, mit den Einstellungen oben. Der Fortschritt erscheint in der Aktivitätsliste.", + "Checking raw files": "RAW-Dateien werden geprüft", + "1 raw can now be developed from its raw data": "1 RAW-Datei kann jetzt aus ihren Rohdaten entwickelt werden", + "This raw is now developed from its raw data": "Diese RAW-Datei wird jetzt aus ihren Rohdaten entwickelt", + "This raw variant still can't be decoded": "Diese RAW-Variante kann noch nicht dekodiert werden", + "No lens data embedded in this file (DNG lens opcodes only).": "Keine Objektivdaten in dieser Datei eingebettet (nur DNG-Objektiv-Opcodes).", + "Export to": "Exportieren nach", + "Specific folder": "Bestimmter Ordner", + "Same folder as original photo": "Im selben Ordner wie das Originalfoto", + "Tidying the thumbnail cache": "Miniaturcache wird aufgeräumt", + "Reset all edits (Cmd+Shift+R)": "Alle Bearbeitungen zurücksetzen (⌘⇧R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Intelligente Alben aktualisieren sich selbst: Ändern Sie ihre Regeln oder verwenden Sie Foto → Foto löschen", + "LightCraft stopped": "LightCraft wurde beendet", + "Show Focus Point": "Fokuspunkt anzeigen", + "Zoom to Focus Point": "Auf Fokuspunkt zoomen", + "Focus": "Fokus", + "Focus point": "Fokuspunkt", + "Wide": "Breit", + "Center": "Mitte", + "Flexible Spot": "Flexibler Spot", + "Zone": "Feld", + "Expanded Flexible Spot": "Erweiterter flexibler Spot", + "Custom AF Area": "Benutzerdefinierter AF-Bereich", + "Face tracking": "Gesichtsverfolgung", + "Lock On AF": "Lock-on-AF", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Tracking", + "Face Tracking": "Gesichtsverfolgung", + "Animal Eye Tracking": "Tieraugen-Tracking", + "Human Eye Tracking": "Augen-Tracking (Mensch)", + "Read Focus Points": "Fokuspunkte lesen", + "Reading focus points": "Fokuspunkte werden gelesen", + "Click to type a value": "Klicken, um einen Wert einzugeben", + "Run face models on the CPU only": "Gesichtsmodelle nur auf der CPU ausführen", + "Detection": "Erkennung", + "Recognition": "Wiedererkennung", + "Not loaded yet": "Noch nicht geladen", + "Checking the GPU…": "GPU wird geprüft …", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Ein Gesichtsmodell nutzt die GPU nur, wenn eine Prüfung zeigt, dass sie dieselben Ergebnisse schneller liefert.", + "Checking the GPU for faces": "GPU für Gesichter wird geprüft" } diff --git a/crates/ui-egui/locales/es-formats.json b/crates/ui-egui/locales/es-formats.json index 452fc6621..5ea0e969a 100644 --- a/crates/ui-egui/locales/es-formats.json +++ b/crates/ui-egui/locales/es-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Generar vistas previas de tamaño estándar ({scope})", "Building previews for {} photos…": "Generando vistas previas de {} fotos…", "Click {}:1": "Clic {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espacio de color: Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "No se pudo abrir el editor: {e}", "Create {n} Virtual Copies": "Crear {n} copias virtuales", "Creates {} stacks from {} of {scope}": "Crea {} pilas a partir de {} de {scope}", @@ -61,6 +62,7 @@ "Import cancelled · {} photo{} added": "Importación cancelada · {0} foto{1} añadida{1}", "Import failed: {e}": "Error de importación: {e}", "Import warnings ({n}):\n{warnings}": "Avisos de importación ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "No se pudieron escribir los archivos XMP de {n} fotos (¿su carpeta está desconectada?). LightCraft lo sigue intentando.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "El perfil XMP \"{profile}\" no se aplicó a {path}; los colores pueden diferir de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometría de Lightroom no se conservó para {path}; {mode} se recalculará.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometría de Lightroom no se conservó para {path}; la transformación guardada se ignora porque Vertical está desactivado.", @@ -161,6 +163,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} coincidente{0} · se actualiza automáticamente cuando cambian las fotos", "{n} photo{} now · updates automatically as photos change": "{n} foto{} ahora · se actualiza automáticamente cuando cambian las fotos", + "{n} selected photos · {pages} pages": "{n} fotos seleccionadas · {pages} páginas", "{pid} (missing)": "{pid} (falta)", "{sel_n} selected · {counted}": "{sel_n} seleccionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -235,6 +238,10 @@ "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct} %", "+{n} more": "+{n} más", + "Create the keyword “{name}”": "Crear la palabra clave “{name}”", + "new, inside {parent}": "nueva, dentro de {parent}", + "Keywords of {n} selected photos": "Palabras clave de {n} fotos seleccionadas", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” quitada de {n} fotos. Editar → Deshacer la devuelve.", "1 task is still running: {names}. Quitting stops it.": "1 tarea sigue en curso: {names}. Salir la detiene.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tareas siguen en curso: {names}. Salir las detiene.", "{what} merge cancelled": "Combinación {what} cancelada", @@ -245,7 +252,48 @@ "{n} added to Apple Photos album “{album}”": "{n} añadidas al álbum “{album}” de Fotos de Apple", "{n} added to Apple Photos": "{n} añadidas a Fotos de Apple", "{n} photos opened for editing; each is stacked with its original": "{n} fotos abiertas para editar; cada una está apilada con su original", + " in {g} group{}": " en {g} grupo(s){:.0}", + " · kept, not applied: {}": " · conservado, no aplicado: {}", + " — kept but not applied: {}": " — se conservan pero no se aplican: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a «{mask}» en las fotos seleccionadas", + "Delete the effect preset “{name}”?": "¿Eliminar el ajuste preestablecido de efecto «{name}»?", + "Delete these {n} effect presets?": "¿Eliminar estos {n} ajustes preestablecidos de efecto?", + "Delete {n} effect preset{}? This can't be undone.": "¿Eliminar {n} ajuste(s) preestablecido(s) de efecto?{:.0} No se puede deshacer.", + "Effect: {name}": "Efecto: {name}", + "Exported {} effect preset{}": "Ajustes de efecto exportados: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efecto(s) de máscara importado(s){:.0} (Máscaras → Efecto)", + "Update “{name}” with Current Settings": "Actualizar «{name}» con los ajustes actuales", + "{n} preset{}": "{n} ajuste(s){:.0}", "{name} opened in the other editor": "{name} abierto en el otro editor", "{n} originals opened in the other editor": "{n} originales abiertos en el otro editor", - "· {in_source} identical copies": "· {in_source} copias idénticas" + "· {in_source} identical copies": "· {in_source} copias idénticas", + "{count} more warnings.": "{count} advertencias más.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros del catálogo procesados; {collections} colecciones.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Faltan {missing} originales. Sus registros del catálogo están disponibles; usa Buscar fotos que faltan para volver a vincularlos.", + "Applied {name}": "Se aplicó {name}", + "Delete “{name}”?": "¿Eliminar «{name}»?", + "Updated {name}": "Se actualizó {name}", + "Exported to {path}": "Exportado a {path}", + "Saved template {name}": "Plantilla {name} guardada", + "Imported {name}": "Se importó {name}", + "Skipped {problem}": "Omitido: {problem}", + "…and {more} more": "…y {more} más", + "Template preview: {name}": "Vista previa de la plantilla: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos en la vista · {slides} diapositivas · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} archivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Presentación exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} omitidas (no se pudieron representar)", + "{n} raws can now be developed from their raw data": "{n} RAW ya se pueden revelar a partir de sus datos RAW", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft se detuvo inesperadamente: {e}\n\nLos detalles están en el archivo de registro: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft se detuvo inesperadamente: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Cambia el nombre de {n} cara{} en toda la biblioteca. Los archivos no se modifican.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” ya es una persona. Combinarlas deja una sola; Deshacer las separa de nuevo.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Combinación {what} añadida a {mp}: el tamaño completo, {full}, necesita más memoria de la que tiene este ordenador", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Combinación {what} añadida a {mp}, el tamaño máximo de una panorámica (tamaño completo: {full})", + "Already in “{}”": "Ya está en “{}”", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} a “{1}”", + "Move {n} photo{} to “{}”?": "¿Mover {n} foto{0} a “{1}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} a “{1}”. Editar → Deshacer las devuelve." } diff --git a/crates/ui-egui/locales/es.json b/crates/ui-egui/locales/es.json index ba3e1e331..77ba4c08e 100644 --- a/crates/ui-egui/locales/es.json +++ b/crates/ui-egui/locales/es.json @@ -8,11 +8,13 @@ "+ Rule": "+ Regla", "0: nothing is rejected.": "0: no se rechaza nada.", "1 day": "1 día", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRGB · ediciones actuales · ajustado sin recortar", "2026-04 or 2026-04-12": "2026-04 o 2026-04-12", "= exactly": "= exactamente", "A blank template keeps the original names.": "Una plantilla vacía conserva los nombres originales.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro oculta las fotos de esta vista: cambiar el filtro o borrarlo (Vista → Borrar filtros)", "A one-time download of about": "Una única descarga de aproximadamente", + "A pen's pressure sets the size and flow of the brush": "La presión de un lápiz determina el tamaño y el flujo del pincel", "AI Mask Model Status": "Estado del modelo de máscaras con IA", "About": "Acerca de", "About LightCraft": "Acerca de LightCraft", @@ -38,6 +40,9 @@ "Add to Favorites": "Añadir a Favoritos", "Add to Local": "Añadir a Local", "Add to My Photos": "Añadir a Mis fotos", + "Can't be moved here": "No se pueden mover aquí", + "This row covers more than one disk: drop on a folder inside it": "Esta fila abarca más de un disco: suéltalas en una carpeta dentro de ella", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Las fotos locales aún no están en la biblioteca: primero Añadir a Mis fotos y luego muévelas", "Add to Selected Photos": "Añadir a las fotos seleccionadas", "Add to Target Album": "Añadir al álbum de destino", "Add to this mask": "Añadir a esta máscara", @@ -126,7 +131,7 @@ "Back to Edit (E)": "Volver a Editar (E)", "Back to Grid": "Volver a la cuadrícula", "Background": "Fondo", - "Balance": "Balance", + "Balance": "Equilibrio", "Basic": "Básico", "Before": "Antes", "Before/After (Y)": "Antes/Después (Y)", @@ -180,6 +185,9 @@ "Cancel Face Model Download": "Cancelar la descarga del modelo de caras", "Cancel Interaction": "Cancelar interacción", "Cancel Pending Photo Action": "Cancelar la acción pendiente sobre la foto", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Fotos CR2 de Canon de las EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II y 1D Mark IV (y otros", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Los archivos CR3 de Canon muestran su JPEG incrustado a tamaño completo (p. ej., 6960 × 4640 en una EOS R6 Mark III) en lugar de la", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Los archivos C-RAW de la Canon EOS R5 Mark II que se abrían como «solo vista previa» (más de la mitad, según la", "Cancel Preview Build": "Cancelar generación de vistas previas", "Candidate": "Candidata", "Caption": "Pie de foto", @@ -201,7 +209,14 @@ "Choose Edit Settings to Copy…": "Elegir ajustes de edición para copiar…", "Choose Face Recognition Model": "Elegir el modelo de reconocimiento facial", "Choose Folder…": "Elegir carpeta…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Elige dónde guardar el PDF después de confirmar. Imprime el PDF desde tu visor.", "Choose a model below (Use) to start.": "Elegir un modelo abajo (Usar) para empezar.", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Mayús+R, conserva la foto y borra sus ediciones).", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtro de etiquetas de color con varias etiquetas a la vez; árbol de carpetas desplegable en Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Máscaras de rango de color: hacer clic en la foto para tomar una muestra. Rangos de luminancia: barra de rango, suavizado, mapa de luminancia.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "NEF de Nikon comprimidos (con y sin pérdida, de 12 y 14 bits; p. ej., 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": "Convertir a DNG, Copiar como DNG, Combinar fotos y las vistas previas inteligentes ya no escriben directamente en el archivo final (issue", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Estado del copyright, condiciones de uso de los derechos y URL de información de copyright en Información, ajustes preestablecidos de metadatos y exportaciones.", "Choose a photo in the grid or filmstrip": "Elegir una foto en la cuadrícula o en la tira de película", "Choose…": "Elegir…", "Cinematic": "Cinematográfico", @@ -252,6 +267,9 @@ "Color Range": "Rango de color", "Color label cleared": "Etiqueta de color eliminada", "Color space": "Espacio de color", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espacio de color: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espacio de color: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espacio de color: Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espacio de color: sRGB (AVIF)", "Colors": "Colores", "Combine files…": "Combinar archivos…", @@ -268,7 +286,14 @@ "Concrete Cool": "Hormigón frío", "Confirm before deleting": "Confirmar antes de eliminar", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover fotos a Eliminadas recientemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Sus archivos y archivos XMP auxiliares se mueven en el disco. Editar → Deshacer los devuelve.", + "Don't ask again": "No volver a preguntar", + "Another library was opened since the photos were dropped: nothing was moved": "Se abrió otra biblioteca después de soltar las fotos: no se movió nada", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos a otra carpeta del disco lo vuelve a activar", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos a otra carpeta del disco", "Constrain Crop": "Restringir recorte", + "Contact Sheet PDF": "PDF de hoja de contactos", "Continue": "Continuar", "Continue Without Saving": "Continuar sin guardar", "Contrast": "Contraste", @@ -408,6 +433,8 @@ "Download the SAM 3 Model?": "¿Descargar el modelo SAM 3?", "Downloads": "Descargas", "Drag Crop Handle": "Arrastrar el tirador de recorte", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ajuste de borde, y reciben en segundo plano una pasada más nítida al ampliar. El modelo se ejecuta dentro de LightCraft en Rust puro", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Editar en editor externo (⇧⌘E): una copia TIFF de 16 bits, apilada, que se actualiza al volver.", "Drag along the horizon; double-click for Auto": "Arrastrar a lo largo del horizonte; doble clic para Automático", "Drag over an eye on the photo; the pupil inside is found automatically.": "Arrastrar sobre un ojo en la foto; la pupila se detecta automáticamente.", "Draw Guides": "Dibujar guías", @@ -450,6 +477,10 @@ "Enter a date as YYYY-MM-DD HH:MM:SS": "Introducir una fecha como AAAA-MM-DD HH:MM:SS", "Erase": "Borrar", "Evaluate Face Recognition": "Evaluar el reconocimiento facial", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Los nombres de los archivos exportados usan las etiquetas de Cambiar nombre de las fotos ({title}, {seq:2}, {date:%Y-%m-%d}…), más las nuevas {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": "Los archivos exportados se escriben en un archivo temporal y luego se renombran en su lugar, así que un disco lleno o una unidad desconectada", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Las exportaciones y los procesados nunca sobrescriben el original de una foto (issue #93): exportar a la propia carpeta de la foto con", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Las exportaciones nunca salen negras por culpa de la GPU (issue #78): un procesado en GPU que se queda sin", "Every EXIF, GPS and XMP field in the file": "Todos los campos EXIF, GPS y XMP del archivo", "Existing previews": "Vistas previas existentes", "Exit": "Salir", @@ -487,6 +518,7 @@ "Face recognition": "Reconocimiento facial", "Faces": "Caras", "Faces of a Person": "Caras de una persona", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Exportaciones e importaciones desde tarjeta más rápidas, con la misma protección para lo que no se puede volver a crear (issue #134): las exportaciones,", "Faded Matte": "Mate desvaído", "Faded Slide": "Diapositiva desvaída", "Faded Urban": "Urbano desvaído", @@ -504,6 +536,7 @@ "File name": "Nombre de archivo", "File types:": "Tipos de archivo:", "Filename": "Nombre de archivo", + "Filename captions": "Pies con el nombre de archivo", "Fill": "Rellenar", "Fill Edges": "Rellenar bordes", "Film": "Película", @@ -563,6 +596,10 @@ "Friday": "Viernes", "Full": "Completa", "Full Screen Preview": "Vista previa a pantalla completa", + "Hold View Until Refreshed": "Mantener vista hasta actualizar", + "HDR TIFF needs 32-bit float.": "El TIFF HDR necesita coma flotante de 32 bits.", + "HDR output": "Salida HDR", + "Letter": "Carta", "Lights Out": "Luces apagadas", "Full screen mode": "Modo de pantalla completa", "Full size": "Tamaño completo", @@ -578,6 +615,11 @@ "Global Saturation": "Saturación global", "Glossy": "Brillante", "Go back to listing them by name": "Volver a la lista por nombre", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Si la biblioteca no se puede abrir al iniciar (abierta en otro programa, ilegible, en una unidad no conectada,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importar ajustes preestablecidos de otros editores: ajustes XMP, archivos clásicos `.lrtemplate`, «ajustes DNG» de aplicaciones móviles y paquetes `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importar → Copiar verifica cada copia, como Mover (issue #96): cada archivo se escribe como un archivo nuevo, se sincroniza con el disco y", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importar → Mover: las fotos van al destino (con las mismas carpetas y cambios de nombre que Copiar, p. ej.,", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Importación: copiar a cualquier carpeta, por día / por mes / en una carpeta, cambiar el nombre al importar, ajuste preestablecido de metadatos, Copiar como DNG.", "Go to History Step": "Ir al paso del historial", "Golden": "Dorado", "Golden Glow": "Resplandor dorado", @@ -619,6 +661,7 @@ "HDR panorama": "Panorámica HDR", "HDR panoramas": "panorámicas HDR", "HDR with Last Settings": "HDR con los últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada horquillado", "HDR…": "HDR…", "Has Edits": "Con ediciones", "Has GPS": "Con GPS", @@ -741,6 +784,10 @@ "Level": "Nivel", "Library": "Biblioteca", "Library Folders": "Carpetas de la biblioteca", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 y anteriores la rechazan («unsupported format … v2»).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft ya no se bloquea al iniciar en PC con Windows cuyo controlador Vulkan está dañado (issue #136, p. ej., algunas Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "Los archivos DNG con pérdida y las vistas previas inteligentes se abren como fotos raw.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Looks de Luminar: los archivos `.lmp` y las colecciones `.mplumpack` se importan como ajustes preestablecidos (agrupados por colección); los reguladores", "Library Info": "Información de la biblioteca", "Library Originals": "Originales de la biblioteca", "Library Preferences": "Preferencias de la biblioteca", @@ -880,6 +927,13 @@ "Name (A–Z)": "Nombre (A–Z)", "Name Face": "Poner nombre a la cara", "Name Faces": "Poner nombre a las caras", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Los NEF de Nikon parten de un aspecto de color y tono ajustado al JPEG de la propia cámara, como los ARW de Sony, en lugar de un procesado neutro apagado", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon divide en dos mitades comprimidas de forma distinta siguen usando la vista previa por ahora).", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Los raw de Panasonic y Leica (RW2, RWL y los archivos RAW antiguos, de la DMC-LX1 a la DC-S1R II) se revelan a partir de los datos raw en", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menú Foto. La tira de película también tiene el menú contextual de la foto. Volver a añadir un archivo que está en Eliminadas recientemente ya no", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Las fotos de Eliminadas recientemente se pueden restaurar desde la aplicación: clic derecho → Restaurar (o Eliminar definitivamente), también en el", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "fotos que faltan); la importación (también arrastrando y soltando), Buscar fotos que faltan, Generar / Descartar vistas previas inteligentes, la vista previa de Cambiar nombre,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Colección rápida y álbum de destino (B en la cuadrícula), conjuntos de palabras clave (⌥1–⌥9), conjuntos de etiquetas de color.", "Name of the folder the original is in": "Nombre de la carpeta en la que está el original", "Named": "Con nombre", "Named People": "Personas con nombre", @@ -920,7 +974,7 @@ "No edits yet.": "Aún no hay ediciones.", "No exposure information": "Sin información de exposición", "No filter presets yet": "Aún no hay ajustes preestablecidos de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).", + "No lens data embedded in this file.": "Este archivo no incluye datos de objetivo.", "No masks yet. Choose a mask type above.": "Aún no hay máscaras. Elegir un tipo de máscara arriba.", "No matching commands": "Ningún comando coincide", "No matching photos": "No hay fotos coincidentes", @@ -931,6 +985,11 @@ "No new presets": "No hay ajustes preestablecidos nuevos", "No photo selected": "No hay ninguna foto seleccionada", "No photos": "Sin fotos", + "Your library is empty": "Tu biblioteca está vacía", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Las fotos que importes aparecen aquí. No se copia ni se mueve nada a menos que lo elijas.", + "or drop photos and folders into this window": "o arrastra fotos y carpetas a esta ventana", + "Import photos to get started": "Importa fotos para empezar", + "or drop photos into this window": "o arrastra fotos a esta ventana", "No photos found": "No se encontraron fotos", "No photos in this folder": "No hay fotos en esta carpeta", "No photos match the active filters": "Ninguna foto coincide con los filtros activos", @@ -1006,6 +1065,7 @@ "Paste from Previous": "Pegar de la anterior", "Paste pages": "Pegar páginas", "Pastel Haze": "Bruma pastel", + "Pen Pressure": "Presión del lápiz", "People": "Personas", "Percent": "Porcentaje", "Percentage": "Porcentaje", @@ -1020,6 +1080,7 @@ "Photo Merge": "Combinación de fotos", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Editar en el editor externo (⇧⌘E) renderiza una copia TIFF de 16 bits, la apila con el original y la abre aquí (un nombre de aplicación en macOS, una ruta de programa en otros sistemas).", "Photos": "Fotos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas en HDR: JPEG con mapa de ganancia HDR (se ve bien en todas partes, con luces más brillantes en pantallas HDR), AVIF a 10 bits, PNG y JPEG XL a 16 bits Rec. 2020 PQ; TIFF necesita coma flotante de 32 bits", "Photos per bracket (0 = auto)": "Fotos por horquillado (0 = automático)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Las fotos vistas en Local, pero nunca añadidas ni modificadas, salen del catálogo cuando su carpeta no se ha explorado durante este tiempo (se comprueba al abrir la biblioteca). Los archivos permanecen en el disco; al explorar la carpeta vuelven a aparecer.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Las fotos que llegan a esta carpeta (captura conectada, un escáner, una aplicación de sincronización) se añaden en cuanto están completas.", @@ -1145,6 +1206,7 @@ "Refining the mask's detail…": "Refinando el detalle de la máscara…", "Refresh Source": "Actualizar origen", "Refresh Source (/)": "Actualizar origen (/)", + "Refresh View": "Actualizar vista", "Reject": "Rechazar", "Reject below focus": "Rechazar por debajo del enfoque", "Rejected": "Rechazada", @@ -1163,6 +1225,9 @@ "Remove Disk from Library…": "Quitar disco de la biblioteca…", "Remove Face Box": "Quitar recuadro de cara", "Remove Face Model": "Quitar modelo de caras", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Cambiar nombre de carpeta… y Mover carpeta a… (Local) se pueden deshacer y rehacer (issue #97): la carpeta vuelve a su sitio en el disco con", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Cambiar nombre de las fotos nunca sobrescribe otra foto cuando solo cambian las mayúsculas y minúsculas (issue #95): en", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Cambiar nombre de las fotos indica los archivos que no pudo devolver a su sitio tras un fallo (issue #105), p. ej., cuando una carpeta de red se desconecta", "Remove Folder from Library": "Quitar carpeta de la biblioteca", "Remove a filter above, or choose Clear all": "Quitar un filtro arriba o elegir Borrar todo", "Remove from Album": "Quitar del álbum", @@ -1198,6 +1263,8 @@ "Rendering": "Renderizado", "Rendering…": "Renderizando…", "Reorder Album": "Reordenar álbum", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Guardar metadatos en un archivo XMP complementario escrito por otra aplicación ya no lo sustituye (issue #92): LightCraft combina sus", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Los archivos de ajustes nunca se restablecen en silencio (issue #103): un `prefs.json`, `presets.json` o `view.json` dañado se conserva", "Reorder Masks": "Reordenar máscaras", "Reset": "Restablecer", "Reset All": "Restablecer todo", @@ -1212,7 +1279,7 @@ "Reset Section": "Restablecer sección", "Reset Tone Curve": "Restablecer curva de tonos", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Restablecer todas las curvas: curvas de puntos (todos los canales) y paramétrica (doble clic en un canal para restablecer solo ese)", - "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", + "Reset all edits": "Restablecer todas las ediciones", "Reshuffle": "Volver a mezclar", "Resize Face Box": "Cambiar tamaño del recuadro de cara", "Resize panel window": "Cambiar tamaño de la ventana del panel", @@ -1356,6 +1423,12 @@ "Show Mask Pins": "Mostrar chinchetas de máscara", "Show Original": "Mostrar original", "Show Person": "Mostrar persona", + "Slideshow, second window, All Metadata, System Info.": "Presentación, segunda ventana, Todos los metadatos, Información del sistema.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catálogos más pequeños y rápidos: las fotos que solo se vieron en Local (nunca añadidas, valoradas ni editadas) se olvidan cuando", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Álbumes inteligentes con editor de reglas: coincidir con todas / alguna / ninguna, grupos anidados, 26 campos.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Los ARW de Sony de antes de 2017 aproximadamente (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II y sus", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Los ARW sin pérdida reducidos de la Sony ILCE-7M4 ahora descodifican los mosaicos YCbCr submuestreados a RGB lineal,", + "Sort Albums A–Z": "Ordenar álbumes A–Z", "Show Photo Counts": "Mostrar recuento de fotos", "Show Photos with Keyword": "Mostrar fotos con la palabra clave", "Show Pins": "Mostrar chinchetas", @@ -1372,6 +1445,7 @@ "Show overlay (O)": "Mostrar superposición (O)", "Show ratings, flags and edit badges": "Mostrar valoraciones, marcas e indicadores de edición", "Show the Navigator while zoomed in": "Mostrar el Navegador al ampliar", + "Show the import review when a camera or card is connected": "Abrir la importación al conectar una cámara o tarjeta", "Show the template tags; click one to insert it": "Mostrar las etiquetas de plantilla; hacer clic en una para insertarla", "Show/Hide Mask": "Mostrar/ocultar máscara", "Show/hide My Photos panel": "Mostrar/ocultar panel Mis fotos", @@ -1399,8 +1473,10 @@ "Soft Pastel": "Pastel suave", "Soft Proofing": "Prueba en pantalla", "Soft Skin": "Piel suave", + "Solo Mode": "Modo solo", "Sort": "Ordenar", "Sort Albums by Name": "Ordenar álbumes por nombre", + "Sort Contents A–Z": "Ordenar contenido A–Z", "Sort by": "Ordenar por", "Source": "Origen", "Source code on GitHub": "Código fuente en GitHub", @@ -1437,6 +1513,7 @@ "Suggest Names for Faces": "Sugerir nombres para las caras", "Suggestions": "Sugerencias", "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Sugiere quién aparece en una foto a partir de las caras con nombre. Todo se queda en el equipo.", + "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": "series TZ/ZS, las Leica D-Lux, V-Lux y C-Lux, y la generación GH6, GH7, G9 II, S5 II y S9. Parten de un", "Summer Haze": "Bruma estival", "Sunday": "Domingo", "Survey": "Encuesta", @@ -1519,6 +1596,101 @@ "Unknown Date": "Fecha desconocida", "Unknown tags stay as typed — check the preview for a {typo}.": "Las etiquetas desconocidas se quedan tal como se escribieron: revisar la vista previa por si hay un {typo}.", "Unnamed Faces": "Caras sin nombre", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Importación automática desde carpeta vigilada; Convertir a DNG; Duplicar; Generar vistas previas estándar / 1:1 / inteligentes.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "sin guardar y Salir (issue #100). Ya no inicia en silencio una sesión de demostración que parecía una biblioteca restablecida y", + "`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`) junto con sus archivos XMP complementarios; cada original sale de la tarjeta solo cuando su", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; en volúmenes que no distinguen mayúsculas, el cambio de mayúsculas se aplica igualmente.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "una unidad llena) ya no cuenta como generada y Generar vistas previas inteligentes la sustituye.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "después (issue #101): la escritura parcial se recorta antes de que LightCraft lo reintente, así que el siguiente inicio reproduce cada", + "again. Change the period (or turn it off) in Settings → Performance.": "de nuevo. Cambiar el periodo (o desactivarlo) en Ajustes → Rendimiento.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "con los originales de la biblioteca ya no recorre toda la biblioteca cuando no hay nada en esa ruta.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ya tiene una carpeta `thumbs/` con otras imágenes ya no las pierde al recortar o vaciar la caché.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "y comprueba el archivo antes de usarlo. Las máscaras conservan su selección, así que se procesan y exportan sin el modelo.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "y todas las fotos importadas en un zip, y Archivo → Restaurar biblioteca desde copia de seguridad… la recupera (la biblioteca actual", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "y nunca bloquea la ventana. Es opcional y no forma parte de LightCraft (licencia SAM de Meta, unos 3,4 GB): la", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "como `<name>.corrupt-<time>` y se avisa; uno que no se puede leer (p. ej., bloqueado por otro programa) se deja como está", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "se pida. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (o `WGPU_BACKEND` de wgpu, que el procesado en GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "con cualquiera de ellos, carpetas enteras de una vez, agrupados por paquete. Las máscaras de los ajustes preestablecidos se incluyen.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "cámara ya no queda incorporado. Las fotos ya importadas como «solo vista previa» cambian al recargarlas. (Los archivos que", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "cambio. Los catálogos que ya contienen un fragmento así se cargan por completo. Salir mientras no se puede escribir el registro del catálogo", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "no se pudieron guardar los cambios lo intenta una vez más y luego pregunta: Volver a intentar guardar, Salir de todos modos o Cancelar.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "o de MCP, una ruta de vista previa de combinación o `lightcraft-cli render IMG.jpg -o IMG.jpg` se rechaza con un", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "se compara con el contenido leído de la tarjeta. Una copia que falla o difiere se elimina y se indica como", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "aspecto de color y tono ajustado al JPEG de la propia cámara, como los raw de Nikon y Sony, encuadrado en la relación de aspecto definida en", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "copia está verificada y en la biblioteca. Los duplicados y los archivos que fallan se quedan donde estaban.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "aún no se descodifican, así que se quedan como solo vista previa. Para los CR3 importados antes, Foto → Recargar desde el disco (ahora en", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "se recortan, y los archivos «12 bits sin comprimir» ya no se recortan en las luces. La serie RX100 se ve mucho más cerca", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "todos los formatos que escriben las cámaras. La mayoría de los cuerpos se abrían antes como solo vista previa: GH1–GH5, las series G, GX, GF, GM, FZ, LX y", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "exportación. El catálogo, los archivos XMP complementarios, los ajustes, los DNG, las combinaciones, las vistas previas inteligentes y las copias que hace una importación siguen", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "campos y conserva el resto (p. ej., los ajustes de revelado y el historial de edición de esa aplicación) byte a byte. Un", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "archivo (en estos cuerpos Sony los guarda solo en datos cifrados de la nota del fabricante), las pocas columnas de relleno del borde", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "primera vez que se usa una máscara con IA, LightCraft pregunta antes de descargarlo, muestra el progreso, permite cancelar y reanudar,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "durante la sesión en lugar de sobrescribirse con los valores predeterminados. Los ajustes de la aplicación (`ui.json`, que recuerda la", + "gets -1, -2… instead of being replaced.": "recibe -1, -2… en lugar de sustituirse.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "y verdoso (issue #150); el balance de blancos se ajusta respecto al aspecto de toma. Los NEF de 12 bits", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "tiene abierta (en este ordenador u otro que comparte la carpeta) indica quién la tiene («already open in LightCraft (process", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "cómo restaurarla o eliminarla definitivamente e importarla de nuevo. (Para empezar de cero con una foto, Restablecer ediciones,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ignoraba antes) elige el backend gráfico; `off` procesa en la CPU. La GPU ahora se inicia después de que la ventana", + "immutable, and HOSTING.md describes the actual build.": "como inmutables, y HOSTING.md describe la compilación real.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importación, de modo que «importación completada» significa que las copias están bien antes de formatear la tarjeta, y un nombre ocupado", + "imported as \"preview only\" switch over on Reload.": "importadas como «solo vista previa» cambian al recargarlas.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "importadas antes cambian con Foto → Recargar desde el disco.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "en trozos cortos para que el watchdog no reinicie las GPU integradas lentas. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "incompleto se rehace en la CPU; el archivo es la misma imagen en ambos casos. El trabajo se envía a la GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "se conserva). Un guardado fallido (almacenamiento lleno) ahora muestra el aviso de cambios sin guardar y se reintenta, una foto que no se puede almacenar", + "is refused and nothing is overwritten.": "se rechaza y no se sobrescribe nada.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "aparece, y solo cuando Ajustes → Rendimiento → Usar la GPU para procesar está activado; si LightCraft se cierra inesperadamente al", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "no se añade, una segunda pestaña muestra un mensaje en lugar de sobrescribir la primera, `?reset` pregunta antes y la página indica", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "al otro (issue #92). Buscar fotos que faltan también encuentra archivos renombrados por su contenido, prefiere una coincidencia de contenido", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "sus fotos y archivos XMP complementarios, y las fotos vuelven a apuntar a ella. Si ahora algo ocupa el lugar anterior, deshacer", + "layout is read from each file instead of assumed.": "disposición se lee de cada archivo en lugar de suponerse.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "no deja ningún DNG y conserva el raw. Las vistas previas inteligentes se escriben igual; una dañada (cortada por un bloqueo o", + "library locked: the lock is the operating system's and goes away with the program.": "biblioteca bloqueada: el bloqueo es del sistema operativo y desaparece con el programa.", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteca) se escriben de forma atómica y se guardan en cuanto se abre otra biblioteca, no solo al salir. Salir cuando", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "se limita a decir «duplicado omitido»: abre el panel lateral en Eliminadas recientemente con la foto seleccionada e indica", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "lo perdía todo al salir; una sesión temporal muestra un aviso todo el tiempo y nunca escribe en la biblioteca.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "memoria del dispositivo, supera los límites de búfer de la GPU, sufre un error o reinicio del controlador, o vuelve", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "mensaje claro, y el original queda intacto byte a byte. Las exportaciones anteriores normales se siguen sobrescribiendo cuando se pide.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "a mitad del lote: el error los enumera (antiguo → nuevo) y la biblioteca apunta a sus nuevos nombres (un cambio de nombre parcial que se puede deshacer),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modelos cuyo sensor empieza por una fila verde-azul) ya no salen magenta (issue #85): la disposición del filtro de color", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nombre (sin sustituir nunca un archivo), y solo entonces se vuelve a vincular la foto o se elimina la copia raw; una escritura fallida", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nunca dejan un archivo truncado; el archivo XMP complementario de una exportación «Original» también sigue la opción «Si el archivo existe».", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ahora se revelan a partir de los datos raw en lugar del JPEG incrustado de la cámara, así que un estilo de imagen ByN u otro definido en la", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "en la tarjeta después de Revisar importación se indica en lugar de importarse con datos obsoletos. Comprobar una ruta de exportación", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "uno a uno: siempre se pueden volver a exportar, y en una unidad USB o un NAS esa sincronización por archivo dominaba una gran", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "a un archivo parecido con el mismo nombre y tamaño, y omite (e indica) las fotos que no puede distinguir en lugar de adivinar.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "parte fuera), o escribir qué seleccionar («cielo», «el coche rojo», «coche, carretera»); ambas se combinan con otras máscaras, tienen un", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "conservando la edición RAW y la exportación a resolución completa en lugar de usar las vistas previas JPEG incrustadas.", + "read in the wrong units.": "leía en unidades incorrectas.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "leer la tarjeta una tercera vez (Mover sigue comparando byte a byte antes de eliminar un origen); un archivo que cambió", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "los procesados y las capturas de pantalla se siguen escribiendo en un archivo temporal y renombrando en su lugar, pero ya no se fuerzan al disco", + "save goes through.": "guardado se completa.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "escena) ahora se revelan a partir de los datos del sensor con todas las herramientas de edición, como los demás raw de la cámara. Las fotos", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "modelos hermanos) ya no se abren de color verde intenso (issue #148): su balance de blancos de toma y su nivel de negro se leen del", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "archivo complementario que no es XMP válido se copia antes a `<name>.xmp.bak-<time>`. Con la denominación predeterminada por raíz, un raw y un", + "smart album value|No": "No", + "smart album value|Yes": "Sí", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "para que ninguna aparezca como que falta. Cambiar el nombre de uno de un par raw + JPEG copia su `IMG_0001.xmp` compartido en lugar de quitárselo", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "iniciar la GPU, el siguiente inicio arranca con el procesado en GPU desactivado e indica cómo volver a activarlo.", + "still saves your queued changes in the closing snapshot.": "sigue guardando los cambios en cola en la instantánea de cierre.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "sincronizaban. Importar → Copiar ahora comprueba cada copia con el hash de contenido tomado al explorar la tarjeta en lugar de", + "the JPEG uses `IMG_0001.JPG.xmp`.": "el JPEG usa `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "el árbol de carpetas de Local y la importación automática también leen el disco en hilos de trabajo. La ventana de progreso de la importación tiene Cancelar.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "el menú Foto y el menú contextual de la foto) toma la vista previa a tamaño completo y rellena los metadatos de cámara que", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "la cámara; los defectos del sensor marcados se rellenan. También se importan los archivos `.rwl` y `.raw`. Las fotos ya", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "el mismo nombre y «Sobrescribir» (o Exportar con los ajustes anteriores repitiéndolo), una ruta de salida exacta desde el canal de control", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "su carpeta lleva 30 días sin explorarse; los archivos y sus complementarios se quedan, y explorar la carpeta los muestra", + "to the camera's own JPEG.": "al JPEG de la propia cámara.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "intacta; las versiones anteriores ya no leen parte de ella como un registro dañado. Cuando esta versión ha abierto una biblioteca,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "(p. ej., un adaptador por software omitido como llvmpipe) y por qué el último procesado recurrió a la alternativa.", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "volúmenes que distinguen mayúsculas (Linux, APFS con distinción de mayúsculas) `img_1.JPG` junto a `IMG_1.JPG` es otra foto y la renombrada recibe", + "were missing, without touching anything already set.": "faltaban, sin tocar nada de lo ya definido.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "cuándo el navegador puede expulsar la biblioteca. Alojamiento: los encabezados de caché de ejemplo ya no marcan los archivos (sin hash)", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "si los originales están presentes se comprueba en un hilo de trabajo (miniaturas de la cuadrícula, el panel Información, el menú de la foto,", + "with a counterpart here come along, the rest is listed.": "con un equivalente aquí se incluyen, el resto se enumera.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "escrita por un LightCraft más reciente se rechaza con «this library was written by a newer version of LightCraft» y se deja", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "escrita por un LightCraft más reciente), LightCraft lo indica con el motivo y ofrece Reintentar, Elegir otra biblioteca…, Continuar", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-arrastrar para enderezar, ⇧G Vertical guiado, una cuadrícula al transformar.", "Unrated": "Sin valorar", "Unreadable": "Ilegible", "Update": "Actualizar", @@ -1549,6 +1721,7 @@ "Vibrance": "Intensidad", "Videos": "Vídeos", "View": "Vista", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Los visores que no leen el bloque cICP del PNG muestran los valores PQ como sRGB: planos y desvaídos. Para una imagen que se vea bien en todas partes, usa JPEG (mapa de ganancia).", "Viewing and sharing": "Visualización y uso compartido", "Vignette": "Viñeta", "Vignetting": "Viñeteado", @@ -1581,6 +1754,7 @@ "Will be restored": "Se restaurará", "Window": "Ventana", "Winter Blue": "Azul invernal", + "Wrote the XMP sidecars that were waiting": "Se escribieron los archivos XMP pendientes", "XMP Sidecar Preferences": "Preferencias de archivos XMP complementarios", "XMP sidecars": "Archivos XMP complementarios", "Year": "Año", @@ -1712,6 +1886,7 @@ "Inspect Face Model File": "Inspeccionar el archivo del modelo de caras", "Install Denoise Model": "Instalar modelo de reducción de ruido", "Install Face Model": "Instalar modelo de caras", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG con el mismo nombre (`IMG_0001.CR3` + `IMG_0001.JPG`) ya no comparten un archivo complementario: el raw conserva `IMG_0001.xmp`,", "Install from file…": "Instalar desde archivo…", "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Instalar un modelo ONNX propio con denoise-model.json junto a él. Esta versión no ofrece descargas de modelos.", "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Su imagen sin ruido aún no se ha generado, y las imágenes no se generan por sí solas (Ajustes, Reducción de ruido con IA).", @@ -1779,6 +1954,9 @@ "Remove Denoise Model": "Quitar modelo de reducción de ruido", "Test Denoise Model": "Probar modelo de reducción de ruido", "Test Face Model": "Probar el modelo de caras", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "El catálogo tiene una versión de formato (issue #102). Abrir una biblioteca de un LightCraft anterior la actualiza; una biblioteca", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "El puerto de control (`--control`) cierra una conexión en cuanto recibe algo que no es una solicitud JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "La caché de miniaturas solo cuenta y elimina sus propios archivos (issue #98): una biblioteca abierta en una carpeta que", "AI Denoise is enabled on the requesting photo": "La reducción de ruido con IA está activada en la foto que la pidió", "After installation, AI Denoise will turn on for the requesting photo.": "Tras la instalación, la reducción de ruido con IA se activará en la foto que la pidió.", "Cancel pending action": "Cancelar acción pendiente", @@ -1791,7 +1969,7 @@ "Increase Rating": "Aumentar valoración", "Index Faces": "Indexar caras", "Index Faces in the Background": "Indexar caras en segundo plano", - "Click to type a value": "Hacer clic para escribir un valor", + "Click to type a value, drag to change it": "Hacer clic para escribir un valor, arrastrar para cambiarlo", "Angle": "Ángulo", "Video": "Vídeo", "Copyrighted": "Con copyright", @@ -1855,6 +2033,15 @@ "Keyword List": "Lista de palabras clave", "Filter Keywords": "Filtrar palabras clave", "No keywords yet": "Aún no hay palabras clave", + "Show fewer": "Mostrar menos", + "Whole names": "Nombres completos", + "Grouped by parent": "Agrupadas por palabra clave superior", + "Keywords of the selection": "Palabras clave de la selección", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Agrupadas: las palabras clave dentro de la misma palabra clave comparten una línea con su nombre; las listas largas y las sugerencias se acortan.", + "Show every keyword of the selected photos": "Mostrar todas las palabras clave de las fotos seleccionadas", + "new keyword": "palabra clave nueva", + "Return creates this keyword and gives it to the selected photos": "Intro crea esta palabra clave y la asigna a las fotos seleccionadas", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separa varias palabras clave con comas. Intro las asigna a las fotos seleccionadas.", "No keywords match": "Ninguna palabra clave coincide", "Keyword Name": "Nombre de la palabra clave", "Synonyms": "Sinónimos", @@ -1993,7 +2180,165 @@ "not added to Apple Photos (cancelled)": "no se añadieron a Fotos de Apple (cancelado)", "The first time, macOS asks whether LightCraft may control Photos.": "La primera vez, macOS pregunta si LightCraft puede controlar Fotos.", "Adding to Apple Photos": "Añadiendo a Fotos de Apple", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "«guardado en memoria pero no escrito en el disco», la barra superior muestra un aviso y LightCraft lo sigue intentando hasta que el", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): un DNG se comprueba con los datos raw, se escribe en un archivo temporal, se sincroniza y se vuelve a leer antes de recibir su", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Ayuda → Información del sistema y Ajustes → Rendimiento indican por qué no se usa la GPU", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(p. ej., D750, D780, D850, D7500, Z 50) ya no se ven casi negros ni con las sombras empastadas: su nivel de negro se", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(issue #94): una solicitud HTTP desde una página web ya no puede llevar un comando en su cuerpo. Las líneas se limitan a", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 en studio-mac)») en lugar de dejar que ambos escriban y se descarten en silencio las ediciones. Un bloqueo nunca deja la", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "vista previa de 1620 × 1080, y se importan con sus metadatos: hora de captura, cámara, objetivo, exposición, GPS y XMP. Sus datos raw", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nuevos ajustes preestablecidos integrados: Retrato, Paisaje, Urbano, Comida, Estaciones, Vintage y virados de ByN.", + "4 MiB and connections at 16.": "4 MiB y las conexiones a 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "con controladores 630): en Windows, la ventana y el procesamiento en GPU usan solo DirectX 12 y nunca cargan el controlador Vulkan salvo que", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Un cambio que no se puede guardar en el disco (unidad llena o desconectada) ya no pasa desapercibido: el comando indica", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Una biblioteca solo está abierta en un programa a la vez (issue #99): abrir una biblioteca que LightCraft o `lightcraft-cli` ya", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Un guardado que falla a medias (disco lleno, una carpeta de red que se desconecta) ya no parece un catálogo dañado", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Un NAS en reposo, una carpeta de red caída o una unidad USB que arranca ya no bloquean la ventana (issue #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Máscaras con IA mediante SAM 3 (Objeto y Describir en el panel Máscaras): hacer clic en un objeto para seleccionarlo (⌥-clic deja una", + "Add Face Model…": "Añadir modelo de caras…", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Sincronización automática: las ediciones se aplican a todas las fotos seleccionadas. Mezcla de ByN automática. Versiones automáticas.", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Etiquetado automático desde registro de track: ubicaciones GPS para las fotos a partir de un registro GPX, emparejadas por hora de captura.", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Versión para navegador (experimental), para mantener segura una biblioteca (issue #107): Archivo → Hacer copia de seguridad de la biblioteca… descarga el catálogo", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "No se encontró ningún procesador gráfico: LightCraft renderiza en la CPU, lo que es mucho más lento. Detalles en Ayuda → Información del sistema.", "On NixOS, start it from `nix develop`.": "En NixOS, inícielo desde `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero." + "Files with the same content as another file in this import: only the first is imported.": "Archivos con el mismo contenido que otro archivo de esta importación: solo se importa el primero.", + "Lightroom Catalog Import": "Importación de catálogo de Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos solo están en memoria. Abre una biblioteca en disco e importa el catálogo de nuevo para guardarlas.", + "Show in Library": "Mostrar en la biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Los catálogos de Lightroom se importan a la biblioteca abierta; no se crea una carpeta de catálogo separada.", + "Destination library": "Biblioteca de destino", + "Playback": "Reproducción", + "Layout": "Diseño", + "Link all margins": "Vincular todos los márgenes", + "Margins": "Márgenes", + "Left": "Izquierda", + "Right": "Derecha", + "Top": "Arriba", + "Bottom": "Abajo", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Los márgenes son una parte del lado más corto de la diapositiva, así que las diapositivas se ven igual en cualquier pantalla.", + "Show guides": "Mostrar guías", + "Aspect preview": "Vista previa de proporción", + "Zoom to fill frame": "Ampliar para llenar el marco", + "Stroke border": "Borde de trazo", + "Colour": "Color", + "Cast shadow": "Proyectar sombra", + "Offset": "Desplazamiento", + "Backdrop": "Fondo", + "Background colour": "Color de fondo", + "Colour wash": "Degradado de color", + "Background image": "Imagen de fondo", + "(not in the library)": "(no está en la biblioteca)", + "Use selected photo": "Usar la foto seleccionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecciona una foto y activa esta opción para llenar con ella el fondo detrás de cada foto.", + "Reset layout": "Restablecer diseño", + "Select a photo to preview its slide": "Selecciona una foto para previsualizar su diapositiva", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La foto seleccionada como diapositiva. La reproducción llena la pantalla; la vista previa de proporción solo cambia esta vista previa.", + "Overlays": "Superposiciones", + "Identity plate": "Placa de identidad", + "Your name or studio": "Tu nombre o estudio", + "Anchored to": "Anclado a", + "Slide": "Diapositiva", + "Render behind image": "Representar detrás de la imagen", + "Rating stars": "Estrellas de valoración", + "Text overlays": "Superposiciones de texto", + "Add text overlay": "Añadir superposición de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. El resto del texto se muestra tal cual.", + "Use export watermark": "Usar la marca de agua de exportación", + "The export watermark, drawn on the photo and sized relative to it.": "La marca de agua de exportación, dibujada sobre la foto y escalada respecto a ella.", + "Titles": "Títulos", + "Intro screen": "Pantalla de introducción", + "Ending screen": "Pantalla final", + "Shows": "Muestra", + "Colour only": "Solo color", + "Override colour": "Reemplazar color", + "Own duration (off: the slide duration)": "Duración propia (desactivada: la de la diapositiva)", + "Duration": "Duración", + "Title text": "Texto del título", + "Intro": "Introducción", + "Ending": "Final", + "End": "Fin", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "La introducción se reproduce una vez antes de la primera foto; el final, después de la última foto cuando Repetir está desactivado. La placa de identidad es la definida en Superposiciones. Los títulos se funden como las diapositivas y el orden aleatorio nunca los mueve.", + "This screen is off: turn it on under Titles to play it.": "Esta pantalla está desactivada: actívela en Títulos para reproducirla.", + "The intro screen, before the first photo.": "La pantalla de introducción, antes de la primera foto.", + "The ending screen, after the last photo.": "La pantalla final, después de la última foto.", + "Templates": "Plantillas", + "Built-in": "Integradas", + "Your templates": "Tus plantillas", + "None yet: save the current settings as a template below.": "Aún no hay ninguna: guarda los ajustes actuales como plantilla abajo.", + "Before the last template": "Antes de la última plantilla", + "Previous settings": "Ajustes anteriores", + "New template name": "Nombre de la nueva plantilla", + "Save as Template": "Guardar como plantilla", + "Import…": "Importar…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Tus propias plantillas necesitan una carpeta de ajustes, que esta versión no tiene; las integradas funcionan.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Haz clic en una plantilla para aplicarla; señala una para previsualizarla en la foto seleccionada. Haz clic derecho en las tuyas para actualizarlas, cambiarles el nombre, exportarlas o eliminarlas.", + "Import Slideshow Templates": "Importar plantillas de presentación", + "Export Slideshow Template": "Exportar plantilla de presentación", + "Slideshow Templates": "Plantillas de presentación", + "Plain Black": "Negro liso", + "Gallery Wall": "Pared de galería", + "Edge to Edge": "De borde a borde", + "Cinema Wide": "Cine panorámico", + "Shooting Data": "Datos de toma", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Cada foto lo más grande posible, sobre negro, con un fundido cruzado breve. Nada encima.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Una pared gris claro con paspartú blanco y sombra suave; el pie de foto y la valoración debajo de la foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para llenar toda la pantalla, con fundidos cruzados lentos.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un encuadre 16:9 con franjas negras arriba y abajo, el título en la esquina, fundidos a negro y un cartel final.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fondo pizarra oscuro con la exposición, el ISO y la distancia focal, y luego la cámara y el objetivo, bajo cada foto.", + "Exporting slideshow": "Exportando presentación", + "Export Slideshow…": "Exportar presentación…", + "Export Slideshow": "Exportar presentación", + "Export Slideshow: folder for the JPEG files": "Exportar presentación: carpeta para los archivos JPEG", + "Frame rate": "Fotogramas por segundo", + "Container": "Contenedor", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automáticamente a pantalla completa", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Una página por diapositiva (el lado largo en píxeles = puntos), con la duración y el fundido de la diapositiva para el modo de presentación del visor.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Archivos JPEG numerados (nombre-001.jpg…) en una carpeta nueva cuyo nombre se elige en el paso siguiente.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada diapositiva durante su duración con sus fundidos. Sin sonido: la música aún no se exporta.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Las diapositivas usan el diseño, las superposiciones y las pantallas de inicio y final de la presentación, en el orden de la vista.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Las fotos de la vista como diapositivas, con los ajustes de arriba. El progreso aparece en la pila de actividad.", + "Checking raw files": "Comprobando archivos RAW", + "1 raw can now be developed from its raw data": "1 RAW ya se puede revelar a partir de sus datos RAW", + "This raw is now developed from its raw data": "Este RAW ya se revela a partir de sus datos RAW", + "This raw variant still can't be decoded": "Esta variante RAW aún no se puede decodificar", + "No lens data embedded in this file (DNG lens opcodes only).": "Este archivo no incluye datos de objetivo (solo opcodes de objetivo DNG).", + "Export to": "Exportar a", + "Specific folder": "Carpeta específica", + "Same folder as original photo": "Misma carpeta que la foto original", + "Tidying the thumbnail cache": "Ordenando la caché de miniaturas", + "Reset all edits (Cmd+Shift+R)": "Restablecer todas las ediciones (Cmd+Mayús+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Los álbumes inteligentes se actualizan solos: cambie sus reglas o use Foto → Eliminar foto", + "LightCraft stopped": "LightCraft se detuvo", + "Show Focus Point": "Mostrar punto de enfoque", + "Zoom to Focus Point": "Zoom al punto de enfoque", + "Focus": "Enfoque", + "Focus point": "Punto de enfoque", + "Wide": "Amplio", + "Center": "Centro", + "Flexible Spot": "Punto flexible", + "Zone": "Zona", + "Expanded Flexible Spot": "Punto flexible ampliado", + "Custom AF Area": "Área AF personalizada", + "Face tracking": "Seguimiento de caras", + "Lock On AF": "AF de bloqueo", + "Multi": "Multi", + "Spot": "Punto", + "Tracking": "Seguimiento", + "Face Tracking": "Seguimiento de caras", + "Animal Eye Tracking": "Seguimiento de ojos de animales", + "Human Eye Tracking": "Seguimiento de ojos humanos", + "Read Focus Points": "Leer puntos de enfoque", + "Reading focus points": "Leyendo puntos de enfoque", + "Click to type a value": "Hacer clic para escribir un valor", + "Run face models on the CPU only": "Ejecutar los modelos de caras solo en la CPU", + "Detection": "Detección", + "Recognition": "Reconocimiento", + "Not loaded yet": "Aún no cargado", + "Checking the GPU…": "Comprobando la GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modelo de caras usa la GPU solo cuando una comprobación muestra que da los mismos resultados más rápido.", + "Checking the GPU for faces": "Comprobando la GPU para las caras" } diff --git a/crates/ui-egui/locales/fr-formats.json b/crates/ui-egui/locales/fr-formats.json index d85e837a3..1ecd4c4b2 100644 --- a/crates/ui-egui/locales/fr-formats.json +++ b/crates/ui-egui/locales/fr-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Créer les aperçus de taille standard ({scope})", "Building previews for {} photos…": "Création des aperçus pour {} photos…", "Click {}:1": "Cliquer sur {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espace colorimétrique : Rec. 2020 {curve} (AVIF HDR)", "Couldn't open the editor: {e}": "Impossible d’ouvrir l’éditeur : {e}", "Create {n} Virtual Copies": "Créer {n} copies virtuelles", "Creates {} stacks from {} of {scope}": "Crée {} piles à partir de {} parmi {scope}", @@ -65,6 +66,7 @@ "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)", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Les fichiers annexes XMP de {n} photos n'ont pas pu être écrits (leur dossier est-il hors ligne ?). LightCraft continue d'essayer.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Le profil XMP « {profile} » n’a pas été appliqué à {path} ; les couleurs peuvent différer de Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La géométrie Lightroom de {path} n’a pas été conservée ; {mode} sera recalculé.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La géométrie Lightroom de {path} n’a pas été conservée ; la transformation enregistrée est ignorée, car Upright est désactivé.", @@ -161,9 +163,10 @@ "{n} photo{}": "{n} photo{}", "{n} photo{} match · updates automatically as photos change": "{n} photo{} correspondante(s) · mise à jour automatique lorsque les photos changent", "{n} photo{} now · updates automatically as photos change": "{n} photo{} actuellement · mise à jour automatique lorsque les photos changent", + "{n} selected photos · {pages} pages": "{n} photos sélectionnées · {pages} pages", "{pid} (missing)": "{pid} (manquant)", "{sel_n} selected · {counted}": "{sel_n} sélectionné(s) · {counted}", - "{stage}… {:.0}%": "{stage}… {:.0}%", + "{stage}… {:.0}%": "{stage}… {:.0} %", "{verb} {n} Photo{}": "{verb} {n} photo{}", "{weekday}, {day}": "{weekday} {day}", "{weekday}, {day} {month} {year}": "{weekday} {day} {month} {year}", @@ -235,6 +238,10 @@ "{done} of {total} MB": "{done} sur {total} Mo", "{pct} %": "{pct} %", "+{n} more": "+{n} autres", + "Create the keyword “{name}”": "Créer le mot-clé « {name} »", + "new, inside {parent}": "nouveau, dans {parent}", + "Keywords of {n} selected photos": "Mots-clés de {n} photos sélectionnées", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "« {name} » retiré de {n} photos. Modifier → Annuler le rétablit.", "1 task is still running: {names}. Quitting stops it.": "1 tâche est encore en cours : {names}. Quitter l'arrête.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tâches sont encore en cours : {names}. Quitter les arrête.", "{what} merge cancelled": "Fusion {what} annulée", @@ -245,7 +252,48 @@ "{n} added to Apple Photos album “{album}”": "{n} ajouté(s) à l'album Apple Photos « {album} »", "{n} added to Apple Photos": "{n} ajouté(s) à Apple Photos", "{n} photos opened for editing; each is stacked with its original": "{n} photos ouvertes pour modification ; chacune est empilée avec son original", + " in {g} group{}": " dans {g} groupe(s){:.0}", + " · kept, not applied: {}": " · conservé, non appliqué : {}", + " — kept but not applied: {}": " — conservés mais non appliqués : {}", + "Apply to “{mask}” on Selected Photos": "Appliquer à « {mask} » sur les photos sélectionnées", + "Delete the effect preset “{name}”?": "Supprimer le paramètre prédéfini d’effet « {name} » ?", + "Delete these {n} effect presets?": "Supprimer ces {n} paramètres prédéfinis d’effet ?", + "Delete {n} effect preset{}? This can't be undone.": "Supprimer {n} paramètre(s) prédéfini(s) d’effet ?{:.0} Cette action est irréversible.", + "Effect: {name}": "Effet : {name}", + "Exported {} effect preset{}": "Paramètres d’effet exportés : {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} effet(s) de masque importé(s){:.0} (Masquage → Effet)", + "Update “{name}” with Current Settings": "Mettre à jour « {name} » avec les réglages actuels", + "{n} preset{}": "{n} paramètre(s){:.0}", "{name} opened in the other editor": "{name} ouvert dans l’autre éditeur", "{n} originals opened in the other editor": "{n} originaux ouverts dans l’autre éditeur", - "· {in_source} identical copies": "· {in_source} copies identiques" + "· {in_source} identical copies": "· {in_source} copies identiques", + "{count} more warnings.": "{count} avertissements supplémentaires.", + "{photos} catalog records processed; {collections} collections.": "{photos} entrées du catalogue traitées ; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originaux sont manquants. Leurs entrées de catalogue sont disponibles ; utilisez Rechercher les photos manquantes pour rétablir les liens.", + "Applied {name}": "{name} appliqué", + "Delete “{name}”?": "Supprimer « {name} » ?", + "Updated {name}": "{name} mis à jour", + "Exported to {path}": "Exporté vers {path}", + "Saved template {name}": "Modèle {name} enregistré", + "Imported {name}": "{name} importé", + "Skipped {problem}": "Ignoré : {problem}", + "…and {more} more": "…et {more} de plus", + "Template preview: {name}": "Aperçu du modèle : {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos affichées · {slides} diapositives · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} fichiers JPEG", + "{s} s of AV1 video": "{s} s de vidéo AV1", + "Slideshow exported: {what}": "Diaporama exporté : {what}", + " · {n} skipped (could not be rendered)": " · {n} ignorées (rendu impossible)", + "{n} raws can now be developed from their raw data": "{n} fichiers RAW peuvent désormais être développés à partir de leurs données RAW", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft s’est arrêté de façon inattendue : {e}\n\nConsultez le fichier journal pour plus de détails : {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft s’est arrêté de façon inattendue : {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renomme {n} visage{} dans la bibliothèque. Les fichiers ne sont pas modifiés.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "« {to} » est déjà une personne. Les combiner n’en fait qu’une ; Annuler les sépare de nouveau.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "Fusion de {what} ajoutée en {mp} : la taille complète, {full}, demande plus de mémoire que n’en a cet ordinateur", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "Fusion de {what} ajoutée en {mp}, la plus grande taille possible pour un panorama (taille complète : {full})", + "Already in “{}”": "Déjà dans « {} »", + "Move {n} photo{} to “{}”": "Déplacer {n} photo{} vers « {} »", + "Move {n} photo{} to “{}”?": "Déplacer {n} photo{} vers « {} » ?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} photo{} déplacée(s) vers « {} ». Modifier → Annuler les remet en place." } diff --git a/crates/ui-egui/locales/fr.json b/crates/ui-egui/locales/fr.json index b0becb154..fa57e373e 100644 --- a/crates/ui-egui/locales/fr.json +++ b/crates/ui-egui/locales/fr.json @@ -8,15 +8,17 @@ "+ Rule": "+ Règle", "0: nothing is rejected.": "0 : rien n'est rejeté.", "1 day": "1 jour", + "150 dpi · sRGB · current edits · fit without cropping": "150 ppp · sRVB · retouches actuelles · ajusté sans recadrage", "2026-04 or 2026-04-12": "2026-04 ou 2026-04-12", "= exactly": "= exactement", "A blank template keeps the original names.": "Un modèle vide conserve les noms d'origine.", "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", + "A pen's pressure sets the size and flow of the brush": "La pression d'un stylet règle la taille et le flux du pinceau", "AI Mask Model Status": "État du modèle de masques IA", "About": "À propos", "About LightCraft": "À propos de LightCraft", - "Active": "Active", + "Active": "Actif", "Activity": "Activité", "Actual size": "Taille réelle", "Add": "Ajouter", @@ -38,6 +40,9 @@ "Add to Favorites": "Ajouter aux favoris", "Add to Local": "Ajouter à Local", "Add to My Photos": "Ajouter à Mes photos", + "Can't be moved here": "Impossible de les déplacer ici", + "This row covers more than one disk: drop on a folder inside it": "Cette ligne couvre plusieurs disques : déposez-les sur un dossier qu’elle contient", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Les photos locales ne sont pas encore dans la bibliothèque : Ajouter à Mes photos d’abord, puis déplacez-les", "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", @@ -178,7 +183,7 @@ "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", + "Candidate": "Candidat", "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)", @@ -197,6 +202,7 @@ "Choose Edit Settings to Copy": "Choisir les réglages de retouche à copier", "Choose Edit Settings to Copy…": "Choisir les réglages de retouche à copier…", "Choose Folder…": "Choisir un dossier…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Choisissez l’emplacement du PDF après confirmation. Imprimez le PDF depuis votre visionneuse.", "Choose a photo in the grid or filmstrip": "Choisissez une photo dans la grille ou la pellicule", "Choose…": "Choisir…", "Cinematic": "Cinématographique", @@ -246,6 +252,9 @@ "Color Range": "Plage de couleurs", "Color label cleared": "Étiquette de couleur supprimée", "Color space": "Espace colorimétrique", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espace colorimétrique : Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espace colorimétrique : Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espace colorimétrique : Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espace colorimétrique : sRGB (AVIF)", "Colors": "Couleurs", "Combine files…": "Combiner des fichiers…", @@ -262,7 +271,14 @@ "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", + "Move Photos": "Déplacer les photos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Leurs fichiers et fichiers annexes XMP sont déplacés sur le disque. Modifier → Annuler les remet en place.", + "Don't ask again": "Ne plus demander", + "Another library was opened since the photos were dropped: nothing was moved": "Une autre bibliothèque a été ouverte depuis le dépôt des photos : rien n’a été déplacé", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Réglages → Confirmer avant de déplacer des photos vers un autre dossier sur le disque le réactive", + "Confirm before moving photos to another folder on disk": "Confirmer avant de déplacer des photos vers un autre dossier sur le disque", "Constrain Crop": "Contraindre le recadrage", + "Contact Sheet PDF": "Planche contact PDF", "Continue": "Continuer", "Continue Without Saving": "Continuer sans enregistrer", "Contrast": "Contraste", @@ -487,6 +503,7 @@ "File name": "Nom de fichier", "File types:": "Types de fichiers :", "Filename": "Nom de fichier", + "Filename captions": "Légendes avec le nom de fichier", "Fill": "Remplir", "Fill Edges": "Remplir les bords", "Film": "Pellicule", @@ -545,6 +562,10 @@ "Friday": "Vendredi", "Full": "Complet", "Full Screen Preview": "Aperçu en plein écran", + "Hold View Until Refreshed": "Conserver la vue jusqu’à l’actualisation", + "HDR TIFF needs 32-bit float.": "Le TIFF HDR exige du 32 bits flottant.", + "HDR output": "Sortie HDR", + "Letter": "Lettre US", "Lights Out": "Lumières éteintes", "Full screen mode": "Mode plein écran", "Full size": "Taille complète", @@ -600,6 +621,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR avec les derniers réglages", + "HDR for Each Bracket Set": "HDR pour chaque bracketing", "HDR…": "HDR…", "Has Edits": "Avec retouches", "Has GPS": "Avec GPS", @@ -897,7 +919,7 @@ "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 lens data embedded in this file.": "Aucune donnée d'objectif intégrée à ce fichier.", "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", @@ -908,6 +930,11 @@ "No new presets": "Aucun nouveau préréglage", "No photo selected": "Aucune photo sélectionnée", "No photos": "Aucune photo", + "Your library is empty": "Votre bibliothèque est vide", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Les photos importées apparaissent ici. Rien n’est copié ni déplacé, sauf si vous le choisissez.", + "or drop photos and folders into this window": "ou déposez des photos et des dossiers dans cette fenêtre", + "Import photos to get started": "Importez des photos pour commencer", + "or drop photos into this window": "ou déposez des photos dans cette fenêtre", "No photos found": "Aucune photo trouvée", "No photos in this folder": "Aucune photo dans ce dossier", "No photos match the active filters": "Aucune photo ne correspond aux filtres actifs", @@ -982,6 +1009,7 @@ "Paste from Previous": "Coller depuis la précédente", "Paste pages": "Coller les pages", "Pastel Haze": "Brume pastel", + "Pen Pressure": "Pression du stylet", "People": "Personnes", "Percent": "Pourcentage", "Percentage": "Pourcentage", @@ -996,6 +1024,7 @@ "Photo Merge": "Fusion de photos", "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Photo → Modifier dans l'éditeur externe (⇧⌘E) génère une copie TIFF 16 bits, l'empile avec l'original et l'ouvre ici (un nom d'application sur macOS, un chemin de programme ailleurs).", "Photos": "Photos", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Photos retouchées en HDR : JPEG avec carte de gain HDR (correct partout, hautes lumières plus vives sur les écrans HDR), AVIF en 10 bits, PNG et JPEG XL en 16 bits Rec. 2020 PQ ; le TIFF exige du 32 bits flottant", "Photos per bracket (0 = auto)": "Photos par bracketing (0 = auto)", "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Les photos vues dans Local mais jamais ajoutées ni modifiées quittent le catalogue lorsque leur dossier n'a pas été parcouru depuis cette durée (vérifié à l'ouverture de la bibliothèque). Les fichiers restent sur le disque ; parcourir le dossier les fait réapparaître.", "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Les photos qui arrivent dans ce dossier (capture connectée, scanner, application de synchronisation) sont ajoutées dès qu'elles sont complètes.", @@ -1119,6 +1148,7 @@ "Refining the mask's detail…": "Affinage du détail du masque…", "Refresh Source": "Actualiser la source", "Refresh Source (/)": "Actualiser la source (/)", + "Refresh View": "Actualiser la vue", "Reject": "Rejeter", "Reject below focus": "Rejeter en dessous de la netteté", "Rejected": "Rejetée", @@ -1181,7 +1211,7 @@ "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)", + "Reset all edits": "Réinitialiser toutes les retouches", "Reshuffle": "Mélanger à nouveau", "Resize Face Box": "Redimensionner le cadre de visage", "Resize to": "Redimensionner à", @@ -1338,6 +1368,7 @@ "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 import review when a camera or card is connected": "Ouvrir l'importation lorsqu'un appareil photo ou une carte est connecté", "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", @@ -1365,6 +1396,7 @@ "Soft Pastel": "Pastel doux", "Soft Proofing": "Épreuvage à l'écran", "Soft Skin": "Peau douce", + "Solo Mode": "Mode solo", "Sort": "Trier", "Sort by": "Trier par", "Source": "Source", @@ -1415,7 +1447,7 @@ "System Info": "Informations système", "System Info…": "Informations système…", "System default": "Valeur par défaut du système", - "Table": "Table", + "Table": "Tableau", "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é", @@ -1511,6 +1543,7 @@ "Vibrance": "Vibrance", "Videos": "Vidéos", "View": "Affichage", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Les visionneuses qui ne lisent pas le bloc cICP du PNG affichent les valeurs PQ comme du sRVB : plates et délavées. Pour une image correcte partout, utilisez le JPEG (carte de gain).", "Viewing and sharing": "Affichage et partage", "Vignette": "Vignette", "Vignetting": "Vignetage", @@ -1543,6 +1576,7 @@ "Will be restored": "Sera restaurée", "Window": "Fenêtre", "Winter Blue": "Bleu hivernal", + "Wrote the XMP sidecars that were waiting": "Fichiers annexes XMP en attente écrits", "XMP Sidecar Preferences": "Préférences des fichiers annexes XMP", "XMP sidecars": "Fichiers annexes XMP", "Year": "Année", @@ -1745,7 +1779,7 @@ "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", + "Click to type a value, drag to change it": "Cliquez pour saisir une valeur, faites glisser pour la modifier", "Angle": "Angle", "Video": "Vidéo", "Copyrighted": "Protégé par copyright", @@ -1809,6 +1843,15 @@ "Keyword List": "Liste des mots-clés", "Filter Keywords": "Filtrer les mots-clés", "No keywords yet": "Aucun mot-clé pour l'instant", + "Show fewer": "Afficher moins", + "Whole names": "Noms complets", + "Grouped by parent": "Groupés par mot-clé parent", + "Keywords of the selection": "Mots-clés de la sélection", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Groupés : les mots-clés contenus dans un même mot-clé partagent une ligne qui le nomme ; les longues listes et les suggestions sont raccourcies.", + "Show every keyword of the selected photos": "Afficher tous les mots-clés des photos sélectionnées", + "new keyword": "nouveau mot-clé", + "Return creates this keyword and gives it to the selected photos": "Entrée crée ce mot-clé et l'attribue aux photos sélectionnées", + "Separate several keywords with commas. Return gives them to the selected photos.": "Séparez plusieurs mots-clés par des virgules. Entrée les attribue aux photos sélectionnées.", "No keywords match": "Aucun mot-clé ne correspond", "Keyword Name": "Nom du mot-clé", "Synonyms": "Synonymes", @@ -1885,14 +1928,14 @@ "Finding faces": "Recherche des visages", "Synchronizing folder": "Synchronisation du dossier", "Looking for changes": "Recherche des modifications", - "Arrange Panel": "Arrange Panel", - "Close panel": "Close panel", - "Float panel": "Float panel", - "Move to group": "Move to group", - "Panels": "Panels", - "Reset Panel Layout": "Reset Panel Layout", - "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels", + "Arrange Panel": "Disposer le panneau", + "Close panel": "Fermer le panneau", + "Float panel": "Panneau flottant", + "Move to group": "Déplacer vers le groupe", + "Panels": "Panneaux", + "Reset Panel Layout": "Réinitialiser la disposition des panneaux", + "Resize panel window": "Redimensionner la fenêtre du panneau", + "Resize panels": "Redimensionner les panneaux", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Ceci est un catalogue Lightroom, pas une bibliothèque LightCraft. L’importer plutôt dans une bibliothèque LightCraft ?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L’importation lit le catalogue sans le modifier et ajoute ses photos, retouches, mots-clés et collections à la bibliothèque ouverte.", "Albums hold library photos: Add to My Photos first": "Les albums contiennent des photos de la bibliothèque : ajoutez-les d’abord à Mes photos", @@ -1955,7 +1998,345 @@ "not added to Apple Photos (cancelled)": "non ajouté à Apple Photos (annulé)", "The first time, macOS asks whether LightCraft may control Photos.": "La première fois, macOS demande si LightCraft peut contrôler Photos.", "Adding to Apple Photos": "Ajout à Apple Photos", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "« enregistré en mémoire mais pas écrit sur le disque », la barre supérieure affiche un avertissement et LightCraft réessaie jusqu'à ce que", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106) : un DNG est vérifié par rapport aux données raw, écrit dans un fichier temporaire, synchronisé et relu avant de recevoir son", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Aide → Informations système et Réglages → Performances indiquent pourquoi le GPU n'est pas", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(p. ex. D750, D780, D850, D7500, Z 50) ne s'affichent plus presque noirs ni avec des ombres bouchées : leur niveau de noir était", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ticket #94) : une requête HTTP venant d'une page web ne peut plus transporter de commande dans son corps. Les lignes sont limitées à", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 sur studio-mac) ») au lieu de laisser les deux écrire et perdre en silence les retouches de l'autre. Un plantage ne laisse jamais la", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "aperçu de 1620 × 1080, et s'importent avec leurs métadonnées : date de prise de vue, appareil, objectif, exposition, GPS et XMP. Leurs données raw", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nouveaux préréglages intégrés : Portrait, Paysage, Urbain, Cuisine, Saisons, Vintage et virages N&B.", + "4 MiB and connections at 16.": "4 Mio et les connexions à 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630) : sous Windows, la fenêtre et le rendu GPU utilisent uniquement DirectX 12 et ne chargent jamais le pilote Vulkan sauf", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Une modification impossible à enregistrer sur le disque (disque plein ou débranché) n'est plus silencieuse : la commande indique", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Une bibliothèque n'est ouverte que dans un programme à la fois (ticket #99) : ouvrir une bibliothèque que LightCraft ou `lightcraft-cli` a déjà", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Un enregistrement interrompu en cours de route (disque plein, partage réseau qui se déconnecte) ne ressemble plus à un catalogue endommagé", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Un NAS en veille, un partage réseau perdu ou un disque USB qui démarre ne figent plus la fenêtre (ticket #104) :", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Masques IA avec SAM 3 (Objet et Décrire dans le panneau Masquage) : cliquez sur un objet pour le sélectionner (⌥-clic exclut une", + "Add Face Model…": "Ajouter un modèle de visages…", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Synchro auto : les retouches s'appliquent à chaque photo sélectionnée. Mélange N&B automatique. Versions automatiques.", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Géolocalisation depuis un tracé : positions GPS de vos photos tirées d'un tracé GPX, associées par date de prise de vue.", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Version navigateur (expérimentale), protection de la bibliothèque (ticket #107) : Fichier → Sauvegarder la bibliothèque… télécharge le catalogue", + "Cancel Face Model Download": "Annuler le téléchargement du modèle de visages", + "Cancel Pending Photo Action": "Annuler l'action en attente sur la photo", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Les photos CR2 Canon des EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II et 1D Mark IV (et d'autres", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Les fichiers CR3 Canon affichent leur JPEG intégré en pleine taille (p. ex. 6960 × 4640 sur un EOS R6 Mark III) au lieu de l'", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Les fichiers C-RAW du Canon EOS R5 Mark II qui s'ouvraient en « aperçu uniquement » (plus de la moitié, selon la", + "Choose Face Recognition Model": "Choisir le modèle de reconnaissance des visages", + "Choose a model below (Use) to start.": "Choisissez un modèle ci-dessous (Utiliser) pour commencer.", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Maj+R, conserve la photo et efface ses retouches.)", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtre par libellé de couleur avec plusieurs libellés à la fois ; arborescence de dossiers dépliable dans Local.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Masques de plage de couleurs : cliquez sur la photo pour échantillonner. Plages de luminance : barre de plage, lissage, carte de luminance.", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Les NEF Nikon compressés (compression sans perte et avec perte, 12 et 14 bits — p. ex. 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": "Convertir en DNG, Copier en DNG, Fusion de photos et les aperçus dynamiques n'écrivent plus directement dans le fichier final (ticket", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Statut du copyright, conditions d'utilisation des droits et URL d'informations sur le copyright dans Infos, les préréglages de métadonnées et les exports.", + "Detect Faces": "Détecter les visages", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Détecter les visages (menu Photo) en a besoin : un petit téléchargement unique.", + "Download Face Model": "Télécharger le modèle de visages", + "Drag Crop Handle": "Faire glisser une poignée de recadrage", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "le réglage Bord, et obtiennent en arrière-plan un passage plus net une fois zoomé. Le modèle s'exécute dans LightCraft en Rust pur", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Modifier dans l'éditeur externe (⇧⌘E) : une copie TIFF 16 bits, empilée, actualisée à votre retour.", + "Evaluate Face Recognition": "Évaluer la reconnaissance des visages", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Les noms de fichiers d'export utilisent les jetons de Renommer les photos ({title}, {seq:2}, {date:%Y-%m-%d}…), plus les nouveaux {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": "Les fichiers exportés sont écrits dans un fichier temporaire puis renommés à leur place ; ainsi un disque plein ou débranché", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Les exports et rendus n'écrasent jamais l'original d'une photo (ticket #93) : exporter dans le dossier de la photo avec", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Les exports ne sont jamais noirs à cause du GPU (ticket #78) : un rendu GPU qui manque de", + "Face Model Downloads": "Téléchargements de modèles de visages", + "Face Models": "Modèles de visages", + "Face Recognition On/Off": "Reconnaissance des visages activée/désactivée", + "Face detector": "Détecteur de visages", + "Face recognition": "Reconnaissance des visages", + "Faces": "Visages", + "Faces of a Person": "Visages d'une personne", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Exports et importations depuis une carte plus rapides, avec la même protection pour ce qui ne peut pas être recréé (ticket #134) : les exports,", + "Go back to listing them by name": "Revenir au tri par nom", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Si votre bibliothèque ne peut pas être ouverte au lancement (ouverte dans un autre programme, illisible, sur un disque non connecté,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importez des préréglages d'autres éditeurs : préréglages XMP, fichiers `.lrtemplate` classiques, « préréglages DNG » d'applications mobiles et des `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Importer → Copier vérifie chaque copie, comme Déplacer (ticket #96) : chaque fichier est écrit comme un nouveau fichier, synchronisé sur le disque et", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Importer → Déplacer : les photos vont dans la destination (avec les mêmes dossiers et le même renommage que Copier, p. ex.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Importation : copie vers n'importe quel dossier, par jour / par mois / un seul dossier, renommage à l'importation, préréglage de métadonnées, Copier en DNG.", + "Index Faces": "Indexer les visages", + "Index Faces in the Background": "Indexer les visages en arrière-plan", + "Inspect Face Model File": "Inspecter le fichier du modèle de visages", + "Install Face Model": "Installer le modèle de visages", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "JPEG de même nom (`IMG_0001.CR3` + `IMG_0001.JPG`) ne partagent plus un seul fichier annexe : le raw garde `IMG_0001.xmp`,", + "Library Folders": "Dossiers de la bibliothèque", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 et les versions antérieures la refusent (« unsupported format … v2 »).", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft ne plante plus au lancement sur les PC Windows dont le pilote Vulkan est défectueux (ticket #136, p. ex. certains pilotes Intel UHD", + "Lossy DNG files and Smart Previews open as raw photos.": "Les fichiers DNG avec perte et les aperçus dynamiques s'ouvrent comme des photos raw.", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Looks Luminar : les fichiers `.lmp` et les collections `.mplumpack` s'importent comme préréglages (groupés par collection) ; les curseurs", + "Name Face": "Nommer le visage", + "Name Faces": "Nommer les visages", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Les NEF Nikon partent d'un rendu des couleurs et des tons ajusté sur le JPEG de l'appareil, comme les ARW Sony, au lieu d'un rendu terne,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon divise en deux moitiés compressées différemment utilisent encore l'aperçu pour l'instant.)", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Les raws Panasonic et Leica (RW2, RWL et les anciens fichiers RAW, du DMC-LX1 au DC-S1R II) se développent à partir des données raw dans", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Photo. Le film fixe a aussi le menu contextuel de la photo. Ajouter de nouveau un fichier qui se trouve dans Supprimées récemment ne", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Les photos de Supprimées récemment peuvent être restaurées depuis l'application : clic droit → Restaurer (ou Supprimer définitivement), aussi dans le", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Photos) ; l'importation (y compris par glisser-déposer), Rechercher les photos manquantes, Générer / Supprimer les aperçus dynamiques, l'aperçu du renommage,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Collection rapide et album cible (B dans la grille), jeux de mots-clés (⌥1–⌥9), jeux de libellés de couleur.", + "Recognise faces (experimental)": "Reconnaître les visages (expérimental)", + "Recognition models": "Modèles de reconnaissance", + "Remove Face Model": "Retirer le modèle de visages", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Renommer le dossier… et Déplacer le dossier vers… (Local) peuvent être annulés et rétablis (ticket #97) : le dossier revient sur le disque avec", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Renommer les photos n'écrase jamais une autre photo quand seule la casse change (ticket #95) : sur les volumes sensibles à la casse", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Renommer les photos signale les fichiers qu'il n'a pas pu remettre en place après un échec (ticket #105), p. ex. quand un partage réseau se déconnecte", + "Reorder Album": "Réordonner l'album", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Enregistrer les métadonnées dans un fichier annexe XMP écrit par une autre application ne le remplace plus (ticket #92) : LightCraft fusionne ses", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Les fichiers de réglages ne sont jamais réinitialisés en silence (ticket #103) : un `prefs.json`, `presets.json` ou `view.json` endommagé est conservé", + "Show Person": "Afficher la personne", + "Slideshow, second window, All Metadata, System Info.": "Diaporama, seconde fenêtre, Toutes les métadonnées, Informations système.", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Catalogues plus petits et plus rapides : les photos que vous avez seulement consultées dans Local (jamais ajoutées, notées ni retouchées) sont oubliées dès que", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albums intelligents avec un éditeur de règles : toutes / au moins une / aucune, groupes imbriqués, 26 champs.", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Les ARW Sony antérieurs à 2017 environ (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II et leurs", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Les ARW sans perte réduits du Sony ILCE-7M4 décodent désormais leurs tuiles YCbCr sous-échantillonnées en RVB linéaire,", + "Sort Albums A–Z": "Trier les albums de A à Z", + "Sort Albums by Name": "Trier les albums par nom", + "Sort Contents A–Z": "Trier le contenu de A à Z", + "Suggest Names for Faces": "Suggérer des noms pour les visages", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggère qui figure sur une photo d'après les visages que vous avez nommés. Tout reste sur votre ordinateur.", + "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": "les séries TZ/ZS, les Leica D-Lux, V-Lux et C-Lux, et la génération GH6, GH7, G9 II, S5 II et S9. Ils partent d'un", + "Test Face Model": "Tester le modèle de visages", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Le catalogue a une version de format (ticket #102). Ouvrir une bibliothèque d'une version antérieure de LightCraft la met à niveau ; une bibliothèque", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Le port de contrôle (`--control`) ferme une connexion dès qu'il reçoit autre chose qu'une requête JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Le cache des vignettes ne compte et ne supprime que ses propres fichiers (ticket #98) : une bibliothèque ouverte sur un dossier qui", + "This build cannot run recognition models: they can be added and chosen, not used.": "Cette version ne peut pas exécuter les modèles de reconnaissance : ils peuvent être ajoutés et choisis, pas utilisés.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Cette version n'a pas d'emplacement pour conserver les modèles de visages ; la liste des modèles n'est donc pas disponible. (L'application de bureau en a un.)", + "Unnamed Faces": "Visages sans nom", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Importation automatique d'un dossier surveillé ; Convertir en DNG ; Dupliquer ; Générer les aperçus standard / 1:1 / dynamiques.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "sans enregistrer et quitter (ticket #100). Elle ne démarre plus en silence une session de démonstration qui ressemblait à une bibliothèque réinitialisée et", + "`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`) avec leurs fichiers annexes XMP ; chaque original ne quitte la carte qu'une fois sa", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG` ; sur les volumes insensibles à la casse, le changement de casse est quand même appliqué.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "un disque plein) n'est plus considéré comme généré et Générer les aperçus dynamiques le remplace.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "par la suite (ticket #101) : l'écriture partielle est tronquée avant que LightCraft ne réessaie, si bien que le lancement suivant rejoue chaque", + "again. Change the period (or turn it off) in Settings → Performance.": "de nouveau. Modifiez la durée (ou désactivez-la) dans Réglages → Performances.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "par rapport aux originaux de la bibliothèque ne parcourt plus toute la bibliothèque quand rien ne se trouve à cet emplacement.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "contient déjà un dossier `thumbs/` d'autres images ne les perd plus quand le cache est réduit ou vidé.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "et vérifie le fichier avant de l'utiliser. Les masques conservent leur sélection ; ils s'affichent et s'exportent donc sans le modèle.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "et chaque photo importée dans un seul zip, et Fichier → Restaurer la bibliothèque depuis une sauvegarde… la rétablit (la bibliothèque actuelle", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "et ne fige jamais la fenêtre. Il est facultatif et ne fait pas partie de LightCraft (licence SAM de Meta, environ 3,4 Go) : la", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "sous `<name>.corrupt-<time>` et vous en êtes averti ; un fichier illisible (p. ex. verrouillé par un autre programme) est laissé tel quel", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "si on le demande. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (ou `WGPU_BACKEND` de wgpu, que le rendu GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "regroupant n'importe lesquels — des dossiers entiers d'un coup, groupés par pack. Les masques contenus dans les préréglages suivent.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "l'appareil n'est plus figé dans l'image. Les photos déjà importées en « aperçu uniquement » basculent au rechargement. (Les fichiers que", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "modification. Les catalogues contenant déjà un tel fragment se chargent entièrement. Quitter alors que le journal du catalogue ne peut pas être écrit", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "les modifications n'ont pas pu être enregistrées réessaie une fois, puis demande : Réessayer d'enregistrer, Quitter quand même ou Annuler.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "de contrôle ou de MCP, un chemin d'aperçu de fusion ou `lightcraft-cli render IMG.jpg -o IMG.jpg` est refusé avec un", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "comparé au contenu lu sur la carte. Une copie qui échoue ou diffère est supprimée et signalée comme", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "rendu des couleurs et des tons ajusté sur le JPEG de l'appareil, comme les raws Nikon et Sony, cadré au format d'image réglé sur", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "copie vérifiée et dans la bibliothèque. Les doublons et les fichiers en échec restent où ils étaient.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "ne sont pas encore décodées ; ils restent donc en aperçu uniquement. Pour les CR3 importés auparavant, Photo → Recharger depuis le disque (désormais dans", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "droit sont rognées, et les fichiers « 12 bits non compressés » ne sont plus écrêtés. La série RX100 s'affiche bien plus près", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "tous les formats que les appareils écrivent. La plupart des boîtiers s'ouvraient auparavant en aperçu uniquement : GH1–GH5, les G, GX, GF, GM, FZ, LX et", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "export. Le catalogue, les fichiers annexes XMP, les réglages, les DNG, les fusions, les aperçus dynamiques et les copies d'une importation sont toujours", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "champs et conserve le reste — p. ex. les réglages de développement et l'historique de cette application — octet par octet. Un", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "fichier (Sony ne les stocke que dans des données constructeur brouillées sur ces boîtiers), les quelques colonnes de remplissage du bord", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "première fois que vous utilisez un masque IA, LightCraft demande avant de le télécharger, affiche la progression, peut annuler et reprendre,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "pour la session au lieu d'être écrasé par les valeurs par défaut. Les réglages de l'application (`ui.json`, qui retient votre", + "gets -1, -2… instead of being replaced.": "reçoit -1, -2… au lieu d'être remplacé.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "rendu neutre verdâtre (ticket #150) ; la balance des blancs est ajustée par rapport au rendu tel que pris. Les NEF 12 bits", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "ouverte — sur cet ordinateur ou un autre partageant le dossier — indique qui l'a ouverte (« déjà ouverte dans LightCraft (processus", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "comment la restaurer ou la supprimer définitivement et l'importer à nouveau. (Pour repartir de zéro sur une photo, Réinitialiser les retouches,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ignorait auparavant) choisit le moteur graphique ; `off` effectue le rendu sur le CPU. Le GPU démarre désormais après que la fenêtre", + "immutable, and HOSTING.md describes the actual build.": "comme immuables, et HOSTING.md décrit la compilation réelle.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importation — ainsi « importation terminée » signifie que les copies sont bonnes avant de formater la carte — et un nom déjà pris", + "imported as \"preview only\" switch over on Reload.": "importées en « aperçu uniquement » basculent au rechargement.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "importées auparavant basculent avec Photo → Recharger depuis le disque.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "par petits morceaux pour que les GPU intégrés lents ne soient pas réinitialisés par leur chien de garde. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "incomplet est refait sur le CPU — le fichier contient la même image dans tous les cas. Le travail est envoyé au GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "est conservée). Un enregistrement échoué (stockage plein) affiche désormais l'avertissement de non-enregistrement et est retenté, une photo impossible à stocker", + "is refused and nothing is overwritten.": "est refusée et rien n'est écrasé.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "est affichée, et seulement si Réglages → Performances → Utiliser le GPU pour le rendu est activé ; si LightCraft s'arrête brutalement pendant", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "n'est pas ajoutée, un second onglet affiche un message au lieu d'écraser le premier, `?reset` demande d'abord, et la page indique", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "à l'autre (ticket #92). Rechercher les photos manquantes retrouve aussi les fichiers renommés par leur contenu, préfère une correspondance de contenu", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ses photos et fichiers annexes XMP, et les photos pointent de nouveau vers lui. Si quelque chose occupe désormais l'ancien emplacement, l'annulation", + "layout is read from each file instead of assumed.": "disposition est lue dans chaque fichier au lieu d'être supposée.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "ne laisse aucun DNG et conserve le raw. Les aperçus dynamiques sont écrits de la même manière ; un aperçu endommagé (interrompu par un plantage ou", + "library locked: the lock is the operating system's and goes away with the program.": "bibliothèque verrouillée : le verrou appartient au système d'exploitation et disparaît avec le programme.", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "bibliothèque) sont écrits de façon atomique et enregistrés dès que vous ouvrez une autre bibliothèque, pas seulement à la fermeture. Quitter alors que", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "se contente plus d'afficher « doublon ignoré » : il ouvre le panneau latéral sur Supprimées récemment avec la photo sélectionnée et indique", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "perdait tout à la fermeture ; une session temporaire affiche un bandeau en permanence et n'écrit jamais dans votre bibliothèque.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "mémoire, dépasse les limites de tampon du GPU, rencontre une erreur ou une réinitialisation du pilote, ou revient", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "message clair, et l'original reste intact octet par octet. Les exports antérieurs ordinaires sont toujours écrasés sur demande.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "en cours de lot : l'erreur les liste (ancien → nouveau) et la bibliothèque pointe vers leurs nouveaux noms (un renommage partiel annulable),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modèles dont le capteur commence par une rangée vert-bleu) ne sortent plus magenta (ticket #85) : la disposition du filtre coloré", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nom (sans jamais remplacer un fichier), et c'est seulement alors que la photo est reliée ou la copie raw supprimée — une écriture échouée", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "ne laissent jamais de fichier tronqué ; le fichier annexe XMP d'un export « Original » suit aussi le choix « Si le fichier existe ».", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "se développent désormais à partir des données raw au lieu du JPEG intégré de l'appareil ; ainsi un style d'image N&B ou autre réglé sur", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "sur la carte après Vérifier l'importation est signalé au lieu d'être importé avec des informations périmées. Vérifier un chemin d'export", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "un par un — ils peuvent toujours être exportés de nouveau, et sur un disque USB ou un NAS cette synchronisation fichier par fichier dominait un gros", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "à un fichier similaire de même nom et même taille, et ignore (en le signalant) les photos qu'il ne peut pas distinguer au lieu de deviner.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "partie), ou saisissez ce qu'il faut sélectionner (« ciel », « la voiture rouge », « voiture, route ») ; les deux se combinent avec d'autres masques, ont un", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "ce qui préserve la retouche RAW et l'export en pleine résolution au lieu d'utiliser les aperçus JPEG intégrés.", + "read in the wrong units.": "lu dans de mauvaises unités.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "relire la carte une troisième fois (Déplacer compare toujours octet par octet avant de supprimer une source) ; un fichier modifié", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "les rendus et les captures d'écran sont toujours écrits dans un fichier temporaire puis renommés à leur place, mais ne sont plus forcés sur le disque", + "save goes through.": "l'enregistrement aboutisse.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "scène) se développent désormais à partir des données du capteur avec tous les outils de retouche, comme les autres raws de l'appareil. Les photos", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "dérivés) ne s'ouvrent plus en vert vif (ticket #148) : leur balance des blancs telle que prise et leur niveau de noir sont lus depuis le", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "fichier annexe qui n'est pas un XMP valide est d'abord copié vers `<name>.xmp.bak-<time>`. Avec le nommage par défaut sur le nom de base, un raw et un", + "smart album value|No": "Non", + "smart album value|Yes": "Oui", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "afin qu'aucun ne paraisse manquant. Renommer l'un d'une paire raw + JPEG copie leur `IMG_0001.xmp` commun au lieu de le retirer", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "le démarrage du GPU, le lancement suivant démarre avec le rendu GPU désactivé et indique comment le réactiver.", + "still saves your queued changes in the closing snapshot.": "enregistre quand même vos modifications en attente dans l'instantané de fermeture.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronisés. Importer → Copier vérifie désormais chaque copie avec l'empreinte du contenu calculée lors de l'analyse de la carte au lieu de", + "the JPEG uses `IMG_0001.JPG.xmp`.": "le JPEG utilise `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "l'arborescence de dossiers de Local et l'importation automatique lisent aussi le disque sur des threads de travail. La fenêtre de progression de l'importation a un bouton Annuler.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "le menu Photo et le menu contextuel de la photo) récupère l'aperçu en pleine taille et complète les métadonnées de l'appareil qui leur", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "l'appareil ; les défauts du capteur répertoriés sont comblés. Les fichiers `.rwl` et `.raw` sont aussi importés. Les photos déjà", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "le même nom et « Écraser » (ou Exporter avec les réglages précédents qui le répète), un chemin de sortie exact venant du canal", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "leur dossier n'a pas été parcouru depuis 30 jours — vos fichiers et fichiers annexes restent, et parcourir le dossier les affiche", + "to the camera's own JPEG.": "du JPEG de l'appareil.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "intacte — les anciennes versions n'en lisent plus une partie comme un journal endommagé. Une fois qu'une bibliothèque a été ouverte par cette version,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "utilisé (p. ex. un adaptateur logiciel ignoré comme llvmpipe) et pourquoi le dernier rendu s'est replié.", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "(Linux, APFS sensible à la casse), `img_1.JPG` à côté de `IMG_1.JPG` est une autre photo et celle renommée reçoit", + "were missing, without touching anything already set.": "manquaient, sans toucher à ce qui est déjà renseigné.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "quand le navigateur risque d'évincer la bibliothèque. Hébergement : les en-têtes de cache d'exemple ne marquent plus les fichiers (sans empreinte)", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "la présence des originaux est vérifiée sur un thread de travail (vignettes de la grille, panneau Infos, menu de la photo, Photos", + "with a counterpart here come along, the rest is listed.": "qui ont un équivalent ici suivent, le reste est listé.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "écrite par une version plus récente de LightCraft est refusée avec « cette bibliothèque a été écrite par une version plus récente de LightCraft » et laissée", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "écrite par une version plus récente de LightCraft), LightCraft le signale avec la raison et propose Réessayer, Choisir une autre bibliothèque…, Continuer", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-glisser pour redresser, ⇧G Upright guidé, une grille pendant la transformation.", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Aucun processeur graphique trouvé : LightCraft effectue le rendu sur le processeur, ce qui est beaucoup plus lent. Détails dans Aide → Informations système.", "On NixOS, start it from `nix develop`.": "Sous NixOS, lancez-le depuis `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé." + "Files with the same content as another file in this import: only the first is imported.": "Fichiers au contenu identique à un autre fichier de cette importation : seul le premier est importé.", + "Lightroom Catalog Import": "Importation de catalogue Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ces photos sont uniquement en mémoire. Ouvrez une bibliothèque sur disque et importez à nouveau le catalogue pour les enregistrer.", + "Show in Library": "Afficher dans la bibliothèque", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Les catalogues Lightroom sont importés dans la bibliothèque ouverte ; aucun dossier de catalogue distinct n’est créé.", + "Destination library": "Bibliothèque de destination", + "Playback": "Lecture", + "Layout": "Disposition", + "Link all margins": "Lier toutes les marges", + "Margins": "Marges", + "Left": "Gauche", + "Right": "Droite", + "Top": "Haut", + "Bottom": "Bas", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Les marges sont une part du petit côté de la diapositive : les diapositives sont identiques sur tous les écrans.", + "Show guides": "Afficher les repères", + "Aspect preview": "Aperçu des proportions", + "Zoom to fill frame": "Zoom pour remplir le cadre", + "Stroke border": "Contour", + "Colour": "Couleur", + "Cast shadow": "Ombre portée", + "Offset": "Décalage", + "Backdrop": "Toile de fond", + "Background colour": "Couleur d’arrière-plan", + "Colour wash": "Dégradé de couleur", + "Background image": "Image d’arrière-plan", + "(not in the library)": "(absente de la bibliothèque)", + "Use selected photo": "Utiliser la photo sélectionnée", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Sélectionnez une photo, puis activez cette option pour en remplir le fond derrière chaque photo.", + "Reset layout": "Réinitialiser la disposition", + "Select a photo to preview its slide": "Sélectionnez une photo pour prévisualiser sa diapositive", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "La photo sélectionnée en diapositive. La lecture remplit l’écran ; l’aperçu des proportions ne change que cet aperçu.", + "Overlays": "Incrustations", + "Identity plate": "Plaque d'identification", + "Your name or studio": "Votre nom ou studio", + "Anchored to": "Ancré à", + "Slide": "Diapositive", + "Render behind image": "Afficher derrière l'image", + "Rating stars": "Étoiles de notation", + "Text overlays": "Incrustations de texte", + "Add text overlay": "Ajouter une incrustation de texte", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Jetons : {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Le reste du texte s'affiche tel quel.", + "Use export watermark": "Utiliser le filigrane d'exportation", + "The export watermark, drawn on the photo and sized relative to it.": "Le filigrane d'exportation, dessiné sur la photo et dimensionné par rapport à elle.", + "Titles": "Titres", + "Intro screen": "Écran d'introduction", + "Ending screen": "Écran de fin", + "Shows": "Affiche", + "Colour only": "Couleur seule", + "Override colour": "Remplacer la couleur", + "Own duration (off: the slide duration)": "Durée propre (désactivée : celle des diapositives)", + "Duration": "Durée", + "Title text": "Texte du titre", + "Intro": "Intro", + "Ending": "Fin", + "End": "Fin", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "L'introduction est lue une fois avant la première photo ; l'écran de fin après la dernière photo lorsque Répéter est désactivé. La plaque d'identification est celle définie sous Incrustations. Les titres apparaissent en fondu comme les diapositives et l'ordre aléatoire ne les déplace jamais.", + "This screen is off: turn it on under Titles to play it.": "Cet écran est désactivé : activez-le sous Titres pour le lire.", + "The intro screen, before the first photo.": "L'écran d'introduction, avant la première photo.", + "The ending screen, after the last photo.": "L'écran de fin, après la dernière photo.", + "Templates": "Modèles", + "Built-in": "Intégrés", + "Your templates": "Vos modèles", + "None yet: save the current settings as a template below.": "Aucun pour l'instant : enregistrez les réglages actuels comme modèle ci-dessous.", + "Before the last template": "Avant le dernier modèle", + "Previous settings": "Réglages précédents", + "New template name": "Nom du nouveau modèle", + "Save as Template": "Enregistrer comme modèle", + "Import…": "Importer…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Vos propres modèles nécessitent un dossier de réglages, absent de cette version ; les modèles intégrés fonctionnent.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Cliquez sur un modèle pour l'appliquer ; survolez-en un pour l'afficher sur la photo sélectionnée. Clic droit sur les vôtres pour les mettre à jour, les renommer, les exporter ou les supprimer.", + "Import Slideshow Templates": "Importer des modèles de diaporama", + "Export Slideshow Template": "Exporter le modèle de diaporama", + "Slideshow Templates": "Modèles de diaporama", + "Plain Black": "Noir uni", + "Gallery Wall": "Mur de galerie", + "Edge to Edge": "Bord à bord", + "Cinema Wide": "Cinéma large", + "Shooting Data": "Données de prise de vue", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Chaque photo aussi grande que possible, sur noir, avec un court fondu enchaîné. Rien par-dessus.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Un mur gris clair avec un passe-partout blanc et une ombre douce ; la légende et la note sous la photo.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Chaque photo recadrée pour remplir tout l'écran, avec des fondus enchaînés lents.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Un cadre 16:9 avec des bandes noires en haut et en bas, le titre dans le coin, des fondus au noir et un carton de fin.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Un fond ardoise sombre avec l'exposition, l'ISO et la focale, puis le boîtier et l'objectif, sous chaque photo.", + "Exporting slideshow": "Exportation du diaporama", + "Export Slideshow…": "Exporter le diaporama…", + "Export Slideshow": "Exporter le diaporama", + "Export Slideshow: folder for the JPEG files": "Exporter le diaporama : dossier des fichiers JPEG", + "Frame rate": "Images par seconde", + "Container": "Conteneur", + "Export PDF…": "Exporter en PDF…", + "Export JPEG…": "Exporter en JPEG…", + "Export Video…": "Exporter en vidéo…", + "Automatically show full screen": "Afficher automatiquement en plein écran", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Une page par diapositive (le grand côté en pixels = points), avec la durée et le fondu de la diapositive pour le mode présentation du lecteur.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Fichiers JPEG numérotés (nom-001.jpg…) dans un nouveau dossier nommé à l'étape suivante.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vidéo AV1, chaque diapositive affichée pendant sa durée avec ses fondus. Sans son : la musique n'est pas encore exportée.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Les diapositives utilisent la mise en page, les incrustations et les écrans d'ouverture et de fin du diaporama, dans l'ordre de l'affichage.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Les photos affichées en diapositives, avec les réglages ci-dessus. La progression s'affiche dans la pile d'activités.", + "Checking raw files": "Vérification des fichiers RAW", + "1 raw can now be developed from its raw data": "1 fichier RAW peut désormais être développé à partir de ses données RAW", + "This raw is now developed from its raw data": "Ce fichier RAW est désormais développé à partir de ses données RAW", + "This raw variant still can't be decoded": "Cette variante RAW ne peut toujours pas être décodée", + "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).", + "Export to": "Exporter vers", + "Specific folder": "Dossier spécifique", + "Same folder as original photo": "Même dossier que la photo d’origine", + "Tidying the thumbnail cache": "Rangement du cache des miniatures", + "Reset all edits (Cmd+Shift+R)": "Réinitialiser toutes les retouches (Cmd+Maj+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Les albums intelligents se mettent à jour d'eux-mêmes : modifiez leurs règles ou utilisez Photo → Supprimer la photo", + "LightCraft stopped": "LightCraft s’est arrêté", + "Show Focus Point": "Afficher le point de mise au point", + "Zoom to Focus Point": "Zoomer sur le point de mise au point", + "Focus": "Mise au point", + "Focus point": "Point de mise au point", + "Wide": "Large", + "Center": "Centre", + "Flexible Spot": "Spot flexible", + "Zone": "Zone", + "Expanded Flexible Spot": "Spot flexible étendu", + "Custom AF Area": "Zone AF personnalisée", + "Face tracking": "Suivi des visages", + "Lock On AF": "AF verrouillé", + "Multi": "Multi", + "Spot": "Spot", + "Tracking": "Suivi", + "Face Tracking": "Suivi des visages", + "Animal Eye Tracking": "Suivi des yeux d’animaux", + "Human Eye Tracking": "Suivi des yeux humains", + "Read Focus Points": "Lire les points de mise au point", + "Reading focus points": "Lecture des points de mise au point", + "Click to type a value": "Cliquez pour saisir une valeur", + "Run face models on the CPU only": "Exécuter les modèles de visages sur le processeur uniquement", + "Detection": "Détection", + "Recognition": "Reconnaissance", + "Not loaded yet": "Pas encore chargé", + "Checking the GPU…": "Vérification du GPU…", + "CPU": "Processeur", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Un modèle de visages n’utilise le GPU que si une vérification montre qu’il donne les mêmes résultats plus vite.", + "Checking the GPU for faces": "Vérification du GPU pour les visages" } diff --git a/crates/ui-egui/locales/it-formats.json b/crates/ui-egui/locales/it-formats.json new file mode 100644 index 000000000..8edab510a --- /dev/null +++ b/crates/ui-egui/locales/it-formats.json @@ -0,0 +1,299 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} con diversi file simili: usa Trova)", + " ({} in Recently Deleted)": " ({} in Eliminate di recente)", + " · {:.0}% clipped": " · {:.0}% tagliato", + " · {failed} couldn't be rendered": " · impossibili da elaborare: {failed}", + " · {skipped} skipped (file exists)": " · saltate: {skipped} (il file esiste)", + " · {} duplicate{} skipped": " · duplicati saltati: {}{:.0}", + " · {} failed: {}": " · {} con errori: {}", + " · {} moved": " · spostate: {}", + " · {} not readable": " · illeggibili: {}", + " · {} original{} left at the source": " · originali lasciati nell'origine: {}{:.0}", + " · {} restored from Recently Deleted": " · ripristinate da Eliminate di recente: {}", + " — not carried over: {}{}": " · non trasferito: {}{}", + "+{} more": "+{} altre", + ", {failed} file{} not readable": ", file illeggibili: {failed}{:.0}", + "; {kept} already had a location": "; avevano già una posizione: {kept}", + "; {outside} outside its time range": "; fuori dal loro intervallo di tempo: {outside}", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} commit, +{} / −{} righe (Δ {}), +{} / −{} risorse binarie, {} - {}", + "Add {local_n} to My Photos": "Aggiungi {local_n} a Le mie foto", + "Add {} Photo{}": "Aggiungi {} foto{:.0}", + "Add {} Photo{}, restore {}": "Aggiungi {} foto{:.0}, ripristina {}", + "Add “{}” to Favorites": "Aggiungi “{}” ai preferiti", + "Added {n} photo{} to My Photos": "Foto aggiunte a Le mie foto: {n}{:.0}", + "Added {n} photo{} to “{}”": "Foto aggiunte a “{1}”: {n}{0:.0}", + "Added {what} to {name}": "Aggiunto a {name}: {what}", + "Adding photos… {} of {}": "Aggiunta delle foto… {} di {}", + "All {n} selected photos move by the same amount.": "Tutte le {n} foto selezionate si spostano della stessa quantità.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Già nella libreria, in Eliminate di recente: foto selezionate {}{:.0} · clic destro → Ripristina, oppure Elimina definitivamente per importarle da capo", + "Already in the library: {} photo{} selected in All Photos": "Già nella libreria (selezionate in Tutte le foto): {}{:.0}", + "Auto ({})": "Automatico ({})", + "Auto Import: added {} photo{}": "Importazione automatica · foto aggiunte: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Sincronizzazione automatica: le modifiche si applicano a {n} foto", + "Automatic ({} MB)": "Automatico ({} MB)", + "Build 1:1 Previews ({scope})": "Genera anteprime 1:1 ({scope})", + "Build Smart Previews ({scope})": "Genera anteprime avanzate ({scope})", + "Build Standard-Sized Previews ({scope})": "Genera anteprime di dimensione standard ({scope})", + "Building previews for {} photos…": "Generazione delle anteprime per {} foto…", + "Click {}:1": "Clic {}:1", + "Couldn't open the editor: {e}": "Impossibile aprire l'editor: {e}", + "Create {n} Virtual Copies": "Crea {n} copie virtuali", + "Creates {} stacks from {} of {scope}": "Crea pile: {} (da {} di {scope})", + "Decoder: {reason}": "Decodificatore: {reason}", + "Delete \"{label}\"": "Elimina \"{label}\"", + "Delete \"{label}\" (and the mask)": "Elimina \"{label}\" (e la maschera)", + "Delete {n} Photos": "Elimina {n} foto", + "Delete “{current}”": "Elimina “{current}”", + "Discard Smart Previews ({scope})": "Elimina anteprime avanzate ({scope})", + "Done ({what}): {done} photo{plural}": "Fatto ({what}): {done} foto{plural:.0}", + "Duplicate \"{label}\"": "Duplica \"{label}\"", + "Example: {example}": "Esempio: {example}", + "Exported {n} photo{}": "Foto esportate: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Foto esportate: {ok} di {total}{:.0}", + "Exported {} point curve preset{}": "Preset di curva a punti esportati: {}{:.0}", + "Exported {} preset{}": "Preset esportati: {}{:.0}", + "Exporting {} of {total}": "Esportazione {} di {total}", + "Filter bar — {active} active filter{}": "Barra dei filtri · filtri attivi: {active}{:.0}", + "Focus {:.0}": "Messa a fuoco {:.0}", + "Folder “{}” (and its subfolders)": "Cartella “{}” (e sottocartelle)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Foto mancanti ritrovate: {n}{:.0}; ancora mancanti: {left}{unsure}", + "Healed {n} dust spot{}": "Macchie di polvere corrette: {n}{:.0}", + "Import cancelled · {} photo{} added": "Importazione annullata · foto aggiunte: {}{:.0}", + "Import failed: {e}": "Importazione non riuscita: {e}", + "Import warnings ({n}):\n{warnings}": "Avvisi di importazione ({n}):\n{warnings}", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Il profilo XMP \"{profile}\" non è stato applicato a {path}; i colori possono differire da Lightroom.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "La geometria di Lightroom non è stata conservata per {path}; {mode} verrà ricalcolato.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata viene ignorata perché il raddrizzamento è disattivato.", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento guidato non è supportata.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "La geometria di Lightroom per {path} non è stata conservata; la trasformazione salvata del raddrizzamento non è supportata.", + "XMP adjustments were not imported for {path}: {fields}.": "Le regolazioni XMP non sono state importate per {path}: {fields}.", + "Imported {n} point curve preset{}": "Preset di curva a punti importati: {n}{:.0}", + "Imported {n} preset{}": "Preset importati: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Preset importati: {n}{:.0}, file illeggibili: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Profili importati: {profiles}{:.0} (Browser dei profili → i loro gruppi)", + "Imported {} photo{}": "Foto importate: {}{:.0}", + "Insert at the cursor (also written {})": "Inserisci al cursore (si scrive anche {})", + "Label Set: {name}": "Set di etichette: {name}", + "LightCraft could not open its window: {e}": "LightCraft non è riuscito ad aprire la sua finestra: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft non è riuscito ad aprire la sua finestra: {e}\n\nI dettagli sono nel file di log: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Catalogo di Lightroom esaminato: foto {photos}, raccolte {collections}, mancanti {missing}", + "Lightroom import complete: {imported} photos added": "Importazione da Lightroom completata: foto aggiunte {imported}", + "Lightroom import complete: {imported} photos added, {failed} failed": "Importazione da Lightroom completata: foto aggiunte {imported}, con errori {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Importazione da Lightroom completata: foto aggiunte {imported}; avviso sull'indice: {warning}", + "Lightroom import failed: {e}{warning}": "Importazione da Lightroom non riuscita: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Analisi di Lightroom non riuscita: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT OR Apache-2.0. Caratteri: {} (OFL). Icone: originali.", + "Matches: {}": "Corrispondenze: {}", + "Merging… {stage} {:.0}%": "Unione in corso… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Spostare {what} in Eliminate di recente?", + "Named ({named_n})": "Con nome ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Pennello “{k}”: fai clic sulle foto per aggiungerla o toglierla · Esc interrompe", + "Paste into {n} photo{} — only settings that were copied are pasted": "Incolla in {n} foto{:.0}: vengono incollate solo le impostazioni copiate", + "Preset: {name}": "Preset: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera ({})", + "Preview only — {}": "Solo anteprima: {}", + "Previews ready for {done} photo{plural}": "Anteprime pronte per {done} foto{plural:.0}", + "Profile: {}": "Profilo: {}", + "Proof Copy ({})": "Copia di prova ({})", + "Proof Preview · {}": "Anteprima di prova · {}", + "Quick Develop · {n} photos": "Sviluppo rapido · {n} foto", + "Quick Develop: {name}": "Sviluppo rapido: {name}", + "Rated {}": "Valutata {}", + "Reading Lightroom catalog… {done} of {total}": "Lettura del catalogo Lightroom… {done} di {total}", + "Reading photos… {done} of {total}": "Lettura delle foto… {done} di {total}", + "Reading photos… {} of {}": "Lettura delle foto… {} di {}", + "Redo {}": "Ripeti {}", + "Remove “{name}” and its {count} photo{} from the library?": "Togliere dalla libreria “{name}” e le sue foto ({count}{:.0})?", + "Remove “{}” from Favorites": "Rimuovi “{}” dai preferiti", + "Removed from {}": "Rimosso da {}", + "Removed {what} from {name}": "Rimosso da {name}: {what}", + "Rename {n} Photos…": "Rinomina {n} foto…", + "Rename “{name}”": "Rinomina “{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Rinomina “{from}” in {n} foto{:.0} (anche le parole chiave sotto di essa). Usa | per i livelli, es. Viaggi|Italia. Un nome esistente unisce le due.", + "Rendering on the CPU: {why}": "Rendering sulla CPU: {why}", + "Replace {} with:": "Sostituisci {} con:", + "Reset {name}": "Reimposta {name}", + "Reset {} Channel": "Reimposta canale {}", + "Restore {}": "Ripristina {}", + "Restore {} Photo{}": "Ripristina {} foto{:.0}", + "Restored {} photo{} from Recently Deleted": "Foto ripristinate da Eliminate di recente: {}{:.0}", + "Saved export preset “{}”": "Preset di esportazione “{}” salvato", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Valuta messa a fuoco ed esposizione di {scope} e trova scatti simili fatti a pochi secondi di distanza (raffiche).", + "Show {hidden} hidden location{}": "Mostra le posizioni nascoste: {hidden}{:.0}", + "Showing {n} of {}": "Mostrate {n} di {}", + "Smart album: {}": "Album smart: {}", + "Tagged {n} photo{} from the tracklog": "Foto geolocalizzate dal tracciato: {n}{:.0}", + "The SAM 3 download failed: {e}": "Download di SAM 3 non riuscito: {e}", + "Undo {}": "Annulla {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Tag sconosciuti{s:.0}: {} (restano come li hai scritti{:.0}; vedi Tag)", + "Updated “{name}”": "“{name}” aggiornato", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versione {}: libreria fotografica e sviluppo raw clean-room, in puro Rust.", + "Version: {}": "Versione: {}", + "Virtual copy “{name}” of {of}": "Copia virtuale “{name}” di {of}", + "Working on {what} for {} photos…": "Elaborazione di {what} per {} foto…", + "none (CPU rendering): {why}": "nessuna (rendering sulla CPU): {why}", + "the {n} selected photos": "le {n} foto selezionate", + "the {} photos in view": "le {} foto nella vista", + "these {count} photos": "queste {count} foto", + "{count} smart preview{} · {:.1} MB": "Anteprime avanzate: {count}{0:.0} · {1:.1} MB", + "{f} folders (and their subfolders)": "{f} cartelle (e le loro sottocartelle)", + "{label} Grading": "Gradazione di {label}", + "{label} {d:+} on every selected photo": "{label} {d:+} su ogni foto selezionata", + "{label} {index} of {n}": "{label} {index} di {n}", + "{label}, {n} photos": "{label}, {n} foto", + "{matching} of {t} photos": "{matching} di {t} foto", + "{matching} photos": "{matching} foto", + "{mode} spot {} of {}": "{mode}: macchia {} di {}", + "{month}": "{month:02}", + "{month} {year}": "{month:02}/{year}", + "{msg} ({failed} failed)": "{msg} ({failed} con errori)", + "{name} Label": "Etichetta {name}", + "{name} Off": "{name}: disattivato", + "{name} On": "{name}: attivato", + "{name} opened for editing; it is stacked with the original": "{name} aperta per la modifica; è in pila con l'originale", + "{name} — double-click to reset it": "{name}: doppio clic per reimpostarlo", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Modifiche non scritte sul disco: {n}{0:.0}: {e}\nSe esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Modifiche salvate in memoria ma non scritte sul disco: {n}{0:.0}: {e}\nLightCraft riprova da solo; se esci ora, andranno perse.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Modifiche salvate solo in memoria: {n}{:.0}: {e}; LightCraft riproverà", + "{n} found · {sel} selected": "Trovate: {n} · selezionate: {sel}", + "{n} photos": "{n} foto", + "{n} photos in {} are hidden by the filters above": "I filtri qui sopra nascondono {n} foto in {}", + "{n} photo{}": "{n} foto{:.0}", + "{n} photo{} match · updates automatically as photos change": "Foto corrispondenti: {n}{:.0} · si aggiorna da solo quando le foto cambiano", + "{n} photo{} now · updates automatically as photos change": "{n} foto{:.0} ora · si aggiorna da solo quando le foto cambiano", + "{pid} (missing)": "{pid} (mancante)", + "{sel_n} selected · {counted}": "Selezionate: {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} {n} foto{:.0}", + "{weekday}, {day}": "{weekday} {day}", + "{weekday}, {day} {month} {year}": "{weekday} {day}/{month:02}/{year}", + "{what} merge added": "Unione {what} aggiunta", + "{what} merge failed: {e}": "Unione {what} non riuscita: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Direttive {{date:…}}: {}", + "{} and higher": "{} e superiori", + "{} contributors · {} commits": "Collaboratori: {} · commit: {}", + "{} failed": "Con errori: {}", + "{} files": "{} file", + "{} files and {f} folder{}": "{} file, cartelle: {f}{:.0}", + "{} item{}": "Elementi: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} di 4 guide: trascina lungo le linee che dovrebbero essere verticali o orizzontali.", + "{} photos": "{} foto", + "{} photos · exposures {} EV": "{} foto · esposizioni {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} foto · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Foto illeggibili: {}{:.0}", + "{} sample{}": "Campioni: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "Macchie in questa foto: {}", + "{} — show their photos": "{}: mostra le loro foto", + "· {dups} already in the library": "· già nella libreria: {dups}", + "· {n} photo{}": "· {n} foto{:.0}", + "· {trashed} in Recently Deleted": "· {trashed} in Eliminate di recente", + "… and {more} more": "… e altre {more}", + "LightCraft can't decode this raw variant yet ({}). You're editing the camera's embedded JPEG preview, which already includes the camera's picture style (e.g. Monochrome) and white balance.": "LightCraft non sa ancora decodificare questa variante raw ({}). Stai modificando l'anteprima JPEG incorporata dalla fotocamera, che contiene già lo stile immagine della fotocamera (ad es. Monocromatico) e il bilanciamento del bianco.", + "Filtered: {label}": "Filtrate: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Foto simili: {n}{:.0} · Visualizza → Cancella filtri per vederle tutte", + "Moved; {} photo(s) relinked": "Spostata; foto ricollegate: {}", + "{} label (click more labels to show any of them)": "Etichetta {} (fai clic su altre etichette per mostrarne una qualsiasi)", + ", {} star{}": ", stelle: {}{:.0}", + ", {} label": ", etichetta {}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Scaricato da {host}. Quando è arrivato ed è stato verificato, LightCraft lo installa e {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "In esecuzione sul processore: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Conservate nella cartella “denoise” della libreria; al raggiungimento del limite si eliminano per prime le più vecchie che nessuna foto usa.", + "Denoising {name}: {done} of {total} tiles{}": "Riduzione disturbo di {name}: {done} di {total} riquadri{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} di {} da {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Generazione dell'immagine con il disturbo ridotto… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Addestrato su: {provenance}", + " · {queued} waiting": " · in attesa: {queued}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Mostra le condizioni del modello, poi scarica {} da {host}, lo installa e comincia a usarlo. Non viene inviato nient'altro.", + "In line for AI Denoise ({ahead} ahead)…": "In coda per la riduzione disturbo IA ({ahead} prima)…", + "Running on {}.": "In esecuzione su {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Metti dentro “{parent}”", + "Delete the keyword “{name}”?": "Eliminare la parola chiave “{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Viene tolta da {count} foto{:.0}, insieme alle parole chiave sotto di essa. Annulla la riporta.", + "Create Keyword Tag Inside “{name}”…": "Crea tag parola chiave dentro “{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "“{parent}” ha già “{name}”. Unire le due?", + "There is a “{name}” at the top level already. Merge the two?": "Al livello principale c'è già “{name}”. Unire le due?", + "Added “{keyword}” to {n} photo{}": "Aggiunta “{keyword}” a {n} foto{:.0}", + "On {have} of {of} selected photos": "Su {have} di {of} foto selezionate", + "Remove “{name}”": "Rimuovi “{name}”", + "There is a keyword set “{name}” already": "Esiste già un set di parole chiave “{name}”", + "Added {n} keyword{}": "Parole chiave aggiunte: {n}{:.0}", + "; {n} gained synonyms": "; con nuovi sinonimi: {n}", + "Exported {n} keyword{}": "Parole chiave esportate: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " · Capture One non importa un elenco con ; , < o > in una parola chiave: {names}", + " — left out, as a keyword list can't hold them: {names}": " · escluse, perché un elenco di parole chiave non può contenerle: {names}", + "{done} of {total}": "{done} di {total}", + "{done} of {total} MB": "{done} di {total} MB", + "{pct} %": "{pct}%", + "+{n} more": "+{n} altre", + "1 task is still running: {names}. Quitting stops it.": "1 attività è ancora in corso: {names}. Uscendo si interrompe.", + "{n} tasks are still running: {names}. Quitting stops them.": "{n} attività sono ancora in corso: {names}. Uscendo si interrompono.", + "{what} merge cancelled": "Unione {what} annullata", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Foto locali saltate: {n}{0:.0}; gli album contengono foto della libreria (prima aggiungile a Le mie foto)", + "New Folder in “{name}”": "Nuova cartella in “{name}”", + "Moved {} photo(s)": "Foto spostate: {}", + "not added to Apple Photos: {e}": "non aggiunte a Foto di Apple: {e}", + "{n} added to Apple Photos album “{album}”": "Aggiunte all'album “{album}” di Foto di Apple: {n}", + "{n} added to Apple Photos": "Aggiunte a Foto di Apple: {n}", + "{n} photos opened for editing; each is stacked with its original": "{n} foto aperte per la modifica; ciascuna è in pila con il suo originale", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "{name} opened in the other editor": "{name} opened in the other editor", + "{s} s of AV1 video": "{s} s of AV1 video", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "{count} more warnings.": "{count} more warnings.", + "· {in_source} identical copies": "· {in_source} identical copies", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "{n} preset{}": "{n} preset{}", + " — kept but not applied: {}": " — kept but not applied: {}", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "Saved template {name}": "Saved template {name}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "Delete “{name}”?": "Delete “{name}”?", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "new, inside {parent}": "new, inside {parent}", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "Skipped {problem}": "Skipped {problem}", + "Updated {name}": "Updated {name}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "Slideshow exported: {what}": "Slideshow exported: {what}", + "Exported to {path}": "Exported to {path}", + "…and {more} more": "…and {more} more", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "{n} JPEG files": "{n} JPEG files", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {name}": "Imported {name}", + "{n} pages": "{n} pages", + " · kept, not applied: {}": " · kept, not applied: {}", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "Applied {name}": "Applied {name}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{n} originals opened in the other editor": "{n} originals opened in the other editor", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "Effect: {name}": "Effect: {name}", + " in {g} group{}": " in {g} group{}", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Already in “{}”", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." +} diff --git a/crates/ui-egui/locales/it.json b/crates/ui-egui/locales/it.json new file mode 100644 index 000000000..382fb80c9 --- /dev/null +++ b/crates/ui-egui/locales/it.json @@ -0,0 +1,1994 @@ +{ + " (library default)": " (predefinito della libreria)", + " (inverted)": " (invertita)", + " · best of its burst": " · la migliore della sua raffica", + " · in a burst": " · in una raffica", + "% of all commits": "% di tutti i commit", + "+ Group": "+ Gruppo", + "+ Rule": "+ Regola", + "0: nothing is rejected.": "0: non viene scartato niente.", + "1 day": "1 giorno", + "2026-04 or 2026-04-12": "2026-04 oppure 2026-04-12", + "= exactly": "= esattamente", + "A blank template keeps the original names.": "Un modello vuoto mantiene i nomi originali.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Un filtro nasconde le foto di questa vista: cambialo o toglilo (Visualizza → Cancella filtri)", + "A one-time download of about": "Un download unico di circa", + "AI Mask Model Status": "Stato del modello IA per le maschere", + "About": "Informazioni", + "About LightCraft": "Informazioni su LightCraft", + "Active": "Attivo", + "Activity": "Attività", + "Actual size": "Dimensione reale", + "Add": "Aggiungi", + "Add Catchlight": "Aggiungi riflesso negli occhi", + "Add Folder to Local": "Aggiungi cartella a Locale", + "Add Mask": "Aggiungi maschera", + "Add Object Click": "Aggiungi oggetto con un clic", + "Add Point Color Sample": "Aggiungi campione colore puntuale", + "Add Red Eye Correction": "Aggiungi correzione occhi rossi", + "Add Remove Spot": "Aggiungi rimozione macchie", + "Add Selected Photos": "Aggiungi le foto selezionate", + "Add a rule (⌥-click: a group)": "Aggiungi una regola (⌥-clic: un gruppo)", + "Add in place": "Aggiungi sul posto", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Aggiungi sul posto: la libreria usa i file dove si trovano; niente viene copiato o spostato.", + "Add keyword": "Aggiungi parola chiave", + "Add number": "Aggiungi numero", + "Add to": "Aggiungi a", + "Add to Album": "Aggiungi all'album", + "Add to Favorites": "Aggiungi ai preferiti", + "Add to Local": "Aggiungi a Locale", + "Add to My Photos": "Aggiungi a Le mie foto", + "Add to Selected Photos": "Aggiungi alle foto selezionate", + "Add to Target Album": "Aggiungi all'album di destinazione", + "Add to this mask": "Aggiungi a questa maschera", + "Add/Subtract/Intersect Mask": "Aggiungi, sottrai o interseca maschera", + "Added to photos you import that don't already have it.": "Aggiunto alle foto che importi e che non lo hanno già.", + "Adding": "Aggiunta in corso", + "Adding Lightroom photos…": "Aggiunta delle foto di Lightroom…", + "Advanced search…": "Ricerca avanzata…", + "After": "Dopo", + "After 30 days": "Dopo 30 giorni", + "After 7 days": "Dopo 7 giorni", + "After 90 days": "Dopo 90 giorni", + "After a year": "Dopo un anno", + "Album": "Album", + "Albums": "Album", + "All": "Tutti", + "All Metadata": "Tutti i metadati", + "All Metadata…": "Tutti i metadati…", + "All Photos": "Tutte le foto", + "All rights reserved": "Tutti i diritti riservati", + "All the photos imported from this disk are included.": "Sono incluse tutte le foto importate da questo disco.", + "All types": "Tutti i tipi", + "Alt Text": "Testo alternativo", + "Always": "Sempre", + "Amount": "Quantità", + "Analyzing the photo for AI masks…": "Analisi della foto per le maschere IA…", + "Any Searchable Text": "Qualsiasi testo ricercabile", + "Aperture": "Diaframma", + "Application": "Applicazione", + "Apply": "Applica", + "Apply Color Label Set": "Applica set di etichette colore", + "Apply Filter Preset": "Applica preset di filtro", + "Apply Metadata Preset": "Applica preset di metadati", + "Apply Point Curve Preset": "Applica preset di curva a punti", + "Apply Preset": "Applica preset", + "Apply Settings JSON": "Applica impostazioni JSON", + "Approved": "Approvata", + "Aqua": "Acqua", + "Aqua Hue": "Tonalità acqua", + "Aqua Luminance": "Luminanza acqua", + "Aqua Saturation": "Saturazione acqua", + "Arrange Panel": "Disponi il pannello", + "ArtCraft Website": "Sito web di ArtCraft", + "ArtCraft — more creative apps": "ArtCraft: altre app creative", + "As Shot": "Come scattata", + "As shot": "Come scattata", + "Ascending": "Crescente", + "Aspect": "Proporzioni", + "Aspect Ratio": "Proporzioni", + "Assisted Culling": "Selezione assistita", + "Assisted Culling…": "Selezione assistita…", + "Auto": "Automatico", + "Auto Advance": "Avanzamento automatico", + "Auto Advance Off": "Avanzamento automatico disattivato", + "Auto Advance On": "Avanzamento automatico attivato", + "Auto Advance after rating or flagging": "Avanzamento automatico dopo valutazione o contrassegno", + "Auto Advance: move to the next photo after rating or flagging": "Avanzamento automatico: passa alla foto successiva dopo una valutazione o un contrassegno", + "Auto Align": "Allineamento automatico", + "Auto B&W Mix": "Mix bianco e nero automatico", + "Auto Crop": "Ritaglio automatico", + "Auto Import": "Importazione automatica", + "Auto Import Now": "Importa automaticamente ora", + "Auto Import Settings": "Impostazioni di importazione automatica", + "Auto Mask": "Maschera automatica", + "Auto Select": "Selezione automatica", + "Auto Settings": "Impostazioni automatiche", + "Auto Straighten": "Raddrizzamento automatico", + "Auto Sync": "Sincronizzazione automatica", + "Auto settings applied": "Impostazioni automatiche applicate", + "Auto-Stack by Capture Time": "Raggruppa automaticamente per ora di scatto", + "Auto-Stack by Capture Time…": "Raggruppa automaticamente per ora di scatto…", + "Auto-Tag Photos from Tracklog": "Geolocalizza automaticamente le foto dal tracciato", + "Auto-Tag from Tracklog": "Geolocalizza dal tracciato", + "Auto-Tag from Tracklog…": "Geolocalizza dal tracciato…", + "Automatic": "Automatico", + "Automatic renders the Detail view at the size it is shown, up to the photo's own pixels. Choose a size to cap it: smaller is faster.": "Automatico disegna la vista Dettaglio alla dimensione in cui è mostrata, fino ai pixel della foto. Scegli una dimensione per limitarla: più piccola è più veloce.", + "Automatically Write Changes into XMP": "Scrivi automaticamente le modifiche nell'XMP", + "Automatically write changes into XMP sidecars": "Scrivi automaticamente le modifiche nei file XMP collaterali", + "Autumn Gold": "Oro d'autunno", + "B&W": "B/N", + "B&W Mix": "Mix B/N", + "B&W Mixer": "Mixer B/N", + "Back": "Indietro", + "Back Up Library…": "Esegui backup della libreria…", + "Back to Edit": "Torna a Modifica", + "Back to Edit (E)": "Torna a Modifica (E)", + "Back to Grid": "Torna alla griglia", + "Background": "Sfondo", + "Balance": "Bilanciamento", + "Basic": "Base", + "Before": "Prima", + "Before/After (Y)": "Prima/Dopo (Y)", + "Before/After Settings": "Impostazioni Prima/Dopo", + "Before/After Split": "Prima/Dopo diviso", + "Before/After Split Top/Bottom": "Prima/Dopo diviso sopra/sotto", + "Before/After Top/Bottom": "Prima/Dopo sopra/sotto", + "Begin Interaction": "Inizia interazione", + "Best of Similar Shots": "La migliore tra scatti simili", + "Binary assets added": "Risorse binarie aggiunte", + "Binary assets removed": "Risorse binarie rimosse", + "Bit depth": "Profondità bit", + "Black & White": "Bianco e nero", + "Blacks": "Neri", + "Bleach Bypass": "Bleach bypass", + "Bleached": "Sbiancato", + "Blending": "Fusione", + "Blue": "Blu", + "Blue Hour Boost": "Ora blu potenziata", + "Blue Hue": "Tonalità blu", + "Blue Luminance": "Luminanza blu", + "Blue Primary": "Primario blu", + "Blue Saturation": "Saturazione blu", + "Blue channel": "Canale blu", + "Blue label": "Etichetta blu", + "Blue/Yellow Fringe": "Frange blu/gialle", + "Blurry photos below the score are flagged as rejects.": "Le foto mosse sotto il punteggio vengono contrassegnate come scartate.", + "Boundary Warp": "Deformazione dei bordi", + "Bright & Airy": "Luminoso e arioso", + "Bring All to Front": "Porta tutto in primo piano", + "Browse Folder": "Sfoglia cartella", + "Browse Folder…": "Sfoglia cartella…", + "Browse Profiles": "Sfoglia profili", + "Browse…": "Sfoglia…", + "Brush": "Pennello", + "Brush Settings": "Impostazioni pennello", + "Brush Stroke": "Tratto pennello", + "Build Previews": "Genera anteprime", + "Build Smart Previews": "Genera anteprime avanzate", + "By Date": "Per data", + "By day (YYYY/YYYY-MM-DD)": "Per giorno (AAAA/AAAA-MM-GG)", + "By month (YYYY/YYYY-MM)": "Per mese (AAAA/AAAA-MM)", + "CPU threads": "Thread della CPU", + "Calibration": "Calibrazione", + "Camera": "Fotocamera", + "Camera make and model": "Marca e modello della fotocamera", + "Cameras and Cards": "Fotocamere e schede", + "Cancel": "Annulla", + "Cancel AI Mask Model Download": "Annulla il download del modello IA per le maschere", + "Cancel Download": "Annulla download", + "Cancel Interaction": "Annulla interazione", + "Cancel Preview Build": "Annulla generazione anteprime", + "Candidate": "Candidata", + "Caption": "Didascalia", + "Capture Date": "Data di scatto", + "Capture date and time in your own format (directives below)": "Data e ora di scatto nel formato che preferisci (direttive qui sotto)", + "Capture date as YYYYMMDD": "Data di scatto come AAAAMMGG", + "Captured": "Scattata", + "Catalog Statistics": "Statistiche del catalogo", + "Catchlight": "Riflesso negli occhi", + "Change": "Cambia", + "Change Component Mode": "Cambia modalità del componente", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "I caratteri non ammessi nei nomi dei file (/ \\ : * ? \" < > |) diventano -; a un nome già esistente si aggiunge -1, -2….", + "Check All": "Seleziona tutto", + "Check Output Path": "Controlla il percorso di uscita", + "Check the files of this type": "Seleziona i file di questo tipo", + "Checking names…": "Controllo dei nomi…", + "Choose Another Library…": "Scegli un'altra libreria…", + "Choose Edit Settings to Copy": "Scegli le impostazioni di modifica da copiare", + "Choose Edit Settings to Copy…": "Scegli le impostazioni di modifica da copiare…", + "Choose Folder…": "Scegli cartella…", + "Choose a photo in the grid or filmstrip": "Scegli una foto nella griglia o nella striscia", + "Choose…": "Scegli…", + "Cinematic": "Cinematografico", + "City": "Città", + "Clarity": "Chiarezza", + "Clean Studio": "Studio pulito", + "Clear": "Cancella", + "Clear Cache": "Svuota cache", + "Clear Filters": "Cancella filtri", + "Clear Guides": "Cancella guide", + "Clear History": "Cancella cronologia", + "Clear Preview Cache": "Svuota cache delle anteprime", + "Clear Quick Collection": "Svuota la raccolta rapida", + "Clear Reference": "Cancella riferimento", + "Clear all": "Cancella tutto", + "Clear form": "Svuota il modulo", + "Clear rating": "Togli valutazione", + "Clear rating and advance": "Togli valutazione e avanza", + "Cleared History": "Cronologia cancellata", + "Click Zoom Ratio": "Rapporto di zoom al clic", + "Click a shortcut, then press the new keys (Esc cancels).": "Fai clic su una scorciatoia, poi premi i nuovi tasti (Esc annulla).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Fai clic su un tag per inserirlo. Gli esempi si riferiscono a IMG_0042.CR3 scattata il 14 gen 2026 alle 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Fai clic sulle foto nella griglia per aggiungere o togliere la parola chiave; Esc interrompe", + "Click the object to select it · ⌥-click leaves a part out": "Fai clic sull'oggetto per selezionarlo · ⌥-clic esclude una parte", + "Click the photo to pick a colour · ⇧-click adds more": "Fai clic sulla foto per scegliere un colore · ⇧-clic ne aggiunge altri", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Fai clic sulla foto per scegliere un colore; ⇧-clic ne aggiunge altri (fino a 5)", + "Click to clear these filters": "Fai clic per togliere questi filtri", + "Click to restore · double-click to rename": "Clic per ripristinare · doppio clic per rinominare", + "Clone": "Clona", + "Close": "Chiudi", + "Close Panel": "Chiudi pannello", + "Close Window": "Chiudi finestra", + "Close all": "Chiudi tutto", + "Close file": "Chiudi file", + "Close panel": "Chiudi pannello", + "Cloudy": "Nuvoloso", + "Collapse": "Comprimi", + "Collapse All Stacks": "Comprimi tutte le pile", + "Collapse Stack": "Comprimi pila", + "Color": "Colore", + "Color Grading": "Gradazione colore", + "Color Label": "Etichetta colore", + "Color Label Names": "Nomi delle etichette colore", + "Color Label Sets": "Set di etichette colore", + "Color Mixer": "Miscelatore colore", + "Color Noise Reduction": "Riduzione disturbo colore", + "Color Priority": "Priorità colore", + "Color Range": "Intervallo colori", + "Color label cleared": "Etichetta colore tolta", + "Color space": "Spazio colore", + "Color space: sRGB (AVIF)": "Spazio colore: sRGB (AVIF)", + "Colors": "Colori", + "Combine files…": "Combina file…", + "Command Journal": "Registro dei comandi", + "Comments panel": "Pannello commenti", + "Commits": "Commit", + "Company": "Azienda", + "Compare": "Confronta", + "Compare (Shift+C)": "Confronta (Maiusc+C)", + "Compare Before and After": "Confronta prima e dopo", + "Compare needs at least two photos": "Per confrontare servono almeno due foto", + "Component options": "Opzioni del componente", + "Compression": "Compressione", + "Concrete Cool": "Cemento freddo", + "Confirm before deleting": "Chiedi conferma prima di eliminare", + "Confirm before moving photos to Recently Deleted": "Chiedi conferma prima di spostare le foto in Eliminate di recente", + "Constrain Crop": "Vincola il ritaglio", + "Continue": "Continua", + "Continue Without Saving": "Continua senza salvare", + "Contrast": "Contrasto", + "Contributors": "Collaboratori", + "Convert to DNG": "Converti in DNG", + "Cool Fade": "Dissolvenza fredda", + "Cool Morning": "Mattino fresco", + "Copy": "Copia", + "Copy After's Settings to Before": "Copia le impostazioni di Dopo in Prima", + "Copy Before to After": "Copia Prima in Dopo", + "Copy Before's Settings to After": "Copia le impostazioni di Prima in Dopo", + "Copy Edit Settings": "Copia impostazioni di modifica", + "Copy History Step Settings to Before": "Copia le impostazioni del passaggio in Prima", + "Copy Metadata": "Copia metadati", + "Copy as DNG": "Copia come DNG", + "Copy into the library (else use the files where they are)": "Copia nella libreria (altrimenti usa i file dove si trovano)", + "Copy pages": "Copia pagine", + "Copy the files (into the library's Originals/, or a folder you choose)": "Copia i file (negli Originals/ della libreria, o in una cartella a tua scelta)", + "Copy to": "Copia in", + "Copy to Clipboard": "Copia negli appunti", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Copia: i file vengono copiati nella cartella qui sotto e la libreria usa le copie; gli originali restano come sono.", + "Copyright": "Copyright", + "Copyright Info URL": "URL informazioni copyright", + "Copyright Status": "Stato del copyright", + "Country": "Paese", + "Create Album": "Crea album", + "Create Album…": "Crea album…", + "Create Folder": "Crea cartella", + "Create Folder…": "Crea cartella…", + "Create New Mask": "Crea nuova maschera", + "Create PDF from clipboard": "Crea PDF dagli appunti", + "Create PDF from file…": "Crea PDF da file…", + "Create PDF from images…": "Crea PDF da immagini…", + "Create Preset": "Crea preset", + "Create Preset…": "Crea preset…", + "Create Proof Copy": "Crea copia di prova", + "Create Smart Album": "Crea album smart", + "Create Smart Album from Filter…": "Crea album smart dal filtro…", + "Create Smart Album…": "Crea album smart…", + "Create Stack": "Crea pila", + "Create Version": "Crea versione", + "Create Version from Step": "Crea versione dal passaggio", + "Create Virtual Copy": "Crea copia virtuale", + "Creator": "Autore", + "Creator metadata": "Metadati dell'autore", + "Crisp Landscape": "Paesaggio nitido", + "Crisp Vista": "Panorama nitido", + "Crop": "Ritaglia", + "Crop & Rotate": "Ritaglia e ruota", + "Crop & Rotate (C)": "Ritaglia e ruota (C)", + "Crop Aspect": "Proporzioni del ritaglio", + "Crop, Rotate, Geometry": "Ritaglio, rotazione, geometria", + "Cross Process": "Processo incrociato", + "Culling": "Selezione", + "Curve": "Curva", + "Custom": "Personalizzato", + "Custom template…": "Modello personalizzato…", + "Custom…": "Personalizzato…", + "Cut": "Taglia", + "Cut pages": "Taglia pagine", + "Cycle Crop Overlay": "Cambia sovrapposizione del ritaglio", + "Cycle Crop Overlay Orientation": "Cambia orientamento della sovrapposizione del ritaglio", + "Cycle Info Overlay": "Cambia sovrapposizione delle informazioni", + "Cycle Mask Overlay Color": "Cambia colore della sovrapposizione maschera", + "Cycle Mask Overlay Mode": "Cambia modalità della sovrapposizione maschera", + "Cylindrical": "Cilindrica", + "Dark & Moody": "Scuro e intenso", + "Darken": "Scurisci", + "Darks": "Toni scuri", + "Date": "Data", + "Date Groups": "Gruppi per data", + "Day": "Giorno", + "Daylight": "Luce diurna", + "Decrease Brush Feather": "Riduci sfumatura del pennello", + "Decrease Brush Size": "Riduci dimensione del pennello", + "Deep Sky": "Cielo profondo", + "Defringe": "Elimina frange", + "Deghost Amount": "Quantità di rimozione fantasmi", + "Dehaze": "Elimina foschia", + "Delete": "Elimina", + "Delete (⌫)": "Elimina (⌫)", + "Delete Album": "Elimina album", + "Delete All Masks": "Elimina tutte le maschere", + "Delete Color Label Set": "Elimina set di etichette colore", + "Delete Component": "Elimina componente", + "Delete Export Preset": "Elimina preset di esportazione", + "Delete Filter Preset": "Elimina preset di filtro", + "Delete Imported Profile": "Elimina profilo importato", + "Delete Keyword": "Elimina parola chiave", + "Delete Keyword Set": "Elimina set di parole chiave", + "Delete Mask": "Elimina maschera", + "Delete Metadata Preset": "Elimina preset di metadati", + "Delete Permanently": "Elimina definitivamente", + "Delete Photo": "Elimina foto", + "Delete Photos": "Elimina foto", + "Delete Point Color": "Elimina colore puntuale", + "Delete Point Color Sample": "Elimina campione colore puntuale", + "Delete Point Curve Preset": "Elimina preset di curva a punti", + "Delete Preset": "Elimina preset", + "Delete Red Eye": "Elimina occhi rossi", + "Delete Red Eye Correction": "Elimina correzione occhi rossi", + "Delete Spot": "Elimina macchia", + "Delete Version": "Elimina versione", + "Delete all": "Elimina tutto", + "Delete all spots": "Elimina tutte le macchie", + "Delete mask": "Elimina maschera", + "Delete them": "Eliminale", + "Density": "Densità", + "Depth Range": "Intervallo di profondità", + "Descending": "Decrescente", + "Describe": "Descrivi", + "Describe what to keep": "Descrivi cosa tenere", + "Describe what to leave out": "Descrivi cosa escludere", + "Describe what to select": "Descrivi cosa selezionare", + "Describe…": "Descrivi…", + "Deselect": "Deseleziona", + "Deselect All": "Deseleziona tutto", + "Desert Heat": "Calore del deserto", + "Desert Warmth": "Tepore del deserto", + "Desktop": "Scrivania", + "Detail": "Dettaglio", + "Detail (D)": "Dettaglio (D)", + "Details": "Dettagli", + "Diagonal": "Diagonale", + "Discard Preview Cache": "Elimina cache delle anteprime", + "Display P3": "Display P3", + "Display name": "Nome visualizzato", + "Display theme": "Tema di visualizzazione", + "Distortion": "Distorsione", + "Document properties…": "Proprietà documento…", + "Don't enlarge": "Non ingrandire", + "Done": "Fine", + "Download": "Scarica", + "Download AI Mask Model": "Scarica il modello IA per le maschere", + "Download the SAM 3 Model?": "Scaricare il modello SAM 3?", + "Downloads": "Download", + "Drag along the horizon; double-click for Auto": "Trascina lungo l'orizzonte; doppio clic per Automatico", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Trascina sopra un occhio nella foto; la pupilla all'interno viene trovata da sola.", + "Draw Guides": "Disegna guide", + "Duplicate": "Duplica", + "Duplicate Component": "Duplica componente", + "Duplicate Mask": "Duplica maschera", + "Duplicate and Invert Mask": "Duplica e inverti maschera", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Ogni / apre un livello di cartella; i tag vengono compilati per ogni foto (un livello senza metadati diventa \"sconosciuto\").", + "Edge": "Bordo", + "Edit": "Modifica", + "Edit (E)": "Modifica (E)", + "Edit Capture Time": "Modifica ora di scatto", + "Edit Capture Time…": "Modifica ora di scatto…", + "Edit Color Label Names": "Modifica nomi delle etichette colore", + "Edit Color Label Names…": "Modifica nomi delle etichette colore…", + "Edit Copy for External Editor": "Modifica copia nell'editor esterno", + "Edit Date": "Modifica data", + "Edit Info": "Modifica informazioni", + "Edit Label Names": "Modifica nomi delle etichette", + "Edit Label Names…": "Modifica nomi delle etichette…", + "Edit Mask": "Modifica maschera", + "Edit Mask Component": "Modifica componente della maschera", + "Edit Sections": "Modifica sezioni", + "Edit Smart Album": "Modifica album smart", + "Edit Smart Album…": "Modifica album smart…", + "Edit Spot": "Modifica macchia", + "Edit in External Editor": "Modifica nell'editor esterno", + "Edit settings copied": "Impostazioni di modifica copiate", + "Edited": "Modificata", + "Edited or not": "Modificata o no", + "Editing": "Modifica", + "Effect": "Effetto", + "Effects": "Effetti", + "Empty Recently Deleted": "Svuota Eliminate di recente", + "Enable Lens Corrections": "Attiva correzioni obiettivo", + "Enable Profile Corrections": "Attiva correzioni del profilo", + "End Interaction": "Termina interazione", + "English": "Inglese", + "Enter Full Screen": "Schermo intero", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Inserisci una data come AAAA-MM-GG HH:MM:SS", + "Erase": "Cancella", + "Every EXIF, GPS and XMP field in the file": "Ogni campo EXIF, GPS e XMP del file", + "Existing previews": "Anteprime esistenti", + "Exit": "Esci", + "Expand": "Espandi", + "Expand All Stacks": "Espandi tutte le pile", + "Expand Stack": "Espandi pila", + "Expand/Collapse Stack": "Espandi/comprimi pila", + "Export": "Esporta", + "Export Album with Preset": "Esporta album con preset", + "Export Album…": "Esporta album…", + "Export As…": "Esporta come…", + "Export Group…": "Esporta gruppo…", + "Export Now": "Esporta ora", + "Export Point Curve Presets": "Esporta preset di curva a punti", + "Export Point Curve Presets…": "Esporta preset di curva a punti…", + "Export Presets": "Esporta predefiniti", + "Export Presets…": "Esporta preset…", + "Export User Presets…": "Esporta preset utente…", + "Export stopped unexpectedly": "L'esportazione si è interrotta in modo imprevisto", + "Export with Preset": "Esporta con preset", + "Export with Previous": "Esporta con le impostazioni precedenti", + "Exporting": "Esportazione in corso", + "Export…": "Esporta…", + "Exposure": "Tempo di esposizione", + "Exposure (shutter · aperture · ISO)": "Esposizione (tempo · diaframma · ISO)", + "Extended Description": "Descrizione estesa", + "External Editor": "Editor esterno", + "Face Boxes": "Riquadri dei volti", + "Face boxes": "Riquadri dei volti", + "Face names written to XMP by Lightroom and other apps show up here.": "Qui compaiono i nomi dei volti scritti nell'XMP da Lightroom e da altre app.", + "Faded Matte": "Opaco sbiadito", + "Faded Slide": "Diapositiva sbiadita", + "Faded Urban": "Città sbiadita", + "Favorite Preset": "Preset preferito", + "Favorite Profile": "Profilo preferito", + "Favorites": "Preferiti", + "Feather": "Sfumatura", + "File": "File", + "File & date": "File e data", + "File Format": "Formato file", + "File Name": "Nome file", + "File Path": "Percorso del file", + "File Size": "Dimensione del file", + "File Type": "Tipo di file", + "File name": "Nome file", + "File types:": "Tipi di file:", + "Filename": "Nome file", + "Fill": "Riempi", + "Fill Edges": "Riempi i bordi", + "Film": "Pellicola", + "Filmstrip": "Striscia", + "Filmstrip (/)": "Striscia (/)", + "Filter": "Filtro", + "Filter Bar": "Barra dei filtri", + "Filter Presets": "Preset di filtro", + "Filter bar": "Barra dei filtri", + "Filter fields": "Campi del filtro", + "Find Dust Spots": "Trova macchie di polvere", + "Find Missing Photos": "Trova foto mancanti", + "Find Missing Photos…": "Trova foto mancanti…", + "Find Similar Photos": "Trova foto simili", + "Find tools and commands…": "Trova strumenti e comandi…", + "Find…": "Trova…", + "First": "Primo", + "First commit": "Primo commit", + "Fit": "Adatta", + "Fit (F)": "Adatta (F)", + "Fit on Screen": "Adatta allo schermo", + "Fit to width": "Adatta alla larghezza", + "Fit visible": "Adatta all'area visibile", + "Five stars": "Cinque stelle", + "Fixed keys": "Tasti fissi", + "Flag": "Bandiera", + "Flag as Pick": "Contrassegna come scelta", + "Flag as Reject": "Contrassegna come scartata", + "Flagged as Pick": "Contrassegnata come scelta", + "Flagged as Reject": "Contrassegnata come scartata", + "Flash": "Flash", + "Flip H": "Capovolgi O", + "Flip Horizontal": "Capovolgi orizzontalmente", + "Flip Overlay (⇧O)": "Capovolgi sovrapposizione (⇧O)", + "Flip V": "Capovolgi V", + "Flip Vertical": "Capovolgi verticalmente", + "Float panel": "Rendi pannello flottante", + "Flow": "Flusso", + "Fluorescent": "Fluorescente", + "Focal Length": "Lunghezza focale", + "Focal length": "Lunghezza focale", + "Focus (assisted culling)": "Messa a fuoco (selezione assistita)", + "Folder": "Cartella", + "Folder name (renamed on disk; its photos follow)": "Nome della cartella (rinominata sul disco; le sue foto la seguono)", + "Folders": "Cartelle", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Le cartelle e {date} usano l'ora di scatto; una foto che non ce l'ha usa la data di oggi.", + "Food": "Cibo", + "Forget Unchanged Local Photos": "Dimentica le foto locali non modificate", + "Forget unchanged photos": "Dimentica le foto non modificate", + "Form fields panel": "Pannello campi modulo", + "Format": "Formato", + "Forward": "Avanti", + "Frame time": "Tempo per fotogramma", + "Frame update": "Aggiornamento del fotogramma", + "Free": "Libero", + "Fresh & Bright": "Fresco e luminoso", + "Friday": "Venerdì", + "Full": "Completo", + "Full Screen Preview": "Anteprima a schermo intero", + "Lights Out": "Luci spente", + "Full screen mode": "Modalità schermo intero", + "Full size": "Dimensione piena", + "GPS Track Log": "Tracciato GPS", + "GPU Rendering": "Rendering con GPU", + "GPU rendering": "Rendering con GPU", + "General": "Generale", + "Generated demo photo": "Foto dimostrativa generata", + "Geometry": "Geometria", + "Get Develop Settings": "Ottieni impostazioni di sviluppo", + "Global Hue": "Tonalità globale", + "Global Luminance": "Luminanza globale", + "Global Saturation": "Saturazione globale", + "Glossy": "Lucido", + "Go to History Step": "Vai al passaggio della cronologia", + "Golden": "Dorato", + "Golden Glow": "Bagliore dorato", + "Golden Hour": "Ora d'oro", + "Grab bag": "Varie", + "Grain": "Grana", + "Graphic": "Grafica", + "Green": "Verde", + "Green Amount": "Quantità verde", + "Green Hue": "Tonalità verde", + "Green Hue High": "Tonalità verde alta", + "Green Hue Low": "Tonalità verde bassa", + "Green Luminance": "Luminanza verde", + "Green Primary": "Primario verde", + "Green Saturation": "Saturazione verde", + "Green channel": "Canale verde", + "Green label": "Etichetta verde", + "Grid": "Griglia", + "Grid Info": "Informazioni della griglia", + "Gritty Street": "Strada grintosa", + "Group": "Gruppo", + "Group by Date: Automatic": "Raggruppa per data: automatico", + "Group by Day": "Raggruppa per giorno", + "Group by Month": "Raggruppa per mese", + "Group by Year": "Raggruppa per anno", + "Group by date": "Raggruppa per data", + "Group into Stack": "Raggruppa in una pila", + "Group name": "Nome gruppo", + "Guided": "Guidato", + "Guided Upright": "Raddrizzamento guidato", + "Guided Upright Guides": "Guide del raddrizzamento guidato", + "HDR Merge": "Unione HDR", + "HDR Merge Preview": "Anteprima unione HDR", + "HDR Panorama Merge": "Unione panorama HDR", + "HDR Panorama Merge Preview": "Anteprima unione panorama HDR", + "HDR Panorama with Last Settings": "Panorama HDR con le ultime impostazioni", + "HDR Panoramas": "Panorami HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panorami HDR", + "HDR with Last Settings": "HDR con le ultime impostazioni", + "HDR…": "HDR…", + "Has Edits": "Con modifiche", + "Has GPS": "Con GPS", + "Heal": "Correggi", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Correggi le piccole macchie scure e sfumate che la polvere del sensore lascia sulle zone uniformi", + "Height": "Altezza", + "Height (px)": "Altezza (px)", + "Help": "Aiuto", + "Hide LightCraft": "Nascondi LightCraft", + "Hide Mask": "Nascondi maschera", + "Hide Others": "Nascondi altri", + "Hides this shortcut only; the folder and its photos stay as they are": "Nasconde solo questa scorciatoia; la cartella e le sue foto restano come sono", + "High": "Alta", + "High Contrast B&W": "B/N ad alto contrasto", + "Highlight": "Luci", + "Highlights": "Luci", + "Highlights Hue": "Tonalità delle luci", + "Highlights Luminance": "Luminanza delle luci", + "Highlights Saturation": "Saturazione delle luci", + "Highlights split": "Divisione delle luci", + "Histogram": "Istogramma", + "History": "Cronologia", + "History & Activity (Y)": "Cronologia e attività (Y)", + "Home": "Cartella personale", + "Horizontal": "Orizzontale", + "Horizontal inset (% of short edge)": "Margine orizzontale (% del lato corto)", + "Horizontal text": "Testo orizzontale", + "Hours": "Ore", + "How to install the model": "Come installare il modello", + "Hue": "Tonalità", + "Hue Range": "Intervallo di tonalità", + "ISO Speed": "Sensibilità ISO", + "ISO speed": "Sensibilità ISO", + "If file exists": "Se il file esiste", + "Image": "Immagine", + "Import": "Importa", + "Import Date": "Data di importazione", + "Import Lightroom Catalog": "Importa catalogo Lightroom", + "Import Lightroom Catalog…": "Importa catalogo Lightroom…", + "Import Photos": "Importa foto", + "Import Photos…": "Importa foto…", + "Import Point Curve Presets": "Importa preset di curva a punti", + "Import Point Curve Presets…": "Importa preset di curva a punti…", + "Import Presets": "Importa predefiniti", + "Import Presets…": "Importa preset…", + "Import Profiles": "Importa profili", + "Import Profiles & Presets…": "Importa profili e preset…", + "Import as new": "Importa come nuova", + "Import from Device": "Importa da dispositivo", + "Import from Folder…": "Importa da cartella…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importa le foto con File → Importa foto… (Cmd+Maiusc+I), oppure trascinale qui", + "Imported": "Importato", + "Imported Photos": "Foto importate", + "Importing": "Importazione in corso", + "Importing Lightroom Catalog": "Importazione del catalogo Lightroom", + "Importing scans the source for photos; it doesn't add the folder to Local (use Local → Browse Folder… to work in a folder without importing).": "L'importazione cerca le foto nell'origine; non aggiunge la cartella a Locale (usa Locale → Sfoglia cartella… per lavorare in una cartella senza importare).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "In Griglia foto e Griglia quadrata: contrassegna come scelta e avanza", + "In Photo Grid and Square Grid: expand or collapse the stack": "In Griglia foto e Griglia quadrata: espandi o comprimi la pila", + "In Recently Deleted": "In Eliminate di recente", + "In library": "Nella libreria", + "In memory — nothing is saved": "In memoria: non viene salvato niente", + "In use": "In uso", + "In-memory session: these settings last until LightCraft quits.": "Sessione in memoria: queste impostazioni durano finché LightCraft resta aperto.", + "Include subfolders": "Includi sottocartelle", + "Increase Brush Feather": "Aumenta sfumatura del pennello", + "Increase Brush Size": "Aumenta dimensione del pennello", + "Info": "Informazioni", + "Info (I)": "Informazioni (I)", + "Info Overlay Off": "Sovrapposizione informazioni disattivata", + "Info Overlay: Exposure": "Sovrapposizione informazioni: esposizione", + "Info Overlay: File": "Sovrapposizione informazioni: file", + "Info overlay": "Sovrapposizione informazioni", + "Insert at the cursor": "Inserisci al cursore", + "Inspect Lightroom Catalog": "Esamina catalogo Lightroom", + "Inspect Photo": "Esamina foto", + "Inspecting Lightroom Catalog": "Analisi del catalogo Lightroom", + "Install the SAM 3 Model": "Installa il modello SAM 3", + "Instant ’70s": "Istantanea anni '70", + "Interface": "Interfaccia", + "Interface language": "Lingua dell'interfaccia", + "Intersect": "Intersezione", + "Intersect with": "Interseca con", + "Into one folder": "In un'unica cartella", + "Invert": "Inverti", + "Invert Component": "Inverti componente", + "Invert Mask": "Inverti maschera", + "JPEG (Large)": "JPEG (grande)", + "JPEG (Small)": "JPEG (piccolo)", + "Join the ArtCraft Discord": "Unisciti al Discord di ArtCraft", + "Join the ArtCraft Discord…": "Unisciti al Discord di ArtCraft…", + "Join the ArtCraft community on Discord": "Unisciti alla community di ArtCraft su Discord", + "Keep in Local": "Tieni in Locale", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Tienila su un disco con spazio libero: le anteprime avanzate occupano circa 1 MB per foto. La cache delle miniature resta nella libreria.", + "Keyboard Shortcuts": "Scorciatoie da tastiera", + "Keyboard shortcuts": "Scorciatoie da tastiera", + "Keyword": "Parola chiave", + "Keyword Painter": "Pennello parole chiave", + "Keyword Set": "Set di parole chiave", + "Keyword Sets": "Set di parole chiave", + "Keyword Suggestions": "Suggerimenti di parole chiave", + "Keyword to paint": "Parola chiave da applicare", + "Keywords": "Parole chiave", + "Keywords (K)": "Parole chiave (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Le parole chiave che aggiungi compaiono qui; ⌥1-⌥9 le applicano.", + "Label": "Etichetta", + "Landscape": "Paesaggio", + "Language": "Lingua", + "Last": "Ultimo", + "Last GPU fallback": "Ultimo ripiego dalla GPU", + "Last commit": "Ultimo commit", + "Last loupe render": "Ultimo rendering della lente", + "Last view": "Ultima vista", + "Lateral Chromatic Aberration": "Aberrazione cromatica laterale", + "Layer": "Livello", + "Layers": "Livelli", + "Leave them": "Lasciale", + "Lens": "Obiettivo", + "Lens Blur": "Sfocatura obiettivo", + "Lens Corrections": "Correzioni obiettivo", + "Level": "Livello", + "Library": "Libreria", + "Library Info": "Informazioni sulla libreria", + "Library Originals": "Originali della libreria", + "Library Preferences": "Preferenze della libreria", + "Library State": "Stato della libreria", + "Library saved": "Libreria salvata", + "Licence:": "Licenza:", + "Light": "Luce", + "LightCraft Default": "Predefinito di LightCraft", + "LightCraft Help": "Aiuto di LightCraft", + "LightCraft Preset": "Preset di LightCraft", + "LightCraft Presets": "Preset di LightCraft", + "LightCraft Website": "Sito web di LightCraft", + "LightCraft could not start": "Impossibile avviare LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft non ha trovato dove salvare la tua libreria.", + "LightCraft couldn't open the library at": "LightCraft non è riuscito ad aprire la libreria in", + "LightCraft on GitHub": "LightCraft su GitHub", + "LightCraft website": "Sito web di LightCraft", + "LightCraft — Second Window": "LightCraft: seconda finestra", + "Lightroom Classic Catalog": "Catalogo di Lightroom Classic", + "Lightroom import cancelled": "Importazione da Lightroom annullata", + "Lightroom inspection cancelled": "Analisi di Lightroom annullata", + "Lights": "Toni chiari", + "Limit file size (KB, 0 = off)": "Limita dimensione del file (KB, 0 = nessun limite)", + "Line delta": "Differenza righe", + "Linear": "Lineare", + "Linear Gradient": "Sfumatura lineare", + "Lines +/−": "Righe +/−", + "Lines added": "Righe aggiunte", + "Lines deleted": "Righe eliminate", + "List Albums": "Elenca album", + "List Develop Controls": "Elenca controlli di sviluppo", + "List History": "Elenca cronologia", + "List Presets": "Elenca preset", + "List Profiles": "Elenca profili", + "List this folder in Local from now on": "Mostra d'ora in poi questa cartella in Locale", + "Loading preview…": "Caricamento anteprima…", + "Local": "Locale", + "Local folders": "Cartelle locali", + "Local library — no cloud account needed": "Libreria locale: non serve un account cloud", + "Locate Missing File…": "Trova file mancante…", + "Locate Photo": "Trova foto", + "Location": "Posizione", + "Lock": "Blocca", + "Lock Crop Aspect": "Blocca proporzioni del ritaglio", + "Locked": "Bloccato", + "Long Edge": "Lato lungo", + "Looking for photos…": "Ricerca delle foto…", + "Lossless": "Senza perdita", + "Low": "Bassa", + "Luminance": "Luminanza", + "Luminance Range": "Intervallo di luminanza", + "Lush Greens": "Verdi rigogliosi", + "Magenta": "Magenta", + "Magenta Hue": "Tonalità magenta", + "Magenta Luminance": "Luminanza magenta", + "Magenta Saturation": "Saturazione magenta", + "Make Candidate the Select": "Rendi la candidata quella scelta", + "Manual": "Manuale", + "Marquee zoom": "Ingrandimento selettivo", + "Mask Adjustment": "Regolazione della maschera", + "Masking": "Mascheratura", + "Masking (M)": "Maschere (M)", + "Masks": "Maschere", + "Match": "Corrispondenza", + "Match Total Exposures": "Uniforma le esposizioni totali", + "Matte": "Opaca", + "Matte Soft": "Opaco morbido", + "Med": "Medio", + "Medium Contrast": "Contrasto medio", + "Megapixels": "Megapixel", + "Memory Budget": "Memoria disponibile", + "Memory Usage": "Uso della memoria", + "Memory budget": "Memoria disponibile", + "Memory for caches": "Memoria per le cache", + "Menu": "Menu", + "Merge": "Unisci", + "Merge Keywords": "Unisci parole chiave", + "Merge into…": "Unisci in…", + "Merged": "Unita", + "Merged PRs": "PR unite", + "Merging…": "Unione in corso…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Sono condizioni di Meta, non di LightCraft. Scaricandolo le accetti.", + "Metadata": "Metadati", + "Metadata Preset": "Preset di metadati", + "Metadata Presets": "Preset di metadati", + "Metadata preset": "Preset di metadati", + "Midpoint": "Punto medio", + "Midtones": "Mezzi toni", + "Midtones Hue": "Tonalità dei mezzitoni", + "Midtones Luminance": "Luminanza dei mezzitoni", + "Midtones Saturation": "Saturazione dei mezzitoni", + "Midtones split": "Divisione dei mezzitoni", + "Minimize": "Riduci a icona", + "Missing Photos": "Foto mancanti", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "I metadati mancanti ({camera}, {title}…) lasciano uno spazio vuoto; un nome che risulta vuoto mantiene il nome originale.", + "Misty Morning": "Mattino nebbioso", + "Mode": "Metodo", + "Model": "Modello", + "Models": "Modelli", + "Modified Date": "Data di modifica", + "Monday": "Lunedì", + "Mono Red Filter": "Mono con filtro rosso", + "Mono Rich": "Mono ricco", + "Mono Soft": "Mono morbido", + "Monochrome": "Monocromia", + "Month": "Mese", + "Moody": "Cupo", + "Moody Portrait": "Ritratto cupo", + "More": "Altro", + "More preset options": "Altre opzioni del preset", + "Move": "Sposta", + "Move Album": "Sposta album", + "Move Down": "Sposta giù", + "Move Down in Stack": "Sposta giù nella pila", + "Move Folder": "Sposta cartella", + "Move Folder To…": "Sposta cartella in…", + "Move Mask": "Sposta maschera", + "Move Preset to Group": "Sposta preset nel gruppo", + "Move Preset to New Group": "Sposta preset in un nuovo gruppo", + "Move Up": "Sposta su", + "Move Up in Stack": "Sposta su nella pila", + "Move in Stack": "Sposta nella pila", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Sposta i file (negli Originals/ della libreria, o in una cartella a tua scelta), togliendoli dall'origine", + "Move them": "Spostale", + "Move to": "Sposta in", + "Move to Group": "Sposta nel gruppo", + "Move to group": "Sposta nel gruppo", + "Move: the files are moved to the folder below; each original (and its XMP sidecar) is removed from the source only after its copy is verified. Duplicates and files that fail stay where they are.": "Sposta: i file vengono spostati nella cartella qui sotto; ogni originale (con il suo XMP collaterale) viene tolto dall'origine solo dopo aver verificato la copia. I duplicati e i file con errori restano dove sono.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Sposta in Eliminate di recente ogni foto importata da questo disco; nessun file viene toccato", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Sposta in Eliminate di recente le foto importate da questa cartella; nessun file viene toccato", + "Muted": "Attenuato", + "Muted Film": "Pellicola attenuata", + "My Photos": "Le mie foto", + "My Photos Panel": "Pannello Le mie foto", + "Name": "Nome", + "Name (A–Z)": "Nome (A-Z)", + "Name of the folder the original is in": "Nome della cartella in cui si trova l'originale", + "Named": "Con nome", + "Named People": "Persone con nome", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "I nomi compaiono nel menu delle etichette, nella barra dei filtri e nel pannello Informazioni, e vengono scritti nell'XMP. Vuoto = il nome del colore.", + "Navigator": "Navigatore", + "Needs Work": "Da rivedere", + "Neon Dusk": "Tramonto al neon", + "Neon Night": "Notte al neon", + "Nested group": "Gruppo annidato", + "Neutral": "Neutro", + "Never": "Mai", + "New": "Nuovo", + "New Album": "Nuovo album", + "New Album…": "Nuovo album…", + "New Folder…": "Nuova cartella…", + "New Group…": "Nuovo gruppo…", + "New Smart Album": "Nuovo album smart", + "New Smart Album from Filter…": "Nuovo album smart dal filtro…", + "New Smart Album…": "Nuovo album smart…", + "New album": "Nuovo album", + "New album…": "Nuovo album…", + "New blank PDF": "Nuovo PDF vuoto", + "New name": "Nuovo nome", + "New time": "Nuova ora", + "New…": "Nuovo…", + "Next Photo": "Foto successiva", + "Night Blue": "Blu notte", + "No": "No", + "No Camera or Card Found": "Nessuna fotocamera o scheda trovata", + "No Date Groups": "Nessun raggruppamento per data", + "No Stars": "Nessuna stella", + "No Tool": "Nessuno strumento", + "No automatic versions.": "Nessuna versione automatica.", + "No camera": "Nessuna fotocamera", + "No contributor data was built into this copy.": "Questa copia non include dati sui collaboratori.", + "No date": "Nessuna data", + "No dust spots found": "Nessuna macchia di polvere trovata", + "No edits yet.": "Ancora nessuna modifica.", + "No exposure information": "Nessuna informazione di esposizione", + "No filter presets yet": "Ancora nessun preset di filtro", + "No lens data embedded in this file (DNG lens opcodes only).": "In questo file non ci sono dati dell'obiettivo (solo opcode obiettivo DNG).", + "No masks yet. Choose a mask type above.": "Ancora nessuna maschera. Scegli un tipo di maschera qui sopra.", + "No matching commands": "Nessun comando corrispondente", + "No matching photos": "Nessuna foto corrispondente", + "No metadata found": "Nessun metadato trovato", + "No model credits were built into this copy.": "Questa copia non include i crediti dei modelli.", + "No named people in these photos": "In queste foto non ci sono persone con nome", + "No named people yet": "Ancora nessuna persona con nome", + "No new presets": "Nessun nuovo preset", + "No photo selected": "Nessuna foto selezionata", + "No photos": "Nessuna foto", + "No photos found": "Nessuna foto trovata", + "No photos in this folder": "Nessuna foto in questa cartella", + "No photos match the active filters": "Nessuna foto corrisponde ai filtri attivi", + "No photos match the filters (View → Clear Filters)": "Nessuna foto corrisponde ai filtri (Visualizza → Cancella filtri)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Ancora nessuna foto raw: le fotocamere compaiono qui quando le loro foto sono nella libreria.", + "No reference photo": "Nessuna foto di riferimento", + "No usable GPU found: rendering on the CPU": "Nessuna GPU utilizzabile: rendering sulla CPU", + "No versions yet. Create one to keep this look.": "Ancora nessuna versione. Creane una per conservare questo aspetto.", + "Noise": "Disturbo", + "Noise Reduction": "Riduzione disturbo", + "Non-raw photos": "Foto non raw", + "None": "Nessuno", + "Not Now": "Non ora", + "Nothing to compare": "Niente da confrontare", + "Nothing to survey": "Niente da esaminare", + "Nudge Develop Value": "Sposta di poco un valore di sviluppo", + "OK": "OK", + "Object": "Oggetto", + "Object Mask": "Maschera oggetto", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Le maschere Oggetto e Descrivi usano SAM 3, il modello di segmentazione di Meta. Non fa parte di LightCraft, e tutto il resto funziona anche senza.", + "October 2026": "Ottobre 2026", + "Off": "Disattivato", + "Offset X": "Spostamento X", + "Offset Y": "Spostamento Y", + "Opacity": "Opacità", + "Opacity (%)": "Opacità (%)", + "Open Library": "Apri libreria", + "Open Library…": "Apri libreria…", + "Open in": "Apri in", + "Open in Detail": "Apri in Dettaglio", + "Open the place in OpenStreetMap": "Apri il luogo in OpenStreetMap", + "Open…": "Apri…", + "Optics": "Ottica", + "Optimize Library": "Ottimizza libreria", + "Optional name": "Nome facoltativo", + "Options": "Opzioni", + "Orange": "Arancione", + "Orange Hue": "Tonalità arancione", + "Orange Luminance": "Luminanza arancione", + "Orange Saturation": "Saturazione arancione", + "Orientation": "Orientamento", + "Original": "Originale", + "Original + Settings": "Originale + impostazioni", + "Original extension without the dot": "Estensione originale senza il punto", + "Original file name without its extension": "Nome del file originale senza estensione", + "Original files": "File originali", + "Original offline · editing the smart preview": "Originale non disponibile · si modifica l'anteprima avanzata", + "Original offline · no smart preview": "Originale non disponibile · nessuna anteprima avanzata", + "Originals": "Originali", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Altre immagini (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Sovrapponi", + "Overwrite": "Sovrascrivi", + "Pages": "Pagine", + "Paint": "Dipingi", + "Paint only areas like the one under the brush": "Dipingi solo le zone simili a quella sotto il pennello", + "Paint over a distraction on the photo to remove it.": "Dipingi sopra un elemento di disturbo nella foto per rimuoverlo.", + "Paint overlay": "Sovrapposizione della pittura", + "Panels": "Pannelli", + "Panorama": "Panorama", + "Panorama Merge": "Unione panorama", + "Panorama Merge Preview": "Anteprima unione panorama", + "Panorama with Last Settings": "Panorama con le ultime impostazioni", + "Panoramas": "Panorami", + "Panorama…": "Panorama…", + "Parametric curve": "Curva parametrica", + "Paste": "Incolla", + "Paste Edit Settings": "Incolla impostazioni di modifica", + "Paste Metadata": "Incolla metadati", + "Paste Selected Settings": "Incolla impostazioni selezionate", + "Paste Selected Settings…": "Incolla impostazioni selezionate…", + "Paste Settings": "Incolla impostazioni", + "Paste Settings from Previous": "Incolla impostazioni dalla precedente", + "Paste from Previous": "Incolla dalla precedente", + "Paste pages": "Incolla pagine", + "Pastel Haze": "Foschia pastello", + "People": "Persone", + "Percent": "Percentuale", + "Percentage": "Percentuale", + "Performance": "Prestazioni", + "Person": "Persona", + "Perspective": "Prospettiva", + "Pet Eye": "Occhi degli animali", + "Pet Eye Catchlight": "Riflesso negli occhi degli animali", + "Photo": "Foto", + "Photo Grid": "Griglia foto", + "Photo Grid (G)": "Griglia foto (G)", + "Photo Merge": "Unione di foto", + "Photo ▸ Edit in External Editor (⇧⌘E) renders a 16-bit TIFF copy, stacks it with the original and opens it here (an app name on macOS, a program path elsewhere).": "Foto → Modifica nell'editor esterno (⇧⌘E) genera una copia TIFF a 16 bit, la mette in pila con l'originale e la apre qui (il nome di un'app su macOS, il percorso di un programma altrove).", + "Photos": "Foto", + "Photos per bracket (0 = auto)": "Foto per bracketing (0 = automatico)", + "Photos seen in Local but never added or changed leave the catalog when their folder hasn't been browsed for this long (checked when the library opens). Files stay on disk; browsing the folder shows them again.": "Le foto viste in Locale ma mai aggiunte o modificate escono dal catalogo quando la loro cartella non viene sfogliata da questo tempo (controllo all'apertura della libreria). I file restano sul disco; sfogliando la cartella ricompaiono.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Le foto che arrivano in questa cartella (scatto collegato, scanner, app di sincronizzazione) vengono aggiunte appena sono complete.", + "Pick": "Scelta", + "Pick Flag": "Contrassegno scelta", + "Pick a colour on the photo with the eyedropper.": "Scegli un colore sulla foto con il contagocce.", + "Pick the sharpest photo of each burst": "Scegli la foto più nitida di ogni raffica", + "Picked": "Scelta", + "Picks": "Scelte", + "Pictures": "Immagini", + "Pixels": "Pixel", + "Point Color": "Colore puntuale", + "Point Curve": "Curva a punti", + "Point Curve Presets": "Preset di curva a punti", + "Point curve": "Curva a punti", + "Portrait": "Ritratto", + "Position": "Posizione", + "Preferences": "Preferenze", + "Preferences…": "Preferenze…", + "Prepare AI Masks": "Prepara le maschere IA", + "Preset": "Predefinito", + "Preset name": "Nome del preset", + "Presets": "Predefiniti", + "Presets & Profiles": "Preset e profili", + "Presets (Shift+P)": "Preset (Maiusc+P)", + "Presets and profiles": "Preset e profili", + "Presets ▾": "Preset ⌄", + "Press keys…": "Premi tasti…", + "Preview": "Anteprima", + "Preview Build Progress": "Avanzamento della generazione anteprime", + "Preview only": "Solo anteprima", + "Preview size": "Dimensione dell'anteprima", + "Preview unavailable": "Anteprima non disponibile", + "Previews": "Anteprime", + "Previous Photo": "Foto precedente", + "Print": "Stampa", + "Print…": "Stampa…", + "Profile": "Profilo", + "Profile Browser": "Browser dei profili", + "Profile Distortion": "Distorsione del profilo", + "Profile Menu": "Menu dei profili", + "Profile Vignetting": "Vignettatura del profilo", + "Profile:": "Profilo:", + "Profiles": "Profili", + "Projection": "Proiezione", + "Proof copy created": "Copia di prova creata", + "Properties": "Proprietà", + "Public domain": "Pubblico dominio", + "Pupil Size": "Dimensione pupilla", + "Purple": "Viola", + "Purple Amount": "Quantità viola", + "Purple Hue": "Tonalità viola", + "Purple Hue High": "Tonalità viola alta", + "Purple Hue Low": "Tonalità viola bassa", + "Purple Luminance": "Luminanza viola", + "Purple Saturation": "Saturazione viola", + "Put the locations you removed from Local back (no files change)": "Rimetti in Locale le posizioni che avevi tolto (nessun file cambia)", + "Quality": "Qualità", + "Query Photos": "Interroga le foto", + "Quick Collection": "Raccolta rapida", + "Quick Develop": "Sviluppo rapido", + "Quiet Green": "Verde quieto", + "Quit": "Esci", + "Quit Anyway": "Esci comunque", + "Quit LightCraft": "Esci da LightCraft", + "Quit with unsaved changes?": "Uscire con modifiche non salvate?", + "RAW decoding": "Decodifica RAW", + "Radial": "Radiale", + "Radial Gradient": "Sfumatura radiale", + "Radius": "Raggio", + "Random": "Casuale", + "Range": "Intervallo", + "Rate ★": "Valuta ★", + "Rate ★ and advance": "Valuta ★ e avanza", + "Rate ★★": "Valuta ★★", + "Rate ★★ and advance": "Valuta ★★ e avanza", + "Rate ★★★": "Valuta ★★★", + "Rate ★★★ and advance": "Valuta ★★★ e avanza", + "Rate ★★★★": "Valuta ★★★★", + "Rate ★★★★ and advance": "Valuta ★★★★ e avanza", + "Rate ★★★★★": "Valuta ★★★★★", + "Rate ★★★★★ and advance": "Valuta ★★★★★ e avanza", + "Rating": "Valutazione", + "Rating cleared": "Valutazione tolta", + "Ratings & flags": "Valutazioni e contrassegni", + "Raw": "Raw", + "Raw default": "Predefinito raw", + "Raw defaults": "Predefiniti raw", + "Raw files": "File raw", + "Raw photos": "Foto raw", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Le foto raw come DNG, con le modifiche incorporate. Le opzioni di dimensione, colore e uscita non si applicano.", + "Read Metadata from File": "Leggi i metadati dal file", + "Read mode": "Modalità lettura", + "Read the SAM License": "Leggi la licenza SAM", + "Reading": "Lettura", + "Reading Lightroom catalog…": "Lettura del catalogo Lightroom…", + "Reading folder…": "Lettura della cartella…", + "Real name": "Nome reale", + "Recent": "Recenti", + "Recent Keywords": "Parole chiave recenti", + "Recently Added": "Aggiunte di recente", + "Recently Deleted": "Eliminate di recente", + "Red": "Rosso", + "Red Eye": "Occhi rossi", + "Red Hue": "Tonalità rosso", + "Red Luminance": "Luminanza rosso", + "Red Primary": "Primario rosso", + "Red Saturation": "Saturazione rosso", + "Red channel": "Canale rosso", + "Red label": "Etichetta rossa", + "Red/Cyan Fringe": "Frange rosse/ciano", + "Redo": "Ripeti", + "Reference": "Riferimento", + "Reference View": "Vista di riferimento", + "Reference the files where they are": "Usa i file dove si trovano", + "Refine": "Rifinisci", + "Refine AI Mask Detail": "Rifinisci il dettaglio della maschera IA", + "Refine Edges": "Rifinisci i bordi", + "Refine Mask Edges": "Rifinisci i bordi della maschera", + "Refine Saturation": "Rifinisci la saturazione", + "Refining the mask's detail…": "Rifinitura del dettaglio della maschera…", + "Refresh Source": "Aggiorna l'origine", + "Refresh Source (/)": "Aggiorna l'origine (/)", + "Reject": "Scarta", + "Reject below focus": "Scarta sotto la soglia di messa a fuoco", + "Rejected": "Rifiutato", + "Rejects": "Scartate", + "Reliability": "Affidabilità", + "Relink Photo": "Ricollega foto", + "Reload Changed Files": "Ricarica i file modificati", + "Reload from Disk": "Ricarica dal disco", + "Remove": "Rimuovi", + "Remove (H)": "Rimuovi (H)", + "Remove CA": "Rimuovi AC", + "Remove Chromatic Aberration": "Rimuovi aberrazione cromatica", + "Camera local tone mapping": "Mappatura tonale locale della fotocamera", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Applica la mappatura tonale locale salvata nel raw (Apple ProRAW), come la mostra la fotocamera", + "Remove Disk from Library": "Rimuovi disco dalla libreria", + "Remove Disk from Library…": "Rimuovi disco dalla libreria…", + "Remove Face Box": "Rimuovi riquadro del volto", + "Remove Folder from Library": "Rimuovi cartella dalla libreria", + "Remove a filter above, or choose Clear all": "Togli un filtro qui sopra, oppure scegli Cancella tutto", + "Remove from Album": "Rimuovi dall'album", + "Remove from Favorites": "Rimuovi dai preferiti", + "Remove from Library…": "Rimuovi dalla libreria…", + "Remove from Local": "Rimuovi da Locale", + "Remove from Photo": "Rimuovi dalla foto", + "Remove from Selected Photos": "Rimuovi dalle foto selezionate", + "Remove from Stack": "Rimuovi dalla pila", + "Remove from Target Album": "Rimuovi dall'album di destinazione", + "Remove from survey": "Rimuovi dalla panoramica", + "Remove location info": "Rimuovi le informazioni sulla posizione", + "Remove shortcut": "Rimuovi scorciatoia", + "Remove this face box (undo with Edit ▸ Undo)": "Rimuovi questo riquadro del volto (annulla con Modifica → Annulla)", + "Remove this filter": "Rimuovi questo filtro", + "Remove this group": "Rimuovi questo gruppo", + "Remove this rule": "Rimuovi questa regola", + "Rename": "Rinomina", + "Rename Album": "Rinomina album", + "Rename Component": "Rinomina componente", + "Rename Folder": "Rinomina cartella", + "Rename Folder…": "Rinomina cartella…", + "Rename Keyword": "Rinomina parola chiave", + "Rename Keyword…": "Rinomina parola chiave…", + "Rename Mask": "Rinomina maschera", + "Rename Photos": "Rinomina foto", + "Rename Photos…": "Rinomina foto…", + "Rename Preset": "Rinomina preset", + "Rename Preview": "Anteprima della rinomina", + "Rename Template Tags": "Tag del modello di rinomina", + "Rename Version": "Rinomina versione", + "Rename…": "Rinomina…", + "Rendering": "Rendering", + "Rendering…": "Rendering in corso…", + "Reorder Masks": "Riordina maschere", + "Reset": "Reimposta", + "Reset All": "Reimposta tutto", + "Reset All Curves": "Reimposta tutte le curve", + "Reset All Keyboard Shortcuts": "Reimposta tutte le scorciatoie da tastiera", + "Reset Before to Import State": "Riporta Prima allo stato di importazione", + "Reset Control": "Reimposta controllo", + "Reset Crop": "Reimposta ritaglio", + "Reset Edits": "Reimposta modifiche", + "Reset Panel Layout": "Reimposta disposizione dei pannelli", + "Reset Parametric Curve": "Reimposta curva parametrica", + "Reset Section": "Reimposta sezione", + "Reset Tone Curve": "Reimposta curva dei toni", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Reimposta tutte le curve: curve a punti (tutti i canali) e parametrica (doppio clic su un canale per reimpostare solo quello)", + "Reset all edits (Cmd+Shift+R)": "Reimposta tutte le modifiche (Cmd+Maiusc+R)", + "Reshuffle": "Rimescola", + "Resize Face Box": "Ridimensiona riquadro del volto", + "Resize panel window": "Ridimensiona la finestra del pannello", + "Resize panels": "Ridimensiona pannelli", + "Resize to": "Ridimensiona a", + "Resolution (ppi)": "Risoluzione (ppi)", + "Restore": "Ripristina", + "Restore (no shortcut)": "Ripristina (nessuna scorciatoia)", + "Restore Library from Backup…": "Ripristina la libreria da un backup…", + "Restore Version": "Ripristina versione", + "Restore keeps their edits; Import as new deletes the old record first.": "Ripristina mantiene le loro modifiche; Importa come nuova elimina prima il vecchio record.", + "Restore them": "Ripristinale", + "Retouch": "Ritocco", + "Reverse the order": "Inverti l'ordine", + "Revert": "Ripristina", + "Review": "Revisione", + "Review Import": "Rivedi importazione", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Clic destro su una foto → Imposta come foto di riferimento, poi Visualizza → Vista di riferimento (Maiusc+R)", + "Right-click a preset to delete it": "Clic destro su un preset per eliminarlo", + "Rights Usage Terms": "Condizioni d'uso dei diritti", + "Rotate": "Ruota", + "Rotate Crop": "Ruota il ritaglio", + "Rotate Crop Aspect": "Ruota le proporzioni del ritaglio", + "Rotate Left": "Ruota a sinistra", + "Rotate Right": "Ruota a destra", + "Roughness": "Rugosità", + "Roundness": "Rotondità", + "Rules": "Regole", + "SAM 3 download stopped: it resumes where it left off next time.": "Download di SAM 3 interrotto: la prossima volta riprende da dove si è fermato.", + "Same as raw default": "Uguale al predefinito raw", + "Sample Mask Color": "Campiona il colore della maschera", + "Sample a colour on the photo": "Campiona un colore sulla foto", + "Saturation": "Saturazione", + "Saturation Range": "Intervallo di saturazione", + "Saturday": "Sabato", + "Save": "Salva", + "Save As…": "Salva con nome…", + "Save Color Label Set": "Salva set di etichette colore", + "Save Current Filter as Preset…": "Salva il filtro corrente come preset…", + "Save Current Keywords as Set…": "Salva le parole chiave correnti come set…", + "Save Export Preset": "Salva preset di esportazione", + "Save Filter Preset": "Salva preset di filtro", + "Save Keyword Set": "Salva set di parole chiave", + "Save Metadata Preset": "Salva preset di metadati", + "Save Metadata Preset…": "Salva preset di metadati…", + "Save Metadata to File": "Salva i metadati nel file", + "Save Point Curve Preset": "Salva preset di curva a punti", + "Save Point Curve…": "Salva curva a punti…", + "Save as Preset": "Salva come preset", + "Save as Set…": "Salva come set…", + "Save as Smart Album…": "Salva come album smart…", + "Save as set": "Salva come set", + "Save as…": "Salva con nome…", + "Save preset": "Salva preset", + "Saving Lightroom import index…": "Salvataggio dell'indice di importazione di Lightroom…", + "Scale": "Scala", + "Scan failed": "Scansione non riuscita", + "Scanning": "Scansione in corso", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "I punteggi restano sulle foto: filtra o crea album smart con Messa a fuoco e La migliore tra scatti simili.", + "Screen": "Schermo", + "Scroll menu down": "Scorri il menu in giù", + "Scroll menu up": "Scorri il menu in su", + "Search": "Cerca", + "Search Photos": "Cerca foto", + "Search commands or keys": "Cerca comandi o tasti", + "Search…": "Cerca…", + "Seasons": "Stagioni", + "Second Window": "Seconda finestra", + "Select": "Seleziona", + "Select All": "Seleziona tutto", + "Select Luminance Range": "Seleziona intervallo di luminanza", + "Select Mask": "Seleziona maschera", + "Select Photos": "Seleziona foto", + "Select Spot": "Seleziona macchia", + "Select a photo to edit": "Seleziona una foto da modificare", + "Select by": "Seleziona per", + "Select by Flag, Rating or Label": "Seleziona per contrassegno, valutazione o etichetta", + "Select photos, then choose View → Survey (N)": "Seleziona le foto, poi scegli Visualizza → Panoramica (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Seleziona due foto, poi scegli Visualizza → Confronta (Maiusc+C)", + "Selected": "Selezionati", + "Selecting…": "Selezione in corso…", + "Selenium Tone": "Viraggio al selenio", + "Send Feedback…": "Invia un feedback…", + "Sepia Tone": "Viraggio seppia", + "Sequence number zero-padded to N digits (1–9)": "Numero progressivo con zeri iniziali fino a N cifre (1-9)", + "Sequence number, counting from the start number": "Numero progressivo, a partire dal numero iniziale", + "Services": "Servizi", + "Set": "Imposta", + "Set Album Cover": "Imposta copertina dell'album", + "Set Before": "Imposta Prima", + "Set Color Label": "Imposta etichetta colore", + "Set Crop": "Imposta ritaglio", + "Set Develop Values": "Imposta valori di sviluppo", + "Set Flag": "Imposta contrassegno", + "Set Image Zoom and Pan": "Imposta zoom e spostamento dell'immagine", + "Set Keyboard Shortcut": "Imposta scelta rapida da tastiera", + "Set Mask Adjustments": "Imposta regolazioni della maschera", + "Set Point Curve": "Imposta curva a punti", + "Set Profile": "Imposta profilo", + "Set Rating": "Imposta valutazione", + "Set Top of Stack": "Imposta la prima della pila", + "Set as Album Cover": "Imposta come copertina dell'album", + "Set as Before": "Imposta come Prima", + "Set as Reference Photo": "Imposta come foto di riferimento", + "Set as Target Album": "Imposta come album di destinazione", + "Set as Target Album (B adds to it)": "Imposta come album di destinazione (B aggiunge)", + "Set as Top of Stack": "Imposta come prima della pila", + "Set date & time": "Imposta data e ora", + "Set name": "Imposta nome", + "Set the mix from the photo's colours": "Imposta il mix dai colori della foto", + "Settings": "Impostazioni", + "Settings file problem": "Problema nel file delle impostazioni", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Impostazioni da cui partono le nuove foto raw. Cambiarle non tocca le foto già nella libreria.", + "Settings pasted": "Impostazioni incollate", + "Settings to include": "Impostazioni da includere", + "Settings…": "Impostazioni…", + "Shade": "Ombra", + "Shadow": "Ombra", + "Shadows": "Ombre", + "Shadows Hue": "Tonalità delle ombre", + "Shadows Luminance": "Luminanza delle ombre", + "Shadows Saturation": "Saturazione delle ombre", + "Shadows Tint": "Tinta delle ombre", + "Shadows split": "Divisione delle ombre", + "Sharpen": "Nitidezza", + "Sharpening": "Contrasto", + "Sharpness": "Nitidezza", + "Shift": "Maiusc", + "Shift by": "Sposta di", + "Short Edge": "Lato corto", + "Show": "Mostra", + "Show All": "Mostra tutto", + "Show Clipping": "Mostra tagli", + "Show Deghost Overlay": "Mostra sovrapposizione della rimozione fantasmi", + "Show Hidden Local Locations": "Mostra le posizioni locali nascoste", + "Show Luminance Map": "Mostra mappa di luminanza", + "Show Mask": "Mostra maschera", + "Show Mask Overlay": "Mostra sovrapposizione maschera", + "Show Mask Pins": "Mostra i punti delle maschere", + "Show Original": "Mostra originale", + "Show Photo Counts": "Mostra il numero di foto", + "Show Photos with Keyword": "Mostra le foto con la parola chiave", + "Show Pins": "Mostra i punti", + "Show Source": "Mostra origine", + "Show Thumbnails": "Mostra miniature", + "Show commands without a shortcut": "Mostra i comandi senza scorciatoia", + "Show destination gamut warning": "Mostra avviso gamma di destinazione", + "Show display gamut warning": "Mostra avviso gamma dello schermo", + "Show file names": "Mostra i nomi dei file", + "Show in Explorer": "Mostra in Esplora file", + "Show in File Manager": "Mostra nel gestore file", + "Show in Finder": "Mostra nel Finder", + "Show on Map": "Mostra sulla mappa", + "Show overlay (O)": "Mostra sovrapposizione (O)", + "Show ratings, flags and edit badges": "Mostra valutazioni, contrassegni e indicatori di modifica", + "Show the Navigator while zoomed in": "Mostra il Navigatore quando lo zoom è attivo", + "Show the template tags; click one to insert it": "Mostra i tag del modello; fai clic su uno per inserirlo", + "Show/Hide Mask": "Mostra/nascondi maschera", + "Show/hide My Photos panel": "Mostra/nascondi il pannello Le mie foto", + "Shutter speed": "Tempo di scatto", + "Side panels": "Pannelli laterali", + "Sidecar names": "Nomi dei file collaterali", + "Size": "Dimensione", + "Size (% of short edge)": "Dimensione (% del lato corto)", + "Size limit": "Limite di dimensione", + "Skip": "Salta", + "Sky": "Cielo", + "Slideshow": "Presentazione", + "Slideshow paused": "Presentazione in pausa", + "Slideshow resumed": "Presentazione ripresa", + "Slideshow · Space pauses · Esc ends": "Presentazione · Spazio mette in pausa · Esc termina", + "Smart Album": "Album smart", + "Smart Album Rule Fields": "Campi delle regole degli album smart", + "Smart Preview Status": "Stato delle anteprime avanzate", + "Smart Previews Location": "Posizione delle anteprime avanzate", + "Smart preview available": "Anteprima avanzata disponibile", + "Smart previews": "Anteprime avanzate", + "Smart previews folder changed": "Cartella delle anteprime avanzate cambiata", + "Smoothness": "Uniformità", + "Soft B&W": "B/N morbido", + "Soft Pastel": "Pastello morbido", + "Soft Proofing": "Prova colore a video", + "Soft Skin": "Pelle morbida", + "Sort": "Ordina", + "Sort by": "Ordina per", + "Source": "Origine", + "Source code on GitHub": "Codice sorgente su GitHub", + "Spherical": "Sferica", + "Spiral": "Spirale", + "Split Stack": "Dividi la pila", + "Spring Fresh": "Primavera fresca", + "Square Grid": "Griglia quadrata", + "Square Grid (G toggles)": "Griglia quadrata (G alterna)", + "Square Grid: show file names and formats": "Griglia quadrata: mostra nomi e formati dei file", + "Stack": "Pila", + "Stack photos taken within this time of each other:": "Metti in pila le foto scattate entro questo intervallo l'una dall'altra:", + "Stacks": "Pile", + "Standard": "Standard", + "Star rating (0–5)": "Valutazione a stelle (0-5)", + "Start at": "Inizia da", + "Start number": "Numero iniziale", + "Starting": "Avvio in corso", + "Startup": "Avvio", + "State / Province": "Regione / provincia", + "Stop": "Interrompi", + "Stop Building Previews": "Interrompi la generazione delle anteprime", + "Stop Using as Target Album": "Non usare più come album di destinazione", + "Stopping…": "Interruzione in corso…", + "Straighten": "Raddrizza", + "Straighten Tool": "Strumento raddrizza", + "Strong Contrast": "Contrasto forte", + "Style": "Stile", + "Subfolder": "Sottocartella", + "Subfolder name": "Nome della sottocartella", + "Subject": "Soggetto", + "Subtract": "Sottrai", + "Subtract from this mask": "Sottrai da questa maschera", + "Suggestions": "Suggerimenti", + "Summer Haze": "Foschia estiva", + "Sunday": "Domenica", + "Survey": "Panoramica", + "Survey (N)": "Panoramica (N)", + "Swap": "Scambia", + "Swap Aspect (X)": "Scambia proporzioni (X)", + "Swap Before and After": "Scambia Prima e Dopo", + "Swap Before and After Settings": "Scambia le impostazioni di Prima e Dopo", + "Swap Compare Photos": "Scambia le foto del confronto", + "Switch light / dark theme": "Alterna tema chiaro/scuro", + "Sync Settings": "Sincronizza impostazioni", + "System": "Sistema", + "System Info": "Info sistema", + "System Info…": "Info sistema…", + "System default": "Predefinito di sistema", + "Table": "Tabella", + "Tags": "Tag", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Tag: vedi Tag accanto al modello. I file vengono rinominati sul disco (con i loro XMP collaterali); ai nomi già esistenti si aggiunge -1, -2…", + "Take a snapshot": "Acquisisci istantanea", + "Targeted Adjustment": "Regolazione mirata", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare i colori in quel punto", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Regolazione mirata: trascina in su o in giù sulla foto per regolare la curva in quel punto", + "Teal & Amber": "Ottanio e ambra", + "Teal & Orange": "Ottanio e arancio", + "Temp": "Temp.", + "Temperature": "Temperatura", + "Template": "Modello", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sessione temporanea: la tua libreria non è aperta, e niente di quello che fai qui viene salvato.", + "Text": "Testo", + "Text direction": "Direzione del testo", + "Texture": "Texture", + "That includes the photos in the folders inside it.": "Comprende anche le foto nelle cartelle al suo interno.", + "That key can't be used as a shortcut": "Quel tasto non si può usare come scorciatoia", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Il modello SAM 3 è installato: le maschere Oggetto e Descrivi sono pronte.", + "The download didn't work:": "Il download non è riuscito:", + "The number at the end of the original name (IMG_0042 → 0042)": "Il numero alla fine del nome originale (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "L'estensione originale viene sempre aggiunta; {ext} la mette anche dentro il nome.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "I file originali, invariati, ciascuno con un XMP collaterale che contiene le sue modifiche.", + "The photo has these settings": "La foto ha queste impostazioni", + "Theme": "Tema", + "They can be restored from Recently Deleted until it is emptied.": "Si possono ripristinare da Eliminate di recente finché non viene svuotato.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Thirds": "Terzi", + "This Computer": "Questo computer", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "Questa versione non può ancora scaricare il modello. Per installarlo a mano, metti model.safetensors, vocab.json e merges.txt del facebook/sam3 di Meta nella cartella qui sopra: Oggetto e Descrivi funzionano appena sono lì.", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Questa cartella non è disponibile (disco scollegato?). Le anteprime avanzate non vengono generate né usate finché non torna o non scegli un'altra cartella.", + "This photo can't be opened": "Impossibile aprire questa foto", + "Threshold": "Soglia", + "Thumbnail cache": "Cache delle miniature", + "Thursday": "Giovedì", + "Time zone": "Fuso orario", + "Tint": "Tinta", + "Title": "Titolo", + "Title metadata": "Metadati del titolo", + "Toggle Keyword from Set": "Applica o togli parola chiave dal set", + "Toggle Mask": "Attiva/disattiva maschera", + "Toggle Section": "Apri/chiudi sezione", + "Toggle Zoom": "Attiva/disattiva zoom", + "Tone Curve": "Curva dei toni", + "Tools": "Strumenti", + "Top Level": "Livello principale", + "Transfer": "Trasferimento", + "Treatment": "Trattamento", + "Triangle": "Triangolo", + "Try Again": "Riprova", + "Try Saving Again": "Riprova a salvare", + "Tuesday": "Martedì", + "Tungsten": "Tungsteno", + "Turn Off": "Disattiva", + "Turn on Include subfolders, or pick another folder under Local": "Attiva Includi sottocartelle, oppure scegli un'altra cartella in Locale", + "Type": "Tipo", + "Uncheck All": "Deseleziona tutto", + "Uncheck the files of this type": "Deseleziona i file di questo tipo", + "Undo": "Annulla", + "Unedited": "Non modificata", + "Unedited raws": "Raw non modificati", + "Unflag": "Togli contrassegno", + "Unflagged": "Senza contrassegno", + "Ungroup Stack": "Separa la pila", + "Unknown Date": "Data sconosciuta", + "Unknown tags stay as typed — check the preview for a {typo}.": "I tag sconosciuti restano come li hai scritti: controlla l'anteprima per un {typo}.", + "Unrated": "Senza valutazione", + "Unreadable": "Illeggibile", + "Update": "Aggiorna", + "Update Mask Shape": "Aggiorna la forma della maschera", + "Update Preset with Current Settings": "Aggiorna il preset con le impostazioni correnti", + "Update Rules from Current Filter": "Aggiorna le regole dal filtro corrente", + "Update Version": "Aggiorna versione", + "Update Version with Current Settings": "Aggiorna la versione con le impostazioni correnti", + "Update with Current Settings": "Aggiorna con le impostazioni correnti", + "Updated the edits saved in the external editor": "Modifiche salvate nell'editor esterno aggiornate", + "Upright": "Raddrizzamento", + "Urban": "Urbano", + "Use Keyword Set": "Usa set di parole chiave", + "Use Library Folder": "Usa la cartella della libreria", + "Use camera-specific defaults": "Usa i predefiniti specifici della fotocamera", + "Use the GPU for rendering": "Usa la GPU per il rendering", + "User Presets": "Preset utente", + "Username": "Nome utente", + "Value": "Valore", + "Variance": "Varianza", + "Version": "Versione", + "Version name": "Nome della versione", + "Versions": "Versioni", + "Versions (Shift+V)": "Versioni (Maiusc+V)", + "Vertical": "Verticale", + "Vertical inset (% of short edge)": "Margine verticale (% del lato corto)", + "Vertical text": "Testo verticale", + "Vibrance": "Vividezza", + "Videos": "Video", + "View": "Visualizza", + "Viewing and sharing": "Visione e condivisione", + "Vignette": "Vignettatura", + "Vignetting": "Vignettatura", + "Vintage": "Vintage", + "Virtual Copy": "Copia virtuale", + "Visible": "Visibile", + "Visualize Spots": "Visualizza macchie", + "Visualize Spots (A)": "Visualizza macchie (A)", + "Visualize range": "Visualizza intervallo", + "Vivid": "Vivace", + "Vivid Pop": "Vivace e brillante", + "Warm Film": "Pellicola calda", + "Warm Glow": "Bagliore caldo", + "Warm Print": "Stampa calda", + "Warm Table": "Tavola calda", + "Watched folder": "Cartella sorvegliata", + "Watermark": "Filigrana", + "Wednesday": "Mercoledì", + "What's New": "Novità", + "When rated or hovered": "Quando ha una valutazione o è sotto il puntatore", + "White Balance": "Bilanciamento del bianco", + "White Balance Selector": "Selettore del bilanciamento del bianco", + "White Balance Selector (W)": "Selettore del bilanciamento del bianco (W)", + "Whites": "Bianchi", + "Width": "Larghezza", + "Width (% of photo)": "Larghezza (% della foto)", + "Width (px)": "Larghezza (px)", + "Width × Height": "Larghezza × altezza", + "Will be replaced": "Verrà sostituita", + "Will be restored": "Verrà ripristinata", + "Window": "Finestra", + "Winter Blue": "Blu d'inverno", + "XMP Sidecar Preferences": "Preferenze dei file XMP collaterali", + "XMP sidecars": "File XMP collaterali", + "Year": "Anno", + "Yellow": "Giallo", + "Yellow Hue": "Tonalità giallo", + "Yellow Luminance": "Luminanza giallo", + "Yellow Saturation": "Saturazione giallo", + "Yellow label": "Etichetta gialla", + "Yes": "Sì", + "You can close this: the download continues, and resumes if interrupted.": "Puoi chiudere questa finestra: il download continua, e riprende se viene interrotto.", + "Your Name": "Il tuo nome", + "Your library couldn't be opened": "Impossibile aprire la tua libreria", + "Zoom": "Ingrandimento", + "Zoom 100%": "Zoom 100%", + "Zoom In": "Ingrandisci", + "Zoom Out": "Riduci", + "Zoom to Fit": "Zoom adatta", + "Zoom to page level": "Adatta alla pagina", + "a literal %": "un % letterale", + "a removed photo": "una foto rimossa", + "all": "tutte", + "and": "e", + "any": "almeno una", + "are empty": "sono vuoti", + "aren't empty": "non sono vuoti", + "blue": "blu", + "comma, separated": "separati, da virgola", + "contains": "contiene", + "copyrighted": "protetto da copyright", + "day 01–31": "giorno 01-31", + "days": "giorni", + "discarding smart previews": "eliminazione delle anteprime avanzate", + "doesn't contain": "non contiene", + "e.g. sky · the red car · car, road": "es. cielo · l'auto rossa · auto, strada", + "ends with": "finisce con", + "false": "no", + "green": "verde", + "hour 00–23": "ora 00-23", + "hours": "ore", + "image": "immagine", + "includes the folders inside it": "comprende le cartelle al suo interno", + "is": "è", + "is <": "è <", + "is >": "è >", + "is after": "è dopo", + "is before": "è prima", + "is between": "è tra", + "is empty": "è vuoto", + "is in the last": "è negli ultimi", + "is unknown": "è sconosciuto", + "is ≤": "è ≤", + "is ≥": "è ≥", + "isn't": "non è", + "isn't empty": "non è vuoto", + "isn't in the last": "non è negli ultimi", + "keep names — or e.g. {date}_{seq:3}": "mantieni i nomi, oppure ad es. {date}_{seq:3}", + "latitude, longitude": "latitudine, longitudine", + "memory only": "solo in memoria", + "minute": "minuto", + "month 01–12": "mese 01-12", + "months": "mesi", + "none": "nessuno", + "none (CPU rendering)": "nessuna (rendering sulla CPU)", + "none of": "nessuna", + "not available here": "non disponibile qui", + "of the following rules:": "delle regole seguenti:", + "or": "o", + "pick": "scelta", + "previews": "anteprime", + "publicDomain": "pubblico dominio", + "purple": "viola", + "raw": "raw", + "red": "rosso", + "reject": "scartata", + "saved in": "salvato in", + "second": "secondo", + "smart previews": "anteprime avanzate", + "starts with": "inizia con", + "the photos in view": "le foto nella vista", + "the selected photos": "le foto selezionate", + "this photo": "questa foto", + "true": "sì", + "unknown": "sconosciuto", + "video": "video", + "weeks": "settimane", + "year, 2 digits": "anno, 2 cifre", + "year, 4 digits": "anno, 4 cifre", + "years": "anni", + "yellow": "giallo", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} è l'ora di scatto; una foto che non ce l'ha usa l'ora in cui è stata importata.", + "© 2026 Your Name": "© 2026 Il tuo nome", + "© Your Name": "© Il tuo nome", + "− harder border · + softer border": "− bordo più netto · + bordo più morbido", + "≤ at most": "≤ al massimo", + "≥ at least": "≥ almeno", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "Any camera": "Qualsiasi fotocamera", + "Any lens": "Qualsiasi obiettivo", + "Any keyword": "Qualsiasi parola chiave", + "Label Set": "Set di etichette", + ", picked": ", scelta", + ", rejected": ", scartata", + "Preview only: editing the camera's embedded JPEG": "Solo anteprima: si modifica il JPEG incorporato dalla fotocamera", + "Captured from": "Scattate dal", + "Captured until": "Scattate fino al", + "Only": "Solo", + "Open Log Folder": "Apri la cartella dei log", + "Back to this person": "Torna a questa persona", + "AI Denoise": "Riduzione disturbo IA", + "AI Denoise Model": "Modello di riduzione disturbo IA", + "AI Denoise is off: press Use on a model.": "La riduzione disturbo IA è disattivata: premi Usa su un modello.", + "AI Denoise needs a model. Install one in Settings.": "La riduzione disturbo IA ha bisogno di un modello. Installane uno nelle Impostazioni.", + "Accept & Download": "Accetta e scarica", + "Already installed. Remove it before installing a replacement.": "Già installato. Rimuovilo prima di installarne un altro.", + "Automatic uses the graphics card where it is faster than the processor: both are timed on this computer the first time a photo is made.": "Automatico usa la scheda grafica quando è più veloce del processore: la prima volta che si elabora una foto, su questo computer vengono cronometrati entrambi.", + "Cache": "Cache", + "Checking and installing the model…": "Controllo e installazione del modello…", + "Checking…": "Controllo in corso…", + "Clear cache": "Svuota cache", + "Denoise Models": "Modelli di riduzione disturbo", + "Denoise Settings": "Impostazioni della riduzione disturbo", + "Denoise all photos with an Amount": "Riduci il disturbo in tutte le foto con una quantità", + "Downloaded and checked. Installing…": "Scaricato e verificato. Installazione…", + "Failed its last test": "Non ha superato l'ultimo test", + "Graphics card": "Scheda grafica", + "I have read these terms and accept them for my own use": "Ho letto queste condizioni e le accetto per uso personale", + "Inspect Denoise Model": "Esamina il modello di riduzione disturbo", + "Install Denoise Model": "Installa un modello di riduzione disturbo", + "Install from file…": "Installa da file…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Installa un tuo modello ONNX con accanto denoise-model.json. Questa versione non offre download di modelli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "La sua immagine con il disturbo ridotto non è ancora pronta, e le immagini non vengono generate da sole (Impostazioni, Riduzione disturbo IA).", + "LightCraft does not know this model, so it assumed:": "LightCraft non conosce questo modello, quindi ha ipotizzato:", + "Limit": "Limite", + "Make it now": "Generala ora", + "Make the denoised picture of the photos I am looking at": "Genera l'immagine con il disturbo ridotto delle foto che sto guardando", + "Next up for AI Denoise…": "Prossima per la riduzione disturbo IA…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Non viene scaricato niente finché non accetti. Il modello resta solo su questo computer; LightCraft non lo carica né lo condivide mai.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Vengono generate solo le foto con una quantità di riduzione disturbo IA sopra 0. Le esportazioni generano sempre la loro se manca.", + "Open page": "Apri pagina", + "Open the model's page": "Apri la pagina del modello", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Apre nel browser la pagina del modello, per leggerne i dettagli o scaricare il file da te.", + "Processor": "Processore", + "Set up AI Denoise…": "Configura la riduzione disturbo IA…", + "Speed": "Velocità", + "Starting…": "Avvio in corso…", + "Tested and works on this computer": "Testato e funzionante su questo computer", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Il modello di riduzione disturbo è installato e in uso: ora funziona il cursore Quantità riduzione disturbo IA in Dettaglio", + "The graphics card is set up when the next photo is made.": "La scheda grafica viene configurata quando si elabora la prossima foto.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Il modello resta solo su questo computer. LightCraft non lo carica né lo condivide mai.", + "The processor does the work.": "Il lavoro lo fa il processore.", + "There is no model file to install": "Non c'è nessun file di modello da installare", + "This build cannot run denoise models.": "Questa versione non può eseguire modelli di riduzione disturbo.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli di riduzione disturbo, quindi la riduzione disturbo IA non è disponibile. (L'app desktop ce l'ha.)", + "This file cannot be used yet": "Questo file non si può ancora usare", + "Tick the box to accept the model's terms first": "Prima spunta la casella per accettare le condizioni del modello", + "Try again": "Riprova", + "Try the graphics card again": "Riprova con la scheda grafica", + "Turn off": "Disattiva", + "Unknown licence": "Licenza sconosciuta", + "Use": "Usa", + "Waiting to start…": "In attesa di partire…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Funziona sui file raw delle fotocamere con sensore standard (Bayer). Gli altri file mantengono la loro normale riduzione del disturbo.", + "cached picture": "immagine in cache", + "cached pictures": "immagini in cache", + "commercial use allowed": "uso commerciale consentito", + "denoiseModel.accept": "denoiseModel.accept", + "in use": "in uso", + "installed": "installato", + "makes AI Denoise available": "rende disponibile la riduzione disturbo IA", + "non-commercial use only": "solo uso non commerciale", + "photo has its denoised picture.": "foto ha la sua immagine con il disturbo ridotto.", + "photo waiting": "foto in attesa", + "photos have their denoised picture.": "foto hanno la loro immagine con il disturbo ridotto.", + "photos waiting": "foto in attesa", + "starts using it for AI Denoise": "comincia a usarlo per la riduzione disturbo IA", + "terms unclear": "condizioni poco chiare", + "the graphics card": "la scheda grafica", + "the graphics card is not used": "la scheda grafica non viene usata", + "Cancel Denoise": "Annulla riduzione disturbo", + "Cancel Denoise Model Download": "Annulla il download del modello di riduzione disturbo", + "Choose Denoise Model": "Scegli il modello di riduzione disturbo", + "Clear Denoise Cache": "Svuota la cache della riduzione disturbo", + "Denoise Model Downloads": "Download dei modelli di riduzione disturbo", + "Denoise Photos": "Riduci il disturbo delle foto", + "Denoise Pump": "Coda della riduzione disturbo", + "Denoise Status": "Stato della riduzione disturbo", + "Download Denoise Model": "Scarica il modello di riduzione disturbo", + "Remove Denoise Model": "Rimuovi il modello di riduzione disturbo", + "Test Denoise Model": "Testa il modello di riduzione disturbo", + "AI Denoise is enabled on the requesting photo": "La riduzione disturbo IA è attiva sulla foto che l'ha richiesta", + "After installation, AI Denoise will turn on for the requesting photo.": "Dopo l'installazione, la riduzione disturbo IA si attiverà sulla foto che l'ha richiesta.", + "Cancel pending action": "Annulla l'azione in sospeso", + "Install a denoise model to enable AI Denoise on this photo": "Installa un modello di riduzione disturbo per attivare la riduzione disturbo IA su questa foto", + "Pending photo action cancelled": "Azione sulla foto in sospeso annullata", + "Pending photo action cancelled because its library or photo changed": "Azione sulla foto in sospeso annullata perché la libreria o la foto sono cambiate", + "ONNX model": "Modello ONNX", + "commercial use subject to GPL conditions": "uso commerciale soggetto alle condizioni della GPL", + "Decrease Rating": "Diminuisci valutazione", + "Increase Rating": "Aumenta valutazione", + "Click to type a value": "Fai clic per scrivere un valore", + "Angle": "Angolo", + "Video": "Video", + "Copyrighted": "Protetta da copyright", + "Public Domain": "Pubblico dominio", + "Unknown": "Sconosciuto", + "Landscape (wide)": "Orizzontale (larga)", + "Portrait (tall)": "Verticale (alta)", + "Square": "Quadrato", + "In Color": "A colori", + "No Label": "Nessuna etichetta", + "Choose an album…": "Scegli un album…", + "No albums yet": "Ancora nessun album", + "Fix the marked rules to save this album.": "Correggi le regole segnate per salvare questo album.", + "Some rules need fixing (Edit Smart Album…):": "Alcune regole vanno corrette (Modifica album smart…):", + "isn't a field rules know": "non è un campo che le regole conoscono", + "can't be tested that way": "non si può verificare in quel modo", + "is yes or no": "è sì o no", + "needs a number": "richiede un numero", + "needs a time like 1/250 or 2": "richiede un tempo come 1/250 o 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "richiede una data come 2026, 2026-08 o 2026-08-14", + "has the first date after the second": "ha la prima data successiva alla seconda", + "needs two values": "richiede due valori", + "needs a number more than 0": "richiede un numero maggiore di 0", + "needs hours, days, weeks, months or years": "richiede ore, giorni, settimane, mesi o anni", + "isn't one of the choices": "non è tra le scelte possibili", + "needs something to look for (or use “is empty”)": "richiede qualcosa da cercare (oppure usa “è vuoto”)", + "needs something to look for (or use “isn't empty”)": "richiede qualcosa da cercare (oppure usa “non è vuoto”)", + "needs text": "richiede un testo", + "can't match any rating from 0 to 5": "non può corrispondere a nessuna valutazione da 0 a 5", + "needs an album": "richiede un album", + "names an album that no longer exists": "indica un album che non esiste più", + "can't test a folder; choose an album in it": "non si può verificare su una cartella; scegli un album al suo interno", + "This group is empty: add a rule or remove it.": "Questo gruppo è vuoto: aggiungi una regola o rimuovilo.", + "needs something to look for (or use “are empty”)": "richiede qualcosa da cercare (oppure usa “sono vuoti”)", + "needs something to look for (or use “aren't empty”)": "richiede qualcosa da cercare (oppure usa “non sono vuoti”)", + "Mo": "Lu", + "Tu": "Ma", + "We": "Me", + "Th": "Gi", + "Fr": "Ve", + "Sa": "Sa", + "Su": "Do", + "Pick a date": "Scegli una data", + "would make this album include itself through other smart albums": "farebbe includere questo album in se stesso attraverso altri album smart", + "Search albums": "Cerca album", + "No albums match": "Nessun album corrisponde", + "This is the album you're editing": "È l'album che stai modificando", + "It tests this album, so testing it back would loop": "Verifica questo album, quindi verificarlo a sua volta creerebbe un ciclo", + "Copy Name": "Nome della copia", + "In a Stack": "In una pila", + "File Extension": "Estensione del file", + "Video Duration": "Durata del video", + "Keyword Count": "Numero di parole chiave", + "People Count": "Numero di persone", + "Shutter Speed": "Tempo di scatto", + "Cropped": "Ritagliata", + "Keywords & People": "Parole chiave e persone", + "Description": "Descrizione", + "Camera Info": "Informazioni sulla fotocamera", + "Develop": "Sviluppo", + "Keyword List": "Elenco parole chiave", + "Filter Keywords": "Filtra parole chiave", + "No keywords yet": "Ancora nessuna parola chiave", + "No keywords match": "Nessuna parola chiave corrisponde", + "Keyword Name": "Nome della parola chiave", + "Synonyms": "Sinonimi", + "Separated by commas": "Separati da virgole", + "Keyword Tag Options": "Opzioni del tag parola chiave", + "Include on Export": "Includi nell'esportazione", + "Export Containing Keywords": "Esporta le parole chiave che la contengono", + "Export Synonyms": "Esporta i sinonimi", + "Create Keyword Tag": "Crea tag parola chiave", + "Edit Keyword Tag": "Modifica tag parola chiave", + "Create": "Crea", + "No photo has it; the keywords below it go too.": "Nessuna foto ce l'ha; vengono tolte anche le parole chiave sotto di essa.", + "Edit Keyword Tag…": "Modifica tag parola chiave…", + "Put New Keywords Inside This Keyword": "Metti le nuove parole chiave dentro questa", + "Purge Unused Keywords": "Elimina le parole chiave non usate", + "Delete Keyword…": "Elimina parola chiave…", + "Drop here for the top level": "Rilascia qui per il livello principale", + "Their photos and the keywords below them come together under one keyword.": "Le loro foto e le parole chiave sotto di esse si riuniscono sotto un'unica parola chiave.", + "Select photos to give them keywords.": "Seleziona delle foto per assegnare loro parole chiave.", + "Add to All Selected Photos": "Aggiungi a tutte le foto selezionate", + "Will Export": "Verrà esportata", + "No keywords are exported": "Nessuna parola chiave viene esportata", + "& Containing": "e contenenti", + "Edit Set…": "Modifica set…", + "Edit Keyword Set": "Modifica set di parole chiave", + "Keywords (⌥1–⌥9)": "Parole chiave (⌥1-⌥9)", + "A keyword set needs a name": "Un set di parole chiave ha bisogno di un nome", + "Save as a new set": "Salva come nuovo set", + "Recent Keywords is built in: choose another name": "Parole chiave recenti è predefinito: scegli un altro nome", + "Import Keywords…": "Importa parole chiave…", + "Export Keywords…": "Esporta parole chiave…", + "Import Keywords": "Importa parole chiave", + "Export Keywords": "Esporta parole chiave", + "Keyword Lists": "Elenchi di parole chiave", + "Synchronize Folder…": "Sincronizza cartella…", + "Synchronize Folder": "Sincronizza cartella", + "Synchronize": "Sincronizza", + "Scanning…": "Scansione…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Trova le foto aggiunte o mancanti in questa cartella sul disco, e i file XMP collaterali modificati da altre app", + "Bring the library up to date with this folder and the folders inside it:": "Aggiorna la libreria con questa cartella e le cartelle al suo interno:", + "Import new photos": "Importa le foto nuove", + "Remove missing photos from the library": "Togli dalla libreria le foto mancanti", + "Read metadata updates from XMP sidecars": "Leggi gli aggiornamenti dei metadati dai file XMP collaterali", + "The library is up to date with this folder.": "La libreria è aggiornata rispetto a questa cartella.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Le foto mancanti finiscono in Eliminate di recente e si possono ripristinare; nessun file sul disco viene toccato.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Leggere un file collaterale sostituisce valutazione, etichette, metadati e modifiche della foto con quello che contiene.", + "Skipped: files already in the library elsewhere": "Saltati: file già presenti altrove nella libreria", + "Skipped: files that can't be read": "Saltati: file che non si possono leggere", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Questa cartella non c'è: è stata spostata o rinominata, oppure il suo disco non è collegato.", + "Relink photos whose file was renamed or moved": "Ricollega le foto il cui file è stato rinominato o spostato", + "A folder is already being synchronized": "Una cartella è già in sincronizzazione", + "Synchronizing…": "Sincronizzazione in corso…", + "Relinked": "Ricollegate", + "Removed": "Rimosse", + "Metadata read": "Metadati letti", + "Failed": "Non superato", + "Stopped": "Interrotto", + "Stopped by an error (see the log)": "Interrotta da un errore (vedi il log)", + "Not saved": "Non salvato", + "A folder is being synchronized": "Una cartella è in sincronizzazione", + "Wait for the import to finish": "Aspetta che l'importazione finisca", + "Appearance": "Aspetto", + "Appearance Mode": "Modalità aspetto", + "Sync with System": "Segui il sistema", + "Light Mode": "Tema chiaro", + "Dark Mode": "Modalità scura", + "Next Appearance Mode": "Modalità aspetto successiva", + "Light Theme": "Tema chiaro", + "Dark Theme": "Tema scuro", + "In Use": "In uso", + "Charcoal": "Carboncino", + "Midnight": "Mezzanotte", + "Silver": "Argento", + "Paper": "Carta", + "Show less": "Mostra meno", + "Quit while tasks are running?": "Uscire mentre ci sono attività in corso?", + "Exporting contact sheet": "Esportazione del foglio provini", + "Reading folder": "Lettura della cartella", + "Scanning folder": "Scansione della cartella", + "Building previews": "Generazione delle anteprime", + "Building smart previews": "Generazione delle anteprime avanzate", + "Discarding smart previews": "Eliminazione delle anteprime avanzate", + "Importing Lightroom catalog": "Importazione del catalogo Lightroom", + "Reading Lightroom catalog": "Lettura del catalogo Lightroom", + "Merging": "Unione", + "Downloading model": "Download del modello", + "Finding faces": "Ricerca dei volti", + "Synchronizing folder": "Sincronizzazione della cartella", + "Looking for changes": "Ricerca delle modifiche", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Questo è un catalogo di Lightroom, non una libreria di LightCraft. Vuoi importarlo in una libreria di LightCraft?", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "L'importazione legge il catalogo senza modificarlo e aggiunge foto, modifiche, parole chiave e raccolte alla libreria aperta ora.", + "Albums hold library photos: Add to My Photos first": "Gli album contengono foto della libreria: prima aggiungi a Le mie foto", + "Add to My Photos First": "Prima aggiungi a Le mie foto", + "Fill Location from GPS": "Compila la posizione dal GPS", + "Download Place Names (GeoNames)": "Scarica i nomi dei luoghi (GeoNames)", + "Place Names Status": "Stato dei nomi dei luoghi", + "Fill Location": "Compila la posizione", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Compila Città, Regione e Paese vuoti dal GPS (nomi dei luoghi GeoNames, senza connessione)", + "Looking up places": "Ricerca dei luoghi", + "Downloading place names": "Download dei nomi dei luoghi", + "Place names downloaded": "Nomi dei luoghi scaricati", + "Place names download cancelled": "Download dei nomi dei luoghi annullato", + "Other…": "Altro…", + "Intent:": "Intento:", + "Perceptual": "Percettivo", + "Relative": "Relativo", + "Simulate Paper & Ink": "Simula carta e inchiostro", + "Content-Aware": "In base al contenuto", + "Sidebar Sections": "Sezioni della barra laterale", + "Reset Sidebar Sections": "Reimposta le sezioni della barra laterale", + "Hide This Section": "Nascondi questa sezione", + "No folders to browse": "Nessuna cartella da sfogliare", + "No photos yet": "Ancora nessuna foto", + "No imported folders yet": "Ancora nessuna cartella importata", + "Show Sidebar Section": "Mostra sezione della barra laterale", + "Move Sidebar Section": "Sposta sezione della barra laterale", + "New Library…": "Nuova libreria…", + "Open Recent Library": "Apri libreria recente", + "Recent Libraries": "Librerie recenti", + "Clear Recent Libraries": "Cancella le librerie recenti", + "No Recent Libraries": "Nessuna libreria recente", + "New Library: Choose Where to Put It": "Nuova libreria: scegli dove metterla", + "New Library": "Nuova libreria", + "Library name": "Nome della libreria", + "New Folder on Disk": "Nuova cartella sul disco", + "New Folder on Disk…": "Nuova cartella sul disco…", + "Move to Folder": "Sposta nella cartella", + "Move to Folder…": "Sposta nella cartella…", + "Move Selected Photos Here": "Sposta qui le foto selezionate", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Sposta i file e i loro XMP collaterali in questa cartella; non sovrascrive mai", + "Appearance Settings…": "Impostazioni aspetto…", + "Add to favorites": "Aggiungi ai preferiti", + "Remove from favorites": "Rimuovi dai preferiti", + "Add to Apple Photos": "Aggiungi a Foto di Apple", + "Adding to Apple Photos…": "Aggiunta a Foto di Apple…", + "not added to Apple Photos (cancelled)": "non aggiunte a Foto di Apple (annullato)", + "The first time, macOS asks whether LightCraft may control Photos.": "La prima volta, macOS chiede se LightCraft può controllare Foto.", + "Adding to Apple Photos": "Aggiunta a Foto di Apple", + "Add Face Model…": "Aggiungi modello dei volti…", + "Cancel Face Model Download": "Annulla il download del modello dei volti", + "Cancel Pending Photo Action": "Annulla l'azione in sospeso sulla foto", + "Choose Face Recognition Model": "Scegli il modello di riconoscimento dei volti", + "Choose a model below (Use) to start.": "Per cominciare, scegli un modello qui sotto (Usa).", + "Detect Faces": "Rileva i volti", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Serve a Rileva i volti (menu Foto): un piccolo download da fare una volta sola.", + "Download Face Model": "Scarica il modello dei volti", + "Drag Crop Handle": "Trascina la maniglia del ritaglio", + "Evaluate Face Recognition": "Valuta il riconoscimento dei volti", + "Face Model Downloads": "Download dei modelli dei volti", + "Face Models": "Modelli dei volti", + "Face Recognition On/Off": "Riconoscimento dei volti attivo/disattivo", + "Face detector": "Rilevatore di volti", + "Face recognition": "Riconoscimento dei volti", + "Faces": "Volti", + "Faces of a Person": "Volti di una persona", + "Go back to listing them by name": "Torna all'elenco per nome", + "Index Faces": "Indicizza i volti", + "Index Faces in the Background": "Indicizza i volti in background", + "Inspect Face Model File": "Esamina il file del modello dei volti", + "Install Face Model": "Installa il modello dei volti", + "Library Folders": "Cartelle della libreria", + "Name Face": "Dai un nome al volto", + "Name Faces": "Dai un nome ai volti", + "Recognise faces (experimental)": "Riconosci i volti (sperimentale)", + "Recognition models": "Modelli di riconoscimento", + "Remove Face Model": "Rimuovi il modello dei volti", + "Reorder Album": "Riordina album", + "Show Person": "Mostra persona", + "Sort Albums A–Z": "Ordina album A-Z", + "Sort Albums by Name": "Ordina album per nome", + "Sort Contents A–Z": "Ordina contenuti A-Z", + "Suggest Names for Faces": "Suggerisci nomi per i volti", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Suggerisce chi c'è in una foto a partire dai volti a cui hai dato un nome. Tutto resta sul tuo computer.", + "Test Face Model": "Testa il modello dei volti", + "This build cannot run recognition models: they can be added and chosen, not used.": "Questa versione non può eseguire modelli di riconoscimento: si possono aggiungere e scegliere, non usare.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Questa versione non ha dove tenere i modelli dei volti, quindi l'elenco dei modelli non è disponibile. (L'app desktop ce l'ha.)", + "Unnamed Faces": "Volti senza nome" +} diff --git a/crates/ui-egui/locales/ja-formats.json b/crates/ui-egui/locales/ja-formats.json index 211e47e29..616c150cf 100644 --- a/crates/ui-egui/locales/ja-formats.json +++ b/crates/ui-egui/locales/ja-formats.json @@ -13,9 +13,11 @@ "Added {n} photo{} to My Photos": "マイフォトに{n}枚を追加しました{:.0}", "Added {n} photo{} to “{}”": "「{1}」に{n}枚を追加しました{0:.0}", "All {n} selected photos move by the same amount.": "選択中の{n}枚を同じ時間だけずらします", + "Already in “{}”": "すでに「{}」にあります", "Auto Sync: edits apply to {n} photos": "自動同期:{n}枚に編集を適用", "Automatic ({} MB)": "自動({}MB)", "Click {}:1": "クリック時 {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "カラースペース:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "{n}枚の仮想コピーを作成", "Decoder: {reason}": "デコーダー:{reason}", "Delete \"{label}\"": "「{label}」を削除", @@ -39,7 +41,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraftのウィンドウを開けませんでした: {e}\n\n詳しくはログファイルをご覧ください: {path}", "Matches: {}": "一致条件:{}", "Merging… {stage} {:.0}%": "合成中… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "{n}枚を「{1}」に移動{0:.0}", + "Move {n} photo{} to “{}”?": "{n}枚を「{1}」に移動しますか?{0:.0}", "Move {what} to Recently Deleted?": "{what}を「最近削除した写真」に移動しますか?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n}枚を「{1}」に移動しました。編集 → 取り消す で元に戻せます。{0:.0}", "Named ({named_n})": "名前付き({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "{n}枚に貼り付け{:.0}(コピーされた設定だけを適用)", "Preset: {name}": "プリセット:{name}", @@ -66,6 +71,7 @@ "Show {hidden} hidden location{}": "非表示の{hidden}か所を表示{:.0}", "Showing {n} of {}": "{}枚中{n}枚を表示", "Smart album: {}": "スマートアルバム:{}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "{n} 枚の写真の XMP サイドカーを書き込めませんでした(フォルダーがオフラインではありませんか?)。LightCraft は再試行を続けます。", "Undo {}": "取り消す:{}", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "バージョン{} — Rust製の写真管理・RAW現像アプリ", "Version: {}": "バージョン:{}", @@ -93,6 +99,7 @@ "{n} photo{}": "{n}枚{:.0}", "{n} photo{} match · updates automatically as photos change": "{n}枚が一致{:.0}・写真の変更に応じて自動更新", "{n} photo{} now · updates automatically as photos change": "現在{n}枚{:.0}・写真の変更に応じて自動更新", + "{n} selected photos · {pages} pages": "選択した写真 {n} 枚 · {pages} ページ", "{sel_n} selected · {counted}": "{sel_n}枚選択・{counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{what} merge added": "{what}合成を追加しました", @@ -197,23 +204,23 @@ "{} 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}.": "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "{host} からダウンロードします。届いて確認が済むと、LightCraft がインストールし、{after}。", "{:.1} MB": "{:.1} MB", - "Running on the processor: {}.": "Running on the processor: {}.", - "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.", - "Denoising {name}: {done} of {total} tiles{}": "Denoising {name}: {done} of {total} tiles{}", + "Running on the processor: {}.": "処理装置で実行中:{}。", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}。ライブラリの「denoise」フォルダーに保存します。上限に達すると、どの写真にも使われていない古いものから削除します。", + "Denoising {name}: {done} of {total} tiles{}": "{name} のノイズを除去中:{total} タイル中 {done}{}", "{size} GB": "{size} GB", - "{} of {} from {host}": "{} of {} from {host}", + "{} of {} from {host}": "{host} から {} / {}", "{} KB": "{} KB", "{} MB": "{} MB", - "Making the denoised picture… {}%": "Making the denoised picture… {}%", + "Making the denoised picture… {}%": "ノイズ除去した画像を作成中… {}%", "{name} · {terms}": "{name} · {terms}", "{n} {noun}": "{n} {noun}", - "Trained on: {provenance}": "Trained on: {provenance}", - " · {queued} waiting": " · {queued} waiting", - "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.", - "In line for AI Denoise ({ahead} ahead)…": "In line for AI Denoise ({ahead} ahead)…", - "Running on {}.": "Running on {}.", + "Trained on: {provenance}": "学習データ:{provenance}", + " · {queued} waiting": " · {queued} 件待機中", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "モデルの条件を表示してから、{host} から {} をダウンロードし、インストールして使用を開始します。それ以外は何も送信しません。", + "In line for AI Denoise ({ahead} ahead)…": "AIノイズ除去の順番待ち(前に {ahead} 件)…", + "Running on {}.": "{} で実行中。", "{file} · {size}": "{file} · {size}", "• {assumption}": "• {assumption}", "Put inside “{parent}”": "「{parent}」の中に入れる", @@ -235,6 +242,10 @@ "{done} of {total} MB": "{done} / {total} MB", "{pct} %": "{pct}%", "+{n} more": "ほか{n}件", + "Create the keyword “{name}”": "キーワード「{name}」を作成", + "new, inside {parent}": "新規、{parent} の中", + "Keywords of {n} selected photos": "選択した写真{n}枚のキーワード", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "「{name}」を写真{n}枚から削除しました。「編集」→「取り消す」で元に戻せます。", "1 task is still running: {names}. Quitting stops it.": "1件のタスクが実行中です:{names}。終了すると停止します。", "{n} tasks are still running: {names}. Quitting stops them.": "{n}件のタスクが実行中です:{names}。終了すると停止します。", "{what} merge cancelled": "{what}合成をキャンセルしました", @@ -245,7 +256,44 @@ "{n} added to Apple Photos album “{album}”": "{n}件をAppleの写真のアルバム「{album}」に追加しました", "{n} added to Apple Photos": "{n}件をAppleの写真に追加しました", "{n} photos opened for editing; each is stacked with its original": "{n}枚の写真を編集用に開きました。それぞれ元の写真とスタックされています", + " in {g} group{}": "({g}グループ){:.0}", + " · kept, not applied: {}": " · 保持(適用なし):{}", + " — kept but not applied: {}": " — 保持(適用なし):{}", + "Apply to “{mask}” on Selected Photos": "選択した写真の「{mask}」に適用", + "Delete the effect preset “{name}”?": "効果プリセット「{name}」を削除しますか?", + "Delete these {n} effect presets?": "これらの効果プリセット{n}件を削除しますか?", + "Delete {n} effect preset{}? This can't be undone.": "効果プリセット{n}件を削除しますか?{:.0}元に戻せません。", + "Effect: {name}": "効果:{name}", + "Exported {} effect preset{}": "効果プリセットを{}件書き出しました{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "マスク効果を{e}件読み込みました{:.0}(マスク → 効果)", + "Update “{name}” with Current Settings": "「{name}」を現在の設定で更新", + "{n} preset{}": "{n}件のプリセット{:.0}", "{name} opened in the other editor": "{name}を別のエディタで開きました", "{n} originals opened in the other editor": "{n}個の元ファイルを別のエディタで開きました", - "· {in_source} identical copies": "・同一のコピー {in_source} 件" + "· {in_source} identical copies": "・同一のコピー {in_source} 件", + "{count} more warnings.": "ほかに{count}件の警告があります。", + "{photos} catalog records processed; {collections} collections.": "カタログの{photos}件のレコードを処理しました。コレクションは{collections}件です。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing}件のオリジナルが見つかりません。カタログのレコードは利用できます。「見つからない写真を検索」で再リンクしてください。", + "Applied {name}": "{name} を適用しました", + "Delete “{name}”?": "「{name}」を削除しますか?", + "Updated {name}": "{name} を更新しました", + "Exported to {path}": "{path} に書き出しました", + "Saved template {name}": "テンプレート {name} を保存しました", + "Imported {name}": "{name} を読み込みました", + "Skipped {problem}": "スキップ: {problem}", + "…and {more} more": "…ほか {more} 件", + "Template preview: {name}": "テンプレートのプレビュー: {name}", + "{n} photos in view · {slides} slides · {time}": "表示中の写真 {n} 枚 · スライド {slides} 枚 · {time}", + "{n} pages": "{n} ページ", + "{n} JPEG files": "JPEG ファイル {n} 個", + "{s} s of AV1 video": "AV1 ビデオ {s} 秒", + "Slideshow exported: {what}": "スライドショーを書き出しました:{what}", + " · {n} skipped (could not be rendered)": " · {n} 枚をスキップ(レンダリングできません)", + "{n} raws can now be developed from their raw data": "{n}枚のRAWをRAWデータから現像できるようになりました", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraftが予期せず終了しました: {e}\n\n詳しくはログファイルをご覧ください: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraftが予期せず終了しました: {e}", + "Renames {n} face{} across the library. The files are not changed.": "ライブラリ全体で{n}件の顔の名前を変更します。ファイルは変更されません。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」はすでに別の人物です。まとめると1人になり、元に戻すと再び分かれます。", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}合成を{mp}で追加しました:フルサイズ({full})にはこのコンピューターのメモリが足りません", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}合成を{mp}で追加しました。パノラマの最大サイズです(フルサイズ:{full})" } diff --git a/crates/ui-egui/locales/ja.json b/crates/ui-egui/locales/ja.json index 7a282e676..2dc80934f 100644 --- a/crates/ui-egui/locales/ja.json +++ b/crates/ui-egui/locales/ja.json @@ -10,11 +10,13 @@ ", rejected": "、不採用", "0: nothing is rejected.": "0:不採用にしません", "1 day": "1日", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 現在の編集 · トリミングせずに収める", "2026-04 or 2026-04-12": "2026-04 または 2026-04-12", "= exactly": "= と等しい", "A blank template keeps the original names.": "テンプレートが空の場合は元の名前のままです", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "フィルターで写真が非表示になっています。条件を変更するか、「表示 → フィルターを解除」を選んでください", "A one-time download of about": "1回限りのダウンロード:約", + "A pen's pressure sets the size and flow of the brush": "ペンの筆圧でブラシのサイズと流量が変わります", "AI Mask Model Status": "AIマスクモデルの状態", "About": "LightCraftについて", "About LightCraft": "LightCraftについて", @@ -39,6 +41,9 @@ "Add to Favorites": "お気に入りに追加", "Add to Local": "ローカルに追加", "Add to My Photos": "マイフォトに追加", + "Can't be moved here": "ここには移動できません", + "This row covers more than one disk: drop on a folder inside it": "この行は複数のディスクにまたがっています。中のフォルダにドロップしてください", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "ローカルの写真はまだライブラリにありません。先に「マイフォトに追加」してから移動してください", "Add to Selected Photos": "選択した写真に追加", "Add to Target Album": "追加先アルバムに追加", "Add to this mask": "このマスクに追加", @@ -198,6 +203,7 @@ "Choose Edit Settings to Copy": "コピーする編集設定を選択", "Choose Edit Settings to Copy…": "コピーする編集設定を選択…", "Choose Folder…": "フォルダを選択…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確定後に PDF の保存先を選びます。PDF はビューアから印刷してください。", "Choose a photo in the grid or filmstrip": "グリッドまたはフィルムストリップから写真を選択してください", "Choose…": "選択…", "Cinematic": "シネマティック", @@ -244,6 +250,9 @@ "Color Priority": "カラー優先", "Color Range": "色範囲", "Color space": "色空間", + "Color space: Rec. 2020 PQ (HDR AVIF)": "カラースペース:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "カラースペース:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "カラースペース:Rec. 2020 PQ、16 ビット(HDR PNG)", "Color space: sRGB (AVIF)": "色空間:sRGB(AVIF)", "Colors": "カラー", "Command Journal": "コマンド記録", @@ -258,7 +267,14 @@ "Concrete Cool": "クールコンクリート", "Confirm before deleting": "削除前に確認", "Confirm before moving photos to Recently Deleted": "「最近削除した写真」に移動する前に確認", + "Move Photos": "写真を移動", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "ファイルと XMP サイドカーがディスク上で移動します。編集 → 取り消す で元に戻せます。", + "Don't ask again": "今後確認しない", + "Another library was opened since the photos were dropped: nothing was moved": "写真をドロップした後に別のライブラリが開かれました。何も移動していません", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「ディスク上の別のフォルダに移動する前に確認」で元に戻せます", + "Confirm before moving photos to another folder on disk": "ディスク上の別のフォルダに移動する前に確認", "Constrain Crop": "切り抜きを制限", + "Contact Sheet PDF": "コンタクトシート PDF", "Continue": "続行", "Contrast": "コントラスト", "Contributors": "コントリビューター", @@ -468,6 +484,7 @@ "File Type": "ファイルの種類", "File types:": "ファイルの種類:", "Filename": "ファイル名", + "Filename captions": "ファイル名のキャプション", "Fill": "画面いっぱい", "Film": "フィルム", "Filmstrip (/)": "フィルムストリップ(/)", @@ -503,12 +520,16 @@ "HDR Panorama Merge Preview": "HDRパノラマ合成のプレビュー", "HDR Panorama with Last Settings": "前回の設定でHDRパノラマ合成", "HDR Panoramas": "HDRパノラマ", + "HDR TIFF needs 32-bit float.": "HDR TIFF には 32 ビット浮動小数点が必要です。", + "HDR output": "HDR 出力", "HDR panorama": "HDRパノラマ", "HDR panoramas": "HDRパノラマ", "HDR with Last Settings": "前回の設定でHDR合成", + "HDR for Each Bracket Set": "ブラケットごとにHDR合成", "Has Edits": "編集", "Has GPS": "GPS情報", "High Contrast B&W": "ハイコントラスト白黒", + "Hold View Until Refreshed": "更新するまで表示を保持", "Home": "ホーム", "Horizontal inset (% of short edge)": "水平方向の余白(短辺に対する%)", "Hours": "時間", @@ -546,6 +567,7 @@ "Last commit": "最後のコミット", "Last loupe render": "直近のルーペ描画", "Lens Blur": "レンズぼかし", + "Letter": "レター", "Library Info": "ライブラリ情報", "Library Preferences": "ライブラリの環境設定", "Library State": "ライブラリの状態", @@ -636,9 +658,11 @@ "Paste Settings from Previous": "前の写真の設定を貼り付け", "Paste from Previous": "前の写真から貼り付け", "Pastel Haze": "パステルヘイズ", + "Pen Pressure": "筆圧", "People": "人物", "Person": "人物", "Pet Eye Catchlight": "ペットの目のキャッチライト", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "HDR で編集した写真:JPEG は HDR ゲインマップ付き(どこでも正しく表示され、HDR ディスプレイではハイライトがより明るい)、AVIF は 10 ビット、PNG と JPEG XL は 16 ビット Rec. 2020 PQ。TIFF には 32 ビット浮動小数点が必要です", "Photos per bracket (0 = auto)": "ブラケットあたりの枚数(0 = 自動)", "Pick Flag": "採用フラグ", "Pictures": "ピクチャ", @@ -684,6 +708,7 @@ "Refine Saturation": "彩度を微調整", "Refining the mask's detail…": "マスクの細部を微調整中…", "Refresh Source": "コピー元を更新", + "Refresh View": "表示を更新", "Reject": "不採用", "Reliability": "信頼性", "Relink Photo": "写真の参照先を再設定", @@ -949,13 +974,18 @@ "No edits yet.": "まだ編集していません", "No exposure information": "露出情報がありません", "No filter presets yet": "フィルタープリセットはまだありません", - "No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)", + "No lens data embedded in this file.": "このファイルにレンズ情報は含まれていません", "No masks yet. Choose a mask type above.": "マスクはありません。上から種類を選択してください", "No matching photos": "一致する写真がありません", "No metadata found": "メタデータがありません", "No new presets": "新しいプリセットはありません", "No photo selected": "写真が選択されていません", "No photos": "写真がありません", + "Your library is empty": "ライブラリは空です", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "読み込んだ写真はここに表示されます。選択しない限り、コピーや移動は行われません。", + "or drop photos and folders into this window": "または写真やフォルダーをこのウィンドウにドロップ", + "Import photos to get started": "写真を読み込んで始めましょう", + "or drop photos into this window": "または写真をこのウィンドウにドロップ", "No photos found": "写真が見つかりません", "No photos in this folder": "このフォルダに写真はありません", "No photos match the active filters": "現在のフィルターに一致する写真がありません", @@ -1132,7 +1162,7 @@ "Reset Section": "項目をリセット", "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)": "すべての編集をリセット(⌘⇧R)", + "Reset all edits": "すべての編集をリセット", "Resize Face Box": "顔の枠のサイズを変更", "Resize panel window": "パネルウィンドウのサイズを変更", "Resize panels": "パネルのサイズを変更", @@ -1284,6 +1314,7 @@ "Show overlay (O)": "重ね表示(O)", "Show ratings, flags and edit badges": "評価・フラグ・編集済みマークを表示", "Show the Navigator while zoomed in": "拡大時にナビゲーターを表示", + "Show the import review when a camera or card is connected": "カメラやメモリーカードを接続したときに読み込み画面を表示", "Show the template tags; click one to insert it": "テンプレートのタグを表示(クリックで挿入)", "Show/Hide Mask": "マスクの表示/非表示", "Show/hide My Photos panel": "マイフォトパネルを表示/非表示", @@ -1310,6 +1341,7 @@ "Soft Pastel": "ソフトパステル", "Soft Proofing": "ソフト校正", "Soft Skin": "ソフトスキン", + "Solo Mode": "ソロモード", "Sort": "並べ替え", "Sort by": "並べ替え", "Source": "読み込み元", @@ -1440,6 +1472,7 @@ "Vibrance": "自然な彩度", "Videos": "動画", "View": "表示", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "PNG の cICP チャンクを読まないビューアは PQ の値を sRGB として表示するため、平坦で色あせて見えます。どこでも正しく見せるには JPEG(ゲインマップ)を使ってください。", "Viewing and sharing": "表示と共有", "Vignette": "周辺光量補正", "Vignetting": "周辺光量補正", @@ -1471,6 +1504,7 @@ "Will be restored": "復元されます", "Window": "ウィンドウ", "Winter Blue": "ウィンターブルー", + "Wrote the XMP sidecars that were waiting": "保留中の XMP サイドカーを書き込みました", "XMP Sidecar Preferences": "XMPサイドカーファイルの環境設定", "XMP sidecars": "XMPサイドカーファイル", "Year": "年", @@ -1663,99 +1697,99 @@ "Open Log Folder": "ログフォルダーを開く", "Reading photos…": "写真を確認中…", "Adding photos…": "写真を追加中…", - "Back to this person": "Back to this person", - "AI Denoise": "AI Denoise", - "AI Denoise Model": "AI Denoise Model", - "AI Denoise is off: press Use on a model.": "AI Denoise is off: press Use on a model.", - "AI Denoise needs a model. Install one in Settings.": "AI Denoise needs a model. Install one in Settings.", - "Accept & Download": "Accept & Download", - "Already installed. Remove it before installing a replacement.": "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.": "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": "Cache", - "Checking and installing the model…": "Checking and installing the model…", - "Checking…": "Checking…", - "Clear cache": "Clear cache", - "Denoise Models": "Denoise Models", - "Denoise Settings": "Denoise Settings", - "Denoise all photos with an Amount": "Denoise all photos with an Amount", - "Downloaded and checked. Installing…": "Downloaded and checked. Installing…", - "Failed its last test": "Failed its last test", - "Graphics card": "Graphics card", - "I have read these terms and accept them for my own use": "I have read these terms and accept them for my own use", - "Inspect Denoise Model": "Inspect Denoise Model", - "Install Denoise Model": "Install Denoise Model", - "Install from file…": "Install from file…", - "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.", - "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "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 does not know this model, so it assumed:", - "Limit": "Limit", - "Make it now": "Make it now", - "Make the denoised picture of the photos I am looking at": "Make the denoised picture of the photos I am looking at", - "Next up for AI Denoise…": "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.": "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.", - "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.", - "Open page": "Open page", - "Open the model's page": "Open the model's page", - "Opens the model's own page in your browser, to read about it or get the file yourself.": "Opens the model's own page in your browser, to read about it or get the file yourself.", - "Processor": "Processor", - "Set up AI Denoise…": "Set up AI Denoise…", - "Speed": "Speed", - "Starting…": "Starting…", - "Tested and works on this computer": "Tested and works on this computer", - "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "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 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.": "The model is kept on this computer only. LightCraft never uploads or shares it.", - "The processor does the work.": "The processor does the work.", - "There is no model file to install": "There is no model file to install", - "This build cannot run denoise models.": "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 build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)", - "This file cannot be used yet": "This file cannot be used yet", - "Tick the box to accept the model's terms first": "Tick the box to accept the model's terms first", - "Try again": "Try again", - "Try the graphics card again": "Try the graphics card again", - "Turn off": "Turn off", - "Unknown licence": "Unknown licence", - "Use": "Use", - "Waiting to start…": "Waiting to start…", - "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.", - "cached picture": "cached picture", - "cached pictures": "cached pictures", - "commercial use allowed": "commercial use allowed", + "Back to this person": "この人物に戻る", + "AI Denoise": "AIノイズ除去", + "AI Denoise Model": "AIノイズ除去モデル", + "AI Denoise is off: press Use on a model.": "AIノイズ除去はオフです:モデルの「使用」を押してください。", + "AI Denoise needs a model. Install one in Settings.": "AIノイズ除去にはモデルが必要です。設定でインストールしてください。", + "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.": "denoise-model.json を横に置いた独自の ONNX モデルをインストールしてください。このビルドにはモデルのダウンロードがありません。", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "ノイズ除去した画像はまだ作成されておらず、自動では作成されません(設定、AIノイズ除去)。", + "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…": "次にAIノイズ除去を行います…", + "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.": "AIノイズ除去の適用量が 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…": "AIノイズ除去を設定…", + "Speed": "速度", + "Starting…": "開始中…", + "Tested and works on this computer": "このコンピューターでテスト済み、動作します", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "ノイズ除去モデルをインストールして使用中です:ディテールの「AIノイズ除去の適用量」スライダーが使えるようになりました", + "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.)": "このビルドにはノイズ除去モデルの保存場所がないため、AIノイズ除去は使えません。(デスクトップ版にはあります。)", + "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": "in use", - "installed": "installed", - "makes AI Denoise available": "makes AI Denoise available", - "non-commercial use only": "non-commercial use only", - "photo has its denoised picture.": "photo has its denoised picture.", - "photo waiting": "photo waiting", - "photos have their denoised picture.": "photos have their denoised picture.", - "photos waiting": "photos waiting", - "starts using it for AI Denoise": "starts using it for AI Denoise", - "terms unclear": "terms unclear", - "the graphics card": "the graphics card", - "the graphics card is not used": "the graphics card is not used", - "Cancel Denoise": "Cancel Denoise", - "Cancel Denoise Model Download": "Cancel Denoise Model Download", - "Choose Denoise Model": "Choose Denoise Model", - "Clear Denoise Cache": "Clear Denoise Cache", - "Denoise Model Downloads": "Denoise Model Downloads", - "Denoise Photos": "Denoise Photos", - "Denoise Pump": "Denoise Pump", - "Denoise Status": "Denoise Status", - "Download Denoise Model": "Download Denoise Model", - "Remove Denoise Model": "Remove Denoise Model", - "Test Denoise Model": "Test Denoise Model", - "AI Denoise is enabled on the requesting photo": "AI Denoise is enabled on the requesting photo", - "After installation, AI Denoise will turn on for the requesting photo.": "After installation, AI Denoise will turn on for the requesting photo.", - "Cancel pending action": "Cancel pending action", - "Install a denoise model to enable AI Denoise on this photo": "Install a denoise model to enable AI Denoise on this photo", - "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", + "in use": "使用中", + "installed": "インストール済み", + "makes AI Denoise available": "AIノイズ除去を使えるようにします", + "non-commercial use only": "非商用利用のみ", + "photo has its denoised picture.": "枚の写真のノイズ除去画像があります。", + "photo waiting": "枚の写真が待機中", + "photos have their denoised picture.": "枚の写真のノイズ除去画像があります。", + "photos waiting": "枚の写真が待機中", + "starts using it for AI Denoise": "AIノイズ除去に使い始めます", + "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": "要求した写真でAIノイズ除去が有効になりました", + "After installation, AI Denoise will turn on for the requesting photo.": "インストール後、要求した写真でAIノイズ除去がオンになります。", + "Cancel pending action": "保留中の操作をキャンセル", + "Install a denoise model to enable AI Denoise on this photo": "この写真でAIノイズ除去を使うには、ノイズ除去モデルをインストールしてください", + "Pending photo action cancelled": "保留中の写真操作をキャンセルしました", + "Pending photo action cancelled because its library or photo changed": "ライブラリまたは写真が変更されたため、保留中の写真操作をキャンセルしました", + "ONNX model": "ONNX モデル", + "commercial use subject to GPL conditions": "商用利用は GPL の条件に従う", "Decrease Rating": "評価を下げる", "Increase Rating": "評価を上げる", - "Click to type a value": "クリックして値を入力", + "Click to type a value, drag to change it": "クリックして値を入力、ドラッグして変更", "Angle": "角度", "Video": "動画", "Copyrighted": "著作権あり", @@ -1821,6 +1855,15 @@ "Keyword List": "キーワードリスト", "Filter Keywords": "キーワードを絞り込む", "No keywords yet": "キーワードはまだありません", + "Show fewer": "表示を減らす", + "Whole names": "パス全体", + "Grouped by parent": "親キーワードでグループ化", + "Keywords of the selection": "選択した写真のキーワード", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "グループ化:同じキーワードの中にあるキーワードは、その名前を示す行にまとめられ、長いリストと候補は短く表示されます。", + "Show every keyword of the selected photos": "選択した写真のキーワードをすべて表示", + "new keyword": "新しいキーワード", + "Return creates this keyword and gives it to the selected photos": "Return キーでこのキーワードを作成し、選択した写真に付けます", + "Separate several keywords with commas. Return gives them to the selected photos.": "複数のキーワードはカンマで区切ります。Return キーで選択した写真に付けます。", "No keywords match": "一致するキーワードはありません", "Keyword Name": "キーワード名", "Synonyms": "同義語", @@ -1959,7 +2002,343 @@ "not added to Apple Photos (cancelled)": "Appleの写真には追加されませんでした(キャンセル)", "The first time, macOS asks whether LightCraft may control Photos.": "初回は、LightCraftに写真の操作を許可するかmacOSが確認します。", "Adding to Apple Photos": "Appleの写真に追加中", + "\"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`)、ヘルプ → システム情報、設定 → パフォーマンスに GPU が使われない", + "(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": "(issue #94):Web ページからの HTTP リクエストが本文でコマンドを送れなくなりました。1 行の上限は", + "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 MiB、接続数の上限は 16 です。", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 ドライバーなど):Windows ではウィンドウと GPU 描画に 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": "ライブラリは一度に 1 つのプログラムでのみ開けます(issue #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 ドライブでウィンドウが固まらなくなりました(issue #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "SAM 3 による AI マスク(マスクパネルの「オブジェクト」と「説明」):オブジェクトをクリックして選択(⌥ クリックで", + "Add Face Model…": "顔モデルを追加…", + "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.": "トラックログから自動タグ付け:GPX トラックログから撮影日時で照合した GPS 位置を写真に付けます。", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "ブラウザー版(試験的)、ライブラリを安全に保つ(issue #107):ファイル → ライブラリをバックアップ… でカタログと", + "Cancel Face Model Download": "顔モデルのダウンロードをキャンセル", + "Cancel Pending Photo Action": "保留中の写真操作をキャンセル", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "EOS 7D、50D、60D、550D、600D、1200D、1300D、5D Mark II、1D Mark IV の Canon CR2 写真(およびその他", + "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(例:EOS R6 Mark III で 6960 × 4640)を表示し、", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "「プレビューのみ」で開いていた Canon EOS R5 Mark II の C-RAW ファイル(撮影シーンによっては半数以上", + "Choose Face Recognition Model": "顔認識モデルを選択", + "Choose a model below (Use) to start.": "下のモデルを選んで(使用)開始してください。", + "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 としてコピー、写真結合、スマートプレビューは最終ファイルに直接書き込まなくなりました(issue", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "著作権の状態、権利の使用条件、著作権情報 URL を情報パネル、メタデータプリセット、書き出しで扱えます。", + "Detect Faces": "顔を検出", + "Detect Faces (Photo menu) needs it: a small one-time download.": "「顔を検出」(写真メニュー)に必要です:一度だけの小さなダウンロードです。", + "Download Face Model": "顔モデルをダウンロード", + "Drag Crop Handle": "切り抜きハンドルをドラッグ", + "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 のコピーをスタックにし、戻ったときに更新します。", + "Evaluate Face Recognition": "顔認識を評価", + "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": "書き出しと描画が写真の元画像を上書きすることはありません(issue #93):写真と同じフォルダーへ", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "GPU が原因で書き出しが真っ黒になることはありません(issue #78):GPU 描画がデバイスの", + "Face Model Downloads": "顔モデルのダウンロード", + "Face Models": "顔モデル", + "Face Recognition On/Off": "顔認識のオン/オフ", + "Face detector": "顔検出器", + "Face recognition": "顔認識", + "Faces": "顔", + "Faces of a Person": "人物の顔", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "書き出しとカードからの読み込みが速くなり、作り直せないものは同じように保護されます(issue #134):書き出し、", + "Go back to listing them by name": "名前順の一覧に戻る", + "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": "読み込み → コピーは「移動」と同様にすべてのコピーを検証します(issue #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.": "読み込み:任意のフォルダーへコピー、日別/月別/1 フォルダー、読み込み時の名前変更、メタデータプリセット、DNG としてコピー。", + "Index Faces": "顔をインデックス", + "Index Faces in the Background": "バックグラウンドで顔をインデックス", + "Inspect Face Model File": "顔モデルファイルを調べる", + "Install Face Model": "顔モデルをインストール", + "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` のまま、", + "Library Folders": "ライブラリのフォルダー", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 以前ではこれを開けません(「unsupported format … v2」)。", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "Vulkan ドライバーが壊れている Windows PC で、LightCraft が起動時にクラッシュしなくなりました(issue #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` コレクションをプリセットとして読み込みます(コレクションごとにグループ化)。スライダーは", + "Name Face": "顔に名前を付ける", + "Name Faces": "顔に名前を付ける", + "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 は、Sony ARW と同様にカメラ自身の JPEG に合わせた色と階調から始まります。くすんだ", + "Nikon splits into two differently compressed halves still use the preview for now.)": "(2 つの異なる圧縮に分かれた 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)、カラーラベルセット。", + "Recognise faces (experimental)": "顔を認識(試験的)", + "Recognition models": "認識モデル", + "Remove Face Model": "顔モデルを削除", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "フォルダー名を変更… とフォルダーを移動…(ローカル)を取り消し/やり直しできます(issue #97):フォルダーはディスク上で", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "「写真の名前を変更」は、大文字小文字だけが変わる場合に別の写真を上書きしません(issue #95):大文字小文字を区別する", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "「写真の名前を変更」は、失敗後に元に戻せなかったファイルを報告します(issue #105)。例:ネットワーク共有が", + "Reorder Album": "アルバムを並べ替え", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "他のアプリが書いた XMP サイドカーへのメタデータ保存で、そのファイルを置き換えなくなりました(issue #92):LightCraft は自分の", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "設定ファイルが黙ってリセットされることはありません(issue #103):壊れた `prefs.json`、`presets.json`、`view.json` は", + "Show Person": "人物を表示", + "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": "2017 年頃より前の Sony ARW(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 にデコードするようになりました。", + "Sort Albums A–Z": "アルバムを A–Z で並べ替え", + "Sort Albums by Name": "アルバムを名前順に並べ替え", + "Sort Contents A–Z": "内容を A–Z で並べ替え", + "Suggest Names for Faces": "顔の名前を提案", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "名前を付けた顔から、写真に写っている人を提案します。すべてこのコンピューター内で処理します。", + "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 の世代。それらは", + "Test Face Model": "顔モデルをテスト", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "カタログにフォーマットのバージョンが付きました(issue #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": "サムネールキャッシュは自分のファイルだけを数えて削除します(issue #98):", + "This build cannot run recognition models: they can be added and chosen, not used.": "このビルドでは認識モデルを実行できません:追加や選択はできますが、使えません。", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "このビルドには顔モデルの保存場所がないため、モデルの一覧は使えません。(デスクトップ版にはあります。)", + "Unnamed Faces": "名前のない顔", + "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": "保存せずに終了(issue #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": "その後(issue #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": "と読み込んだすべての写真を 1 つの zip としてダウンロードし、ファイル → バックアップからライブラリを復元… で元に戻せます(現在のライブラリ", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "ウィンドウが固まることもありません。これは任意で、LightCraft には含まれません(Meta の SAM License、約 3.4 GB):", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "`<name>.corrupt-<time>` として保存され、その旨が通知されます。読めないファイル(例:別のプログラムがロック中)はそのままにして", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "指定した場合のみ読み込みます。`LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off`(または wgpu の `WGPU_BACKEND`。以前は GPU 描画で", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "これらをまとめたバンドル。フォルダー全体を一度に、パックごとにグループ化して読み込みます。プリセット内のマスクも含まれます。", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "カメラ設定が焼き込まれなくなりました。「プレビューのみ」として読み込み済みの写真は再読み込みで切り替わります。(ファイルが", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "変更。このような断片を含むカタログもすべて読み込めます。カタログのログを書き込めない状態で終了すると", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "変更を保存できなかった場合は一度再試行し、「もう一度保存」「このまま終了」「キャンセル」を尋ねます。", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "コントロールチャンネルや MCP、結合プレビューのパス、`lightcraft-cli render IMG.jpg -o IMG.jpg` は分かりやすい", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "カードから読み込んだ内容と照合します。失敗したコピーや内容が異なるコピーは削除され、失敗した", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "Nikon や Sony の raw と同様に、カメラ自身の JPEG に合わせた色と階調から始まり、カメラで設定した縦横比で", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "コピーが検証されてライブラリに入ったあとです。重複ファイルと失敗したファイルは元の場所に残ります。", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "データはまだデコードされないため、プレビューのみのままです。以前に読み込んだ CR3 は、写真 → ディスクから再読み込み(", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "端は切り取られ、「12 ビット非圧縮」のファイルはクリップされなくなりました。RX100 シリーズの描画は", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "カメラが書き出すすべての形式。以前はほとんどの機種がプレビューのみで開いていました:GH1–GH5、G、GX、GF、GM、FZ、LX、", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "書き出し。カタログ、XMP サイドカー、設定、DNG、結合、スマートプレビュー、読み込みで作るコピーは引き続き", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "を書き込み、残り(例:そのアプリの現像設定や編集履歴)はバイト単位でそのまま保持します。", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "ファイル(これらの機種では Sony がメーカーノートの暗号化されたデータにのみ保存)から読み取り、右端の数列の余白は", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "AI マスクを初めて使うとき、LightCraft はダウンロード前に確認し、進行状況を表示し、キャンセルと再開ができ、", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "セッション中はそのままにし、既定値で上書きしません。アプリの設定(`ui.json`。", + "gets -1, -2… instead of being replaced.": "-1、-2… が付き、置き換えられません。", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "緑がかった中性の描画(issue #150)の代わりに。ホワイトバランスは撮影時の見た目を基準に調整されます。12 ビット NEF", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "開いている場合(このコンピューターでもフォルダーを共有する別のコンピューターでも)、誰が開いているかを表示します(「すでに LightCraft で開いています(プロセス", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "復元方法、または完全に削除して新たに読み込む方法を表示します。(写真を最初からやり直すには、編集をリセット、", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "これまで無視されていた)でグラフィックスバックエンドを選びます。`off` は CPU で描画します。GPU はウィンドウの", + "immutable, and HOSTING.md describes the actual build.": "変更されないようになり、HOSTING.md が実際のビルドを説明しています。", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "読み込み。「読み込み完了」はカードをフォーマットする前にコピーが正常であることを意味します。使用中の名前は", + "imported as \"preview only\" switch over on Reload.": "「プレビューのみ」として読み込み済みの写真は再読み込みで切り替わります。", + "imported earlier switch over with Photo ▸ Reload from Disk.": "以前に読み込んだものは 写真 → ディスクから再読み込み で切り替わります。", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "低速な内蔵 GPU がウォッチドッグでリセットされないよう、短い単位で処理します。`ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "不完全な場合は CPU でやり直します。どちらでも同じ画像になります。処理は GPU に", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "保持されます)。保存の失敗(ストレージ不足)では未保存の警告を表示して再試行し、保存できない写真は", + "is refused and nothing is overwritten.": "拒否され、何も上書きされません。", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "表示後に起動し、設定 → パフォーマンス → 描画に GPU を使用 がオンのときだけです。LightCraft が", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "追加されません。2 つ目のタブは 1 つ目を上書きせずにメッセージを表示し、`?reset` は先に確認し、ページは", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "もう一方から奪いません(issue #92)。「見つからない写真を探す」は名前を変えたファイルも内容で見つけ、", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "写真と XMP サイドカーも戻り、写真は再びそこを指します。元の場所に別のものがある場合、取り消しは", + "layout is read from each file instead of assumed.": "配列は決め打ちせず各ファイルから読み取ります。", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "DNG を残さず raw を保持します。スマートプレビューも同じ方法で書き込まれます。壊れたもの(クラッシュで途切れた、", + "library locked: the lock is the operating system's and goes away with the program.": "ライブラリのロック:ロックはオペレーティングシステムのもので、プログラムの終了とともに解除されます。", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "ライブラリを記憶)はアトミックに書き込まれ、終了時だけでなく別のライブラリを開いたときにも保存されます。", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "「重複をスキップ」と表示するだけではなくなりました:サイドパネルで「最近削除した写真」を開いて写真を選択し、", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "終了時にすべてを失っていました。一時セッションでは常にバナーを表示し、ライブラリには書き込みません。", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "メモリーを使い切る、GPU のバッファー上限を超える、ドライバーのエラーやリセットに遭う、または", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "メッセージで拒否され、元画像はバイト単位でそのまま残ります。通常の以前の書き出しは、指定すれば引き続き上書きされます。", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "一括処理の途中:エラーに一覧(旧 → 新)を表示し、ライブラリは新しい名前を指します(取り消し可能な部分的な名前変更)。", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "緑と青の行から始まるセンサーの機種)がマゼンタにならなくなりました(issue #85):カラーフィルターの", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "名前(ファイルを置き換えません)。その後にだけ写真を再リンクするか raw のコピーを削除します。書き込みに失敗すると", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "途中で切れたファイルを残しません。「元画像」書き出しの XMP サイドカーも「ファイルが存在する場合」の選択に従います。", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "カメラに埋め込まれた JPEG ではなく raw データから現像するようになったため、カメラで設定したモノクロなどのピクチャースタイルが", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "読み込みの確認後にカードで変更されたファイルは、古い情報のまま読み込まずに報告します。書き出しパスの確認では", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "1 ファイルずつ。いつでも書き出し直せるもので、USB ドライブや NAS ではファイルごとの同期が大きな", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "同じ名前・同じサイズの似たファイルより優先し、区別できない写真は推測せずにスキップ(して報告)します。", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "部分を除外)、選択するものを入力(「空」「赤い車」「車, 道路」)。どちらも他のマスクと組み合わせられ、", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "埋め込み JPEG プレビューを使わずに、RAW 編集とフル解像度の書き出しを保ちます。", + "read in the wrong units.": "誤った単位で読み取られていました。", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "カードを 3 回目に読み込む代わりに(「移動」は元ファイルを削除する前に引き続きバイト単位で比較します)。変更された", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "描画とスクリーンショットは引き続き一時ファイルに書き込んで名前を変えて置き換えますが、ディスクへの強制書き込みはしなくなりました", + "save goes through.": "保存が完了します。", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "シーン)は、カメラの他の raw と同様に、すべての編集ツールを使ってセンサーデータから現像するようになりました。写真", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "兄弟機種)が鮮やかな緑色で開かなくなりました(issue #148):撮影時のホワイトバランスと黒レベルを", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "有効な XMP でないサイドカーは、まず `<name>.xmp.bak-<time>` にコピーします。既定のファイル名ベースの命名では、raw と", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "そのため、見つからない写真として表示されません。raw と JPEG の組の片方の名前を変えると、共有の `IMG_0001.xmp` をコピーし、", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "GPU を起動すると、次回起動時は GPU 描画をオフにして始め、元に戻す方法を表示します。", + "still saves your queued changes in the closing snapshot.": "閉じるときのスナップショットに、キューにある変更を保存します。", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "同期しました。読み込み → コピーは、カードを走査したときのコンテンツハッシュと各コピーを照合するようになりました。", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG は `IMG_0001.JPG.xmp` を使います。", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "「ローカル」のフォルダーツリーと自動読み込みもワーカースレッドでディスクを読みます。読み込みの進行状況ウィンドウにキャンセルがあります。", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "写真メニューと写真のコンテキストメニュー)でフルサイズのプレビューを取り込み、不足していたカメラのメタデータを補います", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "カメラ。センサーの欠陥画素は補間されます。`.rwl` と `.raw` ファイルも読み込みます。すでに読み込み済みの写真", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "同じ名前で「上書き」(または前回の設定で書き出しで繰り返す場合)、コントロールからの正確な出力パス", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "フォルダーを 30 日間閲覧していない場合は忘れます。ファイルとサイドカーは残り、フォルダーを閲覧すると", + "to the camera's own JPEG.": "カメラ自身の JPEG に近づきました。", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "変更しません。古いバージョンが一部を壊れたログとして読むことはなくなりました。このバージョンでライブラリを開いたあとは、", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "使われない理由(例:llvmpipe のようなソフトウェアアダプターをスキップ)と、直近の描画がフォールバックした理由を表示します。", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "ボリューム(Linux、大文字小文字を区別する APFS)では `IMG_1.JPG` の横の `img_1.JPG` は別の写真で、名前を変えたほうは", + "were missing, without touching anything already set.": "既に設定されているものには触れません。", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "ブラウザーがライブラリを削除する可能性があるとき。ホスティング:サンプルのキャッシュヘッダーは、(ハッシュなしの)ファイルを", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "元画像の有無はワーカースレッドで確認します(グリッドのサムネール、情報パネル、写真メニュー、見つからない", + "with a counterpart here come along, the rest is listed.": "対応するものがあれば取り込み、残りは一覧に表示します。", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "新しい LightCraft で書かれたものは「このライブラリは新しいバージョンの LightCraft で書かれました」として拒否され、", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "新しい LightCraft で書かれた)、LightCraft はその旨と理由を示し、「再試行」「別のライブラリを選択…」「続行」を提示します", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ ドラッグで角度補正、⇧G ガイド付き遠近補正、変形中のグリッド。", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "グラフィックプロセッサが見つかりません。LightCraftはCPUで描画するため、かなり遅くなります。詳細は「ヘルプ → システム情報」を参照してください。", "On NixOS, start it from `nix develop`.": "NixOSでは `nix develop` から起動してください。", - "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。" + "Files with the same content as another file in this import: only the first is imported.": "この読み込み内の別のファイルと内容が同じファイルです。最初のファイルだけが読み込まれます。", + "Lightroom Catalog Import": "Lightroomカタログの読み込み", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "これらの写真はメモリ内にのみあります。ディスク上のライブラリを開き、カタログを再度読み込んで保存してください。", + "Show in Library": "ライブラリに表示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroomカタログは開いているライブラリに読み込まれます。別のカタログフォルダーは作成されません。", + "Destination library": "読み込み先ライブラリ", + "Playback": "再生", + "Layout": "レイアウト", + "Link all margins": "すべての余白をリンク", + "Margins": "余白", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "余白はスライドの短辺に対する割合なので、どの画面でもスライドは同じように見えます。", + "Show guides": "ガイドを表示", + "Aspect preview": "縦横比プレビュー", + "Zoom to fill frame": "ズームして枠を埋める", + "Stroke border": "境界線", + "Colour": "カラー", + "Cast shadow": "ドロップシャドウ", + "Offset": "オフセット", + "Backdrop": "背景", + "Background colour": "背景色", + "Colour wash": "カラーウォッシュ", + "Background image": "背景画像", + "(not in the library)": "(ライブラリにありません)", + "Use selected photo": "選択した写真を使用", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "写真を選択してからこれをオンにすると、各写真の背後をその写真で埋めます。", + "Reset layout": "レイアウトをリセット", + "Select a photo to preview its slide": "写真を選択するとスライドをプレビューできます", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "選択した写真をスライドとして表示します。再生では画面全体に表示され、縦横比プレビューはこのプレビューだけを変更します。", + "Overlays": "オーバーレイ", + "Identity plate": "ID プレート", + "Your name or studio": "あなたの名前またはスタジオ名", + "Anchored to": "基準", + "Slide": "スライド", + "Render behind image": "画像の背後に表示", + "Rating stars": "レーティングの星", + "Text overlays": "テキストオーバーレイ", + "Add text overlay": "テキストオーバーレイを追加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "トークン: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。その他の文字は入力どおりに表示されます。", + "Use export watermark": "書き出しの透かしを使用", + "The export watermark, drawn on the photo and sized relative to it.": "書き出しの透かし。写真の上に、写真に対する比率で描かれます。", + "Titles": "タイトル", + "Intro screen": "イントロ画面", + "Ending screen": "エンディング画面", + "Shows": "表示内容", + "Colour only": "カラーのみ", + "Override colour": "カラーを上書き", + "Own duration (off: the slide duration)": "個別の再生時間(オフ:スライドの再生時間)", + "Duration": "再生時間", + "Title text": "タイトルテキスト", + "Intro": "イントロ", + "Ending": "エンディング", + "End": "終了", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "イントロは最初の写真の前に 1 回再生され、エンディングは繰り返しがオフのとき最後の写真の後に再生されます。ID プレートはオーバーレイで設定したものです。タイトルはスライドと同様にフェードし、ランダム順でも位置は変わりません。", + "This screen is off: turn it on under Titles to play it.": "この画面はオフです。再生するにはタイトルでオンにしてください。", + "The intro screen, before the first photo.": "イントロ画面(最初の写真の前)。", + "The ending screen, after the last photo.": "エンディング画面(最後の写真の後)。", + "Templates": "テンプレート", + "Built-in": "組み込み", + "Your templates": "自分のテンプレート", + "None yet: save the current settings as a template below.": "まだありません。下で現在の設定をテンプレートとして保存できます。", + "Before the last template": "最後のテンプレートの前", + "Previous settings": "以前の設定", + "New template name": "新しいテンプレート名", + "Save as Template": "テンプレートとして保存", + "Import…": "読み込み…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "自分のテンプレートには設定フォルダーが必要ですが、このビルドにはありません。組み込みテンプレートは使えます。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "テンプレートをクリックすると適用、ポインターを合わせると選択中の写真でプレビューします。自分のテンプレートは右クリックで更新・名前変更・書き出し・削除できます。", + "Import Slideshow Templates": "スライドショーテンプレートを読み込み", + "Export Slideshow Template": "スライドショーテンプレートを書き出し", + "Slideshow Templates": "スライドショーテンプレート", + "Plain Black": "シンプルブラック", + "Gallery Wall": "ギャラリーの壁", + "Edge to Edge": "全面表示", + "Cinema Wide": "シネマワイド", + "Shooting Data": "撮影データ", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "各写真を黒地にできるだけ大きく表示し、短いクロスフェードでつなぎます。上には何も描きません。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "明るいグレーの壁に白いマットと柔らかな影。写真の下にキャプションとレーティング。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "各写真を画面全体に合わせて切り抜き、ゆっくりクロスフェードします。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "上下に黒帯のある16:9の画面、隅にタイトル、黒を経由するフェード、エンディングカード。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "暗いスレート色の背景。各写真の下に露出・ISO・焦点距離、その下にカメラとレンズ。", + "Exporting slideshow": "スライドショーを書き出し中", + "Export Slideshow…": "スライドショーを書き出し…", + "Export Slideshow": "スライドショーを書き出し", + "Export Slideshow: folder for the JPEG files": "スライドショーを書き出し:JPEG ファイルのフォルダー", + "Frame rate": "フレームレート", + "Container": "コンテナー", + "Export PDF…": "PDF を書き出し…", + "Export JPEG…": "JPEG を書き出し…", + "Export Video…": "ビデオを書き出し…", + "Automatically show full screen": "自動的にフルスクリーンで表示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "スライドごとに 1 ページ(長辺のピクセル数 = ポイント数)。ビューアーのプレゼンテーションモード用にスライドの表示時間とフェードを含みます。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "番号付きの JPEG ファイル(name-001.jpg…)を、次の手順で名前を付ける新しいフォルダーに書き出します。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 ビデオ。各スライドを表示時間のあいだフェード付きで表示します。音声なし:音楽はまだ書き出されません。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "スライドにはスライドショーのレイアウト、オーバーレイ、イントロ画面とエンディング画面が表示順で使われます。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "表示中の写真を上の設定でスライドにします。進行状況はアクティビティ一覧に表示されます。", + "Checking raw files": "RAWファイルを確認中", + "1 raw can now be developed from its raw data": "1枚のRAWをRAWデータから現像できるようになりました", + "This raw is now developed from its raw data": "このRAWはRAWデータから現像されるようになりました", + "This raw variant still can't be decoded": "このRAWの形式はまだデコードできません", + "No lens data embedded in this file (DNG lens opcodes only).": "このファイルにレンズ情報は含まれていません(DNGのレンズ情報のみ対応)", + "Export to": "書き出し先", + "Specific folder": "指定フォルダー", + "Same folder as original photo": "元の写真と同じフォルダー", + "Tidying the thumbnail cache": "サムネールキャッシュを整理中", + "Reset all edits (Cmd+Shift+R)": "すべての編集をリセット(⌘⇧R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "スマートアルバムは自動で更新されます。ルールを変更するか、写真 → 写真を削除を使ってください", + "LightCraft stopped": "LightCraftが終了しました", + "Show Focus Point": "フォーカスポイントを表示", + "Zoom to Focus Point": "フォーカスポイントにズーム", + "Focus": "フォーカス", + "Focus point": "フォーカスポイント", + "Wide": "ワイド", + "Center": "中央", + "Flexible Spot": "フレキシブルスポット", + "Zone": "ゾーン", + "Expanded Flexible Spot": "拡張フレキシブルスポット", + "Custom AF Area": "カスタムAFエリア", + "Face tracking": "顔追尾", + "Lock On AF": "ロックオンAF", + "Multi": "マルチ", + "Spot": "スポット", + "Tracking": "トラッキング", + "Face Tracking": "顔トラッキング", + "Animal Eye Tracking": "動物瞳トラッキング", + "Human Eye Tracking": "人物瞳トラッキング", + "Read Focus Points": "フォーカスポイントを読み込む", + "Reading focus points": "フォーカスポイントを読み込み中", + "Click to type a value": "クリックして値を入力", + "Run face models on the CPU only": "顔モデルを CPU だけで実行", + "Detection": "検出", + "Recognition": "認識", + "Not loaded yet": "未読み込み", + "Checking the GPU…": "GPU を確認中…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "顔モデルが GPU を使うのは、同じ結果をより速く出せると確認できた場合だけです。", + "Checking the GPU for faces": "顔用の GPU を確認中" } diff --git a/crates/ui-egui/locales/ka-formats.json b/crates/ui-egui/locales/ka-formats.json new file mode 100644 index 000000000..9b08b0d5d --- /dev/null +++ b/crates/ui-egui/locales/ka-formats.json @@ -0,0 +1,299 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} რამდენიმე მსგავსი ფაილით: გამოიყენეთ „მოძებნა“)", + " ({} in Recently Deleted)": " ({} ახლახან წაშლილებში)", + " · {:.0}% clipped": " · {:.0}% მოკვეთილია", + " · {failed} couldn't be rendered": " · ვერ დარენდერდა: {failed}", + " · {queued} waiting": " · რიგშია: {queued}", + " · {skipped} skipped (file exists)": " · გამოტოვებულია: {skipped} (ფაილი უკვე არსებობს)", + " · {} duplicate{} skipped": " · გამოტოვებული დუბლიკატი: {}{:.0}", + " · {} failed: {}": " · ვერ მოხერხდა: {}: {}", + " · {} moved": " · გადატანილია: {}", + " · {} not readable": " · ვერ იკითხება: {}", + " · {} original{} left at the source": " · წყაროში დატოვებული ორიგინალი: {}{:.0}", + " · {} restored from Recently Deleted": " · ახლახან წაშლილებიდან აღდგენილია: {}", + " — 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}", + " — not carried over: {}{}": " — არ გადავიდა: {}{}", + "+{n} more": "+ კიდევ {n}", + "+{} more": "+ კიდევ {}", + ", {failed} file{} not readable": ", ვერ იკითხება ფაილი: {failed}{:.0}", + ", {} label": ", იარლიყი: {}", + ", {} star{}": ", ვარსკვლავი: {}{:.0}", + "1 task is still running: {names}. Quitting stops it.": "ჯერ კიდევ მიმდინარეობს 1 ამოცანა: {names}. გასვლა მას შეწყვეტს.", + "; {kept} already had a location": "; მდებარეობა უკვე ჰქონდა: {kept}", + "; {n} gained synonyms": "; სინონიმები დაემატა: {n}", + "; {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} keyword{}": "დაემატა საკვანძო სიტყვა: {n}{:.0}", + "Added {n} photo{} to My Photos": "ჩემს ფოტოებს დაემატა ფოტო: {n}{:.0}", + "Added {n} photo{} to “{}”": "„{1}“-ს დაემატა ფოტო: {n}{0:.0}", + "Added {what} to {name}": "{what}: დაემატა {name}-ს", + "Added “{keyword}” to {n} photo{}": "„{keyword}“ დაემატა ფოტოს: {n}{:.0}", + "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)": "ავტომატური ({} 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 Keyword Tag Inside “{name}”…": "საკვანძო სიტყვის შექმნა „{name}“-ში…", + "Create {n} Virtual Copies": "ვირტუალური ასლების შექმნა: {n}", + "Creates {} stacks from {} of {scope}": "შეიქმნება დასტა: {} — {}-დან ({scope})", + "Decoder: {reason}": "დეკოდერი: {reason}", + "Delete \"{label}\"": "„{label}“-ის წაშლა", + "Delete \"{label}\" (and the mask)": "„{label}“-ის წაშლა (ნიღაბთან ერთად)", + "Delete the keyword “{name}”?": "წაიშალოს საკვანძო სიტყვა „{name}“?", + "Delete {n} Photos": "ფოტოების წაშლა: {n}", + "Delete “{current}”": "„{current}“-ის წაშლა", + "Denoising {name}: {done} of {total} tiles{}": "{name}-ის ხმაურის მოცილება: ფრაგმენტი {done} / {total}{}", + "Discard Smart Previews ({scope})": "ჭკვიანი წინასწარი ხედების მოცილება ({scope})", + "Done ({what}): {done} photo{plural}": "მზადაა ({what}): ფოტო: {done}{plural:.0}", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "ჩამოიტვირთება {host}-დან. მიღებისა და შემოწმების შემდეგ LightCraft მას დააყენებს, {after}.", + "Duplicate \"{label}\"": "„{label}“-ის დუბლირება", + "Example: {example}": "მაგალითი: {example}", + "Exported {n} keyword{}": "ექსპორტირებულია საკვანძო სიტყვა: {n}{:.0}", + "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}", + "Filtered: {label}": "გაფილტრულია: {label}", + "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}", + "Import warnings ({n}):\n{warnings}": "იმპორტის გაფრთხილებები ({n}):\n{warnings}", + "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}", + "In line for AI Denoise ({ahead} ahead)…": "AI ხმაურის მოცილების რიგში (წინ არის: {ahead})…", + "Insert at the cursor (also written {})": "კურსორის ადგილას ჩასმა (ასევე იწერება როგორც {})", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "მოიხსნება ფოტოებიდან ({count}){:.0} მის ქვემოთ მყოფ საკვანძო სიტყვებთან ერთად. დაბრუნება მას აღადგენს.", + "Label Set: {name}": "იარლიყების ნაკრები: {name}", + "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-ხედს, რომელიც უკვე შეიცავს კამერის გამოსახულების სტილს (მაგ., მონოქრომულს) და თეთრის ბალანსს.", + "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 geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Guided Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; Upright-ის შენახული გარდაქმნა არ არის მხარდაჭერილი.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; შენახული გარდაქმნა იგნორირებულია, რადგან Upright გამორთულია.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "{path}-ისთვის Lightroom-ის გეომეტრია არ შენარჩუნდა; {mode} ხელახლა გამოითვლება.", + "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). ხატულები: ორიგინალური.", + "Making the denoised picture… {}%": "იქმნება ხმაურმოცილებული სურათი… {}%", + "Matches: {}": "დამთხვევა: {}", + "Merging… {stage} {:.0}%": "გაერთიანება… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "გადავიდეს {what} ახლახან წაშლილებში?", + "Moved {} photo(s)": "გადატანილია ფოტო: {}", + "Moved; {} photo(s) relinked": "გადატანილია; ხელახლა დაკავშირდა ფოტო: {}", + "Named ({named_n})": "სახელიანი ({named_n})", + "New Folder in “{name}”": "ახალი საქაღალდე „{name}“-ში", + "On {have} of {of} selected photos": "{of} მონიშნული ფოტოდან {have}-ზე", + "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 · {}": "საცდელი ჩვენება · {}", + "Put inside “{parent}”": "„{parent}“-ში მოთავსება", + "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}”": "„{name}“-ის მოშორება", + "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}": "რენდერი CPU-ზე: {why}", + "Replace {} with:": "{}-ის ჩანაცვლება:", + "Reset {name}": "ჩამოყრა: {name}", + "Reset {} Channel": "არხის ჩამოყრა: {}", + "Restore {}": "აღდგენა: {}", + "Restore {} Photo{}": "ფოტოს აღდგენა: {}{:.0}", + "Restored {} photo{} from Recently Deleted": "ახლახან წაშლილებიდან აღდგა ფოტო: {}{:.0}", + "Running on the processor: {}.": "მუშაობს პროცესორზე: {}.", + "Running on {}.": "მუშაობს: {}.", + "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} / {}", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "აჩვენებს მოდელის პირობებს, შემდეგ ჩამოტვირთავს {}-ს {host}-დან, დააყენებს და დაიწყებს მის გამოყენებას. სხვა არაფერი იგზავნება.", + "Smart album: {}": "სმარტ-ალბომი: {}", + "Tagged {n} photo{} from the tracklog": "მარშრუტის ჟურნალით მოინიშნა ფოტო: {n}{:.0}", + "The SAM 3 download failed: {e}": "SAM 3-ის ჩამოტვირთვა ვერ მოხერხდა: {e}", + "There is a keyword set “{name}” already": "საკვანძო სიტყვების ნაკრები „{name}“ უკვე არსებობს", + "There is a “{name}” at the top level already. Merge the two?": "ზედა დონეზე „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?", + "Trained on: {provenance}": "გაწვრთნილია: {provenance}", + "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.": "ვერსია {} — სუფთა ოთახის მეთოდით შექმნილი, სუფთა Rust-ზე დაწერილი ფოტობიბლიოთეკა და RAW-დამმუშავებელი.", + "Version: {}": "ვერსია: {}", + "Virtual copy “{name}” of {of}": "{of}-ის ვირტუალური ასლი „{name}“", + "Working on {what} for {} photos…": "{what}: მუშავდება ფოტოები ({})…", + "XMP adjustments were not imported for {path}: {fields}.": "{path}-ისთვის XMP-ის კორექციები არ შემოვიდა: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "{path}-ისთვის XMP პროფილი „{profile}“ არ გამოიყენება; ფერები შეიძლება განსხვავდებოდეს Lightroom-ისგან.", + "none (CPU rendering): {why}": "არცერთი (რენდერი CPU-ზე): {why}", + "not added to Apple Photos: {e}": "არ დაემატა Apple Photos-ს: {e}", + "the {n} selected photos": "მონიშნული ფოტოები ({n})", + "the {} photos in view": "ხედში არსებული ფოტოები ({})", + "these {count} photos": "ეს ფოტოები ({count})", + "{:.1} MB": "{:.1} MB", + "{count} smart preview{} · {:.1} MB": "ჭკვიანი წინასწარი ხედი: {count}{:.0} · {:.1} MB", + "{done} of {total}": "{done} / {total}", + "{done} of {total} MB": "{done} / {total} MB", + "{file} · {size}": "{file} · {size}", + "{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:02}", + "{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} opened in the other editor": "{name} გაიხსნა სხვა რედაქტორში", + "{name} · {terms}": "{name} · {terms}", + "{name} — double-click to reset it": "{name} — ორმაგი დაწკაპუნებით ჩამოყრა", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "გამოტოვებულია ლოკალური ფოტო: {n}{0:.0} — ალბომებში მხოლოდ ბიბლიოთეკის ფოტოებია (ჯერ დაამატეთ ჩემს ფოტოებში)", + "{n} added to Apple Photos": "დაემატა Apple Photos-ს: {n}", + "{n} added to Apple Photos album “{album}”": "დაემატა Apple Photos-ის ალბომს „{album}“: {n}", + "{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} originals opened in the other editor": "სხვა რედაქტორში გახსნილი ორიგინალები: {n}", + "{n} photos": "ფოტო: {n}", + "{n} photos in {} are hidden by the filters above": "ზემოთ მოცემული ფილტრები მალავს „{}“-ის ფოტოებს: {n}", + "{n} photos opened for editing; each is stacked with its original": "რედაქტირებისთვის გაიხსნა ფოტო: {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} · ავტომატურად განახლდება ფოტოების ცვლილებისას", + "{n} similar photo{} · View ▸ Clear Filters to see all": "მსგავსი ფოტო: {n}{:.0} · ყველას სანახავად: ხედი → ფილტრების გასუფთავება", + "{n} tasks are still running: {names}. Quitting stops them.": "ჯერ კიდევ მიმდინარეობს ამოცანები ({n}): {names}. გასვლა მათ შეწყვეტს.", + "{n} {noun}": "{noun}: {n}", + "{pct} %": "{pct} %", + "{pid} (missing)": "{pid} (აკლია)", + "{sel_n} selected · {counted}": "მონიშნულია: {sel_n} · {counted}", + "{size} GB": "{size} GB", + "{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 cancelled": "{what}: გაერთიანება გაუქმდა", + "{what} merge failed: {e}": "{what}: გაერთიანება ვერ მოხერხდა: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "{{date:…}} დირექტივები: {}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "{} and higher": "{} და მეტი", + "{} contributors · {} commits": "მონაწილე: {} · კომიტი: {}", + "{} failed": "ვერ მოხერხდა: {}", + "{} files": "ფაილი: {}", + "{} files and {f} folder{}": "ფაილი: {}, საქაღალდე: {f}{:.0}", + "{} item{}": "ელემენტი: {}{:.0}", + "{} label (click more labels to show any of them)": "იარლიყი „{}“ (დაწკაპეთ სხვა იარლიყებზე, რომ ნებისმიერი მათგანის მქონე ფოტო გამოჩნდეს)", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "დამხმარე ხაზი: {} / 4 — გაავლეთ ხაზებზე, რომლებიც ვერტიკალური ან ჰორიზონტალური უნდა იყოს.", + "{} of {} from {host}": "{} / {} — {host}-დან", + "{} photos": "ფოტო: {}", + "{} photos · exposures {} EV": "ფოტო: {} · ექსპოზიციები {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "ფოტო: {} · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "ვერ იკითხება ფოტო: {}{:.0}", + "{} sample{}": "ნიმუში: {}{:.0}", + "{} sec": "{} წმ", + "{} spot(s) on this photo": "ლაქა ამ ფოტოზე: {}", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. ინახება ბიბლიოთეკის „denoise“ საქაღალდეში; ლიმიტის მიღწევისას პირველად იშლება ყველაზე ძველი, არცერთი ფოტოს მიერ გამოუყენებელი ფაილები.", + "{} — show their photos": "{} — მისი ფოტოების ჩვენება", + "· {dups} already in the library": "· უკვე ბიბლიოთეკაშია: {dups}", + "· {in_source} identical copies": "· იდენტური ასლები: {in_source}", + "· {n} photo{}": "· ფოტო: {n}{:.0}", + "· {trashed} in Recently Deleted": "· ახლახან წაშლილებში: {trashed}", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}“-ში „{name}“ უკვე არსებობს. გაერთიანდეს ორივე?", + "• {assumption}": "• {assumption}", + "… and {more} more": "… და კიდევ {more}", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + " — kept but not applied: {}": " — kept but not applied: {}", + "Applied {name}": "Applied {name}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Delete “{name}”?": "Delete “{name}”?", + "Effect: {name}": "Effect: {name}", + "Exported to {path}": "Exported to {path}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "Imported {name}": "Imported {name}", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "Saved template {name}": "Saved template {name}", + "Skipped {problem}": "Skipped {problem}", + "Slideshow exported: {what}": "Slideshow exported: {what}", + "Template preview: {name}": "Template preview: {name}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "Updated {name}": "Updated {name}", + "new, inside {parent}": "new, inside {parent}", + "{count} more warnings.": "{count} more warnings.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "{n} JPEG files": "{n} JPEG files", + "{n} pages": "{n} pages", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} preset{}": "{n} preset{}", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{s} s of AV1 video": "{s} s of AV1 video", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "…and {more} more": "…and {more} more", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Already in “{}”", + "Move {n} photo{} to “{}”": "Move {n} photo{} to “{}”", + "Move {n} photo{} to “{}”?": "Move {n} photo{} to “{}”?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Moved {n} photo{} to “{}”. Edit → Undo moves them back." +} diff --git a/crates/ui-egui/locales/ka.json b/crates/ui-egui/locales/ka.json new file mode 100644 index 000000000..f6530929d --- /dev/null +++ b/crates/ui-egui/locales/ka.json @@ -0,0 +1,2722 @@ +{ + " (library default)": " (ბიბლიოთეკის ნაგულისხმევი)", + " (inverted)": " (ინვერსიული)", + " · best of its burst": " · საუკეთესო თავის სერიაში", + " · in a burst": " · სერიაში", + "\"Albums\" header takes it back to the top level; Esc cancels; holding a dragged album or photos near the top or bottom edge of the sidebar scrolls it). Drop on the top or bottom half of another album,": "„ალბომების“ სათაური მას ზედა დონეზე აბრუნებს; Esc აუქმებს; გადათრეული ალბომის ან ფოტოების გვერდითი ზოლის ზედა ან ქვედა კიდესთან დაჭერა მას აგორებს). ჩააგდეთ სხვა ალბომის ზედა ან ქვედა ნახევარზე,", + "\"File exists\".": "„File exists“.", + "\"HEIC/HEIF support isn't included in this build\" for each `.heic`/`.heif` at import.": "იმპორტისას ყოველ `.heic`/`.heif`-ზე იტყობინება: „HEIC/HEIF-ის მხარდაჭერა ამ აწყობაში არ შედის“.", + "\"Rendering…\" on the photo": "„რენდერდება…“ ფოტოზე", + "\"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 მონაცემებთან, იწერება დროებით ფაილში, სინქრონიზდება და ხელახლა იკითხება, სანამ მიიღებს", + "% of all commits": "ყველა კომიტის %", + "& Containing": "და შემცველი", + "(\"Failed to associate portal window with parent window\" in the portal's log): tiling compositors put it beside the": "(„Failed to associate portal window with parent window“ პორტალის ჟურნალში): ფილისებრი კომპოზიტორები მას", + "(Esc keeps the old angle; a decimal comma works too), or click the Straighten value; hovering any slider's value now says it can be typed.": "(Esc ძველ კუთხეს ტოვებს; ათწილადი მძიმეც მუშაობს), ან დაწკაპეთ გასწორების მნიშვნელობაზე; ნებისმიერი სლაიდერის მნიშვნელობაზე მიტანისას ახლა ჩანს, რომ მისი აკრეფა შეიძლება.", + "(Space, G, X, ⌫) and ⌘ shortcuts were not affected.": "(Space, G, X, ⌫) და ⌘ მალსახმობებზე ეს გავლენას არ ახდენდა.", + "(`album.setRules` takes `name`).": "(`album.setRules` იღებს `name`-ს).", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), დახმარება → სისტემის ინფორმაცია და პარამეტრები → წარმადობა განმარტავს, რატომ არ არის GPU", + "(`id`, `parent?`, `before?`) and `album.sort`.": "(`id`, `parent?`, `before?`) და `album.sort`-ს.", + "(`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a": "(`render in.raw -o out.jxl`). ხარისხი — როგორც JPEG-ისთვის, ან უდანაკარგო; ძალისხმევა (სწრაფი / ჩვეულებრივი) დროს ცვლის", + "(checked on one CC0 iPhone 12 Pro ProRAW). Photos without mattes are unchanged.": "(შემოწმებულია ერთ CC0 iPhone 12 Pro ProRAW-ზე). მატების გარეშე ფოტოები უცვლელია.", + "(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) აღარ გამოისახება თითქმის შავად ან ჩახშობილი ჩრდილებით: მათი შავის დონე", + "(folders open on click) or finds them as you type part of a name or folder (↑ / ↓ and Enter to pick); albums that": "(საქაღალდეები დაწკაპუნებით იხსნება), ან პოულობს მათ, როცა სახელის ან საქაღალდის ნაწილს აკრეფთ (↑ / ↓ და Enter ასარჩევად); ალბომები, რომელთა", + "(issue #256): the correction the camera records in the file is applied under Lens Corrections ▸ Enable Profile": "(ხარვეზი #256): ფაილში კამერის მიერ ჩაწერილი კორექცია გამოიყენება ობიექტივის კორექციები → პროფილის", + "(issue #706; before, the import skipped it as \"already in the library\" while the grid showed it deleted). The": "(ხარვეზი #706; ადრე იმპორტი მას გამოტოვებდა როგორც „უკვე ბიბლიოთეკაშია“, ბადე კი წაშლილად აჩვენებდა).", + "(issue #735).": "(ხარვეზი #735).", + "(issue #782).": "(ხარვეზი #782).", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(ხარვეზი #94): ვებგვერდიდან გაგზავნილ HTTP მოთხოვნას ტანში ბრძანების გადაცემა აღარ შეუძლია. სტრიქონები შეზღუდულია", + "(issues #187, #298). Importing files that are in Recently Deleted asks whether to leave them there, restore them": "(ხარვეზები #187, #298). ახლახან წაშლილში მყოფი ფაილების იმპორტისას კითხულობს, დარჩეს ისინი იქ, აღდგეს", + "(macOS, Windows, Linux, FreeBSD, Nix) now really are built with HEIC support; a build without it reports": "(macOS, Windows, Linux, FreeBSD, Nix) ახლა მართლაც HEIC-ის მხარდაჭერით არის აწყობილი; მის გარეშე აწყობა", + "(with their edits) or import them as new; the trash view's Photo menu has Empty Recently Deleted.": "(მათი ცვლილებებით) თუ ახლად იმპორტირდეს; ნაგვის ხედის ფოტოს მენიუს აქვს ახლახან წაშლილის გასუფთავება.", + "(~0.6 s per slider tick); now the list is saved only after it changes, and a slider tick takes ~2 ms.": "(~0.6 წმ სლაიდერის თითო ბიჯზე); ახლა სია მხოლოდ ცვლილების შემდეგ ინახება და სლაიდერის ბიჯი ~2 მწმ-ს იკავებს.", + ")": ")", + "**Import Lightroom Catalog…** with that catalog, or Cancel. `lightcraft-cli --library` refuses those folders too.": "**Lightroom-ის კატალოგის იმპორტი…** ამ კატალოგით, ან გაუქმებას. `lightcraft-cli --library` ასეთ საქაღალდეებზეც უარს ამბობს.", + "+ Group": "+ ჯგუფი", + "+ Rule": "+ წესი", + ", picked": ", რჩეული", + ", rejected": ", უარყოფილი", + "0: nothing is rejected.": "0: არაფერი უარიყოფა.", + "1 day": "1 დღე", + "1200D (Rebel T5) and 100D (Rebel SL1) now count as clipped (issue #1272). They used to stop at 81–98% of white, so": "1200D (Rebel T5) და 100D (Rebel SL1) კამერების CR2 ფაილებში ახლა მოკვეთილად ითვლება (ხარვეზი #1272). ადრე ისინი თეთრის 81–98%-ზე ჩერდებოდა, ამიტომ", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 studio-mac-ზე)“) იმის ნაცვლად, რომ ორივემ ჩაწეროს და შეუმჩნევლად წაშალოს ერთმანეთის ცვლილებები. ავარია არასოდეს ტოვებს", + "12–32 mm kit zoom was off by about 5 % of the image width at the corners before. Files shot with the correction off": "12–32 მმ კიტ-ზუმი ადრე კუთხეებში გამოსახულების სიგანის დაახლოებით 5 %-ით სცდებოდა. კორექციის გამორთვით გადაღებული ფაილები", + "16-bit Rec. 2020 PQ. Not in the web version yet.": "16-ბიტიანი Rec. 2020 PQ. ვებ-ვერსიაში ჯერ არ არის.", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "1620 × 1080 წინასწარი ხედი და იმპორტირდება მეტამონაცემებით: გადაღების დრო, კამერა, ობიექტივი, ექსპოზიცია, GPS და XMP. მათი RAW", + "2026-04 or 2026-04-12": "2026-04 ან 2026-04-12", + "2026-10-02": "2026-10-02", + "2026-10-05": "2026-10-05", + "2026-10-07": "2026-10-07", + "2026-10-08": "2026-10-08", + "2026-10-09": "2026-10-09", + "2026-10-10": "2026-10-10", + "2026-10-11": "2026-10-11", + "2026-10-12": "2026-10-12", + "2026-10-13": "2026-10-13", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 ახალი ჩაშენებული პრესეტი: პორტრეტი, პეიზაჟი, ქალაქი, საკვები, სეზონები, ვინტაჟი და შავ-თეთრი ტონირება.", + "3396 K, one on the Shade preset 7493 K. As for #730, only the numbers change: edits made on the old relative": "3396 K-ს, „ჩრდილის“ პრესეტით გადაღებული — 7493 K-ს. როგორც #730-ში, იცვლება მხოლოდ რიცხვები: ძველ ფარდობით", + "4 MiB and connections at 16.": "4 MiB-ით, ხოლო კავშირები — 16-ით.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630 დრაივერები): Windows-ზე ფანჯარა და GPU რენდერირება მხოლოდ DirectX 12-ს იყენებს და Vulkan-ის დრაივერს არ ტვირთავს, თუ", + "= exactly": "= ზუსტად", + "A Folders section in the sidebar lists the folders your photos were imported from; choose one to see its photos.": "გვერდით ზოლში საქაღალდეების სექცია ჩამოთვლის საქაღალდეებს, საიდანაც ფოტოები იმპორტირდა; აირჩიეთ ერთი მისი ფოტოების სანახავად.", + "A blank template keeps the original names.": "ცარიელი შაბლონი ორიგინალ სახელებს ინარჩუნებს.", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "ცვლილება, რომლის დისკზე შენახვაც ვერ ხერხდება (სავსე ან გამორთული დისკი), აღარ რჩება შეუმჩნეველი: ბრძანება იტყობინება", + "A dialog taller than the window no longer runs off it (issue #781): on a 1366×768 screen the Export dialog's title": "ფანჯარაზე მაღალი დიალოგი აღარ გადის მის საზღვრებს გარეთ (ხარვეზი #781): 1366×768 ეკრანზე ექსპორტის დიალოგის სათაური", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "ფილტრი მალავს ამ ხედის ფოტოებს: შეცვალეთ ან გაასუფთავეთ იგი (ხედი → ფილტრების გასუფთავება)", + "A folder is already being synchronized": "საქაღალდე უკვე სინქრონიზდება", + "A folder is being synchronized": "საქაღალდე სინქრონიზდება", + "A keyword set needs a name": "საკვანძო სიტყვების ნაკრებს სახელი სჭირდება", + "A label follows its folder when it is renamed or moved in LightCraft, together with the labels of the folders": "იარლიყი თავის საქაღალდეს მიჰყვება, როცა ის LightCraft-ში გადაერქმევა ან გადაიტანება, მასში მყოფი საქაღალდეების", + "A library holding thousands of presets (a Lightroom collection imported whole: 11 000 presets, a 42 MB": "ბიბლიოთეკა ათასობით პრესეტით (მთლიანად იმპორტირებული Lightroom-ის კოლექცია: 11 000 პრესეტი, 42 MB-იანი", + "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 one-time download of about": "ერთჯერადი ჩამოტვირთვა, დაახლოებით", + "A photo that runs into a bug in LightCraft while it renders (in a raw decoder or the editing pipeline) now fails": "ფოტო, რომელიც რენდერისას LightCraft-ის ხარვეზს აწყდება (RAW დეკოდერში ან რედაქტირების კონვეიერში), ახლა ისე ვერ იხსნება,", + "A preset whose lens-profile switch is off no longer turns off the lens corrections built into a DNG (iPhone ProRAW": "პრესეტი, რომლის ობიექტივის პროფილის გადამრთველი გამორთულია, აღარ თიშავს DNG-ში ჩაშენებულ ობიექტივის კორექციებს (iPhone ProRAW", + "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):", + "A smart album can include or exclude another smart album: \"Keywords contain travel\" and \"Album isn't Excluded Photos\"": "სმარტ-ალბომს შეუძლია სხვა სმარტ-ალბომის ჩართვა ან გამორიცხვა: „საკვანძო სიტყვები შეიცავს travel“ და „ალბომი არ არის Excluded Photos“", + "A smart album whose rules no longer check (an album they test was deleted) is marked ⚠ in the sidebar; its tooltip": "სმარტ-ალბომი, რომლის წესებიც აღარ მოწმდება (მათ მიერ შემოწმებული ალბომი წაიშალა), გვერდით ზოლში ⚠-ით აღინიშნება; მისი მინიშნება", + "A thumbnail of the photo open in Detail is made from its already decoded preview instead of decoding the file": "დეტალურ ხედში გახსნილი ფოტოს ესკიზი მისი უკვე დეკოდირებული წინასწარი ხედიდან კეთდება, ნაცვლად იმისა, რომ ფაილი", + "A zoomed view keeps the GPU copy of the original while it is on screen: pausing for a few seconds between pans no": "გადიდებული ხედი ორიგინალის GPU ასლს ინარჩუნებს, სანამ ის ეკრანზეა: გადაადგილებებს შორის რამდენიმეწამიანი პაუზა", + "AI Denoise": "AI ხმაურის მოცილება", + "AI Denoise Model": "AI ხმაურის მოცილების მოდელი", + "AI Denoise and Find Missing Photos. Each row has the task's name, a progress bar, the count and what it is working": "AI ხმაურის მოცილება და დაკარგული ფოტოების მოძებნა. თითოეულ სტრიქონს აქვს ამოცანის სახელი, პროგრესის ზოლი, რაოდენობა და ის, რაზეც", + "AI Denoise is enabled on the requesting photo": "AI ხმაურის მოცილება ჩართულია მომთხოვნ ფოტოზე", + "AI Denoise is off: press Use on a model.": "AI ხმაურის მოცილება გამორთულია: მოდელზე დააჭირეთ „გამოყენებას“.", + "AI Denoise needs a model. Install one in Settings.": "AI ხმაურის მოცილებას მოდელი სჭირდება. დააინსტალირეთ პარამეტრებში.", + "AI Mask Model Status": "AI ნიღბების მოდელის მდგომარეობა", + "AI RAW denoise": "RAW-ის AI ხმაურის მოცილება", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "AI ნიღბები SAM 3-ით (ობიექტი და აღწერა ნიღბვის პანელში): დაწკაპეთ ობიექტზე მის მოსანიშნად (⌥-დაწკაპუნება ტოვებს", + "ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come": "ARW) ფოტოების გაერთიანება გაერთიანებულ DNG-ს აძლევს იმ ფერს, რომელიც თითო ფოტოს ცალკე გახსნისას აქვს (ხარვეზი #965). CR2-ების HDR გაერთიანება ადრე", + "About": "პროგრამის შესახებ", + "About LightCraft": "LightCraft-ის შესახებ", + "Accept & Download": "მიღება და ჩამოტვირთვა", + "Active": "აქტიური", + "Activity": "აქტივობა", + "Activity stack": "აქტივობების დასტა", + "Actual size": "რეალური ზომა", + "Add": "დამატება", + "Add Catchlight": "თვალის ანარეკლის დამატება", + "Add Face Model…": "სახეების მოდელის დამატება…", + "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 Face Recognition Model": "სახის ამოცნობის მოდელის დამატება", + "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": "დამატება:", + "Add to Album": "ალბომში დამატება", + "Add to All Selected Photos": "ყველა მონიშნულ ფოტოზე დამატება", + "Add to Apple Photos": "Apple Photos-ში დამატება", + "Add to Apple Photos (macOS)": "Apple Photos-ში დამატება (macOS)", + "Add to Favorites": "რჩეულებში დამატება", + "Add to Local": "ლოკალურში დამატება", + "Add to My Photos": "ჩემს ფოტოებში დამატება", + "Add to My Photos First": "ჯერ ჩემს ფოტოებში დამატება", + "Add to Selected Photos": "მონიშნულ ფოტოებზე დამატება", + "Add to Target Album": "სამიზნე ალბომში დამატება", + "Add to favorites": "რჩეულებში დამატება", + "Add to this mask": "ამ ნიღაბზე დამატება", + "Add/Subtract/Intersect Mask": "ნიღბის დამატება/გამოკლება/გადაკვეთა", + "Added to photos you import that don't already have it.": "ემატება იმპორტირებულ ფოტოებს, რომლებსაც ის ჯერ არ აქვთ.", + "Adding": "დამატება", + "Adding Lightroom photos…": "Lightroom-ის ფოტოების დამატება…", + "Adding photos…": "ფოტოების დამატება…", + "Adding to Apple Photos": "Apple Photos-ში დამატება", + "Adding to Apple Photos…": "Apple Photos-ში დამატება…", + "Advanced search…": "გაფართოებული ძიება…", + "After": "შემდეგ", + "After 30 days": "30 დღის შემდეგ", + "After 7 days": "7 დღის შემდეგ", + "After 90 days": "90 დღის შემდეგ", + "After a year": "ერთი წლის შემდეგ", + "After installation, AI Denoise will turn on for the requesting photo.": "დაინსტალირების შემდეგ AI ხმაურის მოცილება მომთხოვნ ფოტოზე ჩაირთვება.", + "Agents list and stop tasks with `activity.list` and `activity.cancel`; `ui.inspect` reports them as `activity`.": "აგენტები ამოცანებს ჩამოთვლიან და აჩერებენ `activity.list`-ით და `activity.cancel`-ით; `ui.inspect` მათ `activity`-ად აბრუნებს.", + "Agents use `app.newLibrary {path, name}`, `app.recentLibraries` and `app.clearRecentLibraries`.": "აგენტები იყენებენ `app.newLibrary {path, name}`, `app.recentLibraries` და `app.clearRecentLibraries`.", + "Agents: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` for files already on disk, and": "აგენტები: `app.export {addToPhotos, photosAlbum}`, `export.addToPhotos {paths}` დისკზე უკვე არსებული ფაილებისთვის, და", + "Agents: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` takes `sidebar`.": "აგენტები: `view.sidebarSection`, `view.sidebarMoveSection`, `view.sidebarReset`; `ui.set` იღებს `sidebar`-ს.", + "Album": "ალბომი", + "Album folders fold with the same disclosure triangle as Local, By Date, Folders and Keywords.": "ალბომების საქაღალდეები იკეცება იმავე სამკუთხედით, როგორც ლოკალური, თარიღით, საქაღალდეები და საკვანძო სიტყვები.", + "Album rule is \"is\" or \"isn't\" an album, picked with the album picker.": "ალბომის წესი არის „არის“ ან „არ არის“ ალბომი, რომელიც ალბომის ამრჩევით აირჩევა.", + "Album, New Smart Album, Rename Keyword, Merge Keywords and similar dialogs, or the name field under unnamed faces for": "გადარქმევის, ახალი სმარტ-ალბომის, საკვანძო სიტყვის გადარქმევის, საკვანძო სიტყვების გაერთიანების და მსგავსი დიალოგების სახელის ველზე, ან უსახელო სახეების ქვეშ სახელის ველზე", + "Albums": "ალბომები", + "Albums hold library photos: Add to My Photos first": "ალბომებში ბიბლიოთეკის ფოტოებია: ჯერ დაამატეთ ჩემს ფოტოებში", + "Albums tree": "ალბომების ხე", + "All": "ყველა", + "All Metadata": "ყველა მეტამონაცემი", + "All Metadata…": "ყველა მეტამონაცემი…", + "All Photos": "ყველა ფოტო", + "All rights reserved": "ყველა უფლება დაცულია", + "All the photos imported from this disk are included.": "ამ დისკიდან იმპორტირებული ყველა ფოტო შედის.", + "All types": "ყველა ტიპი", + "Already installed. Remove it before installing a replacement.": "უკვე დაინსტალირებულია. ჩანაცვლების დაინსტალირებამდე წაშალეთ.", + "Alt Text": "ალტერნატიული ტექსტი", + "Always": "ყოველთვის", + "Amount": "სიძლიერე", + "An uncompressed Leaf Aptus 22 MOS no longer opens as a dark, colourless picture of its sensor mosaic: it imports": "შეუკუმშავი Leaf Aptus 22 MOS აღარ იხსნება მისი სენსორის მოზაიკის მუქ, უფერო სურათად: ის იმპორტდება", + "Analyzing the photo for AI masks…": "ფოტოს ანალიზი AI ნიღბებისთვის…", + "Angle": "კუთხე", + "Any Searchable Text": "ნებისმიერი საძიებო ტექსტი", + "Any Searchable Text also finds the state / province, alt text, people and a virtual copy's name.": "ნებისმიერი საძიებო ტექსტი ასევე პოულობს შტატს / პროვინციას, ალტერნატიულ ტექსტს, ადამიანებსა და ვირტუალური ასლის სახელს.", + "Any camera": "ნებისმიერი კამერა", + "Any keyword": "ნებისმიერი საკვანძო სიტყვა", + "Any lens": "ნებისმიერი ობიექტივი", + "Aperture": "დიაფრაგმა", + "Appearance": "იერსახე", + "Appearance Mode": "იერსახის რეჟიმი", + "Appearance Settings…": "იერსახის პარამეტრები…", + "Appearance lists the modes and themes. On Linux, Auto follows the desktop portal's colour scheme": "იერსახე კი რეჟიმებსა და თემებს ჩამოთვლის. Linux-ზე ავტომატური მიჰყვება დესკტოპის პორტალის ფერთა სქემას", + "Apple Photos Imports": "Apple Photos-ში დამატებები", + "Apple ProRAW's gain table map (its local tone mapping, `ProfileGainTableMap`) is now read and kept when a photo is": "Apple ProRAW-ის მოგების ცხრილის რუკა (მისი ლოკალური ტონალური ასახვა, `ProfileGainTableMap`) ახლა იკითხება და ინახება, როცა ფოტო", + "Application": "აპლიკაცია", + "Apply": "გამოყენება", + "Apply Color Label Set": "ფერადი იარლიყების ნაკრების გამოყენება", + "Apply Filter Preset": "ფილტრის პრესეტის გამოყენება", + "Apply Metadata Preset": "მეტამონაცემების პრესეტის გამოყენება", + "Apply Point Curve Preset": "წერტილოვანი მრუდის პრესეტის გამოყენება", + "Apply Preset": "პრესეტის გამოყენება", + "Apply Settings JSON": "JSON პარამეტრების გამოყენება", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "RAW-ში შენახული ლოკალური ტონალური ასახვის გამოყენება (Apple ProRAW), როგორც კამერა ასახავს", + "Approved": "დამტკიცებულია", + "Aqua": "ზღვისფერი", + "Aqua Hue": "ზღვისფერის ფერის ტონი", + "Aqua Luminance": "ზღვისფერის სიკაშკაშე", + "Aqua Saturation": "ზღვისფერის გაჯერება", + "Arabic names and other Arabic text in headings, chosen tabs and other bold places are now drawn bold, as Latin text": "არაბული სახელები და სხვა არაბული ტექსტი სათაურებში, არჩეულ ჩანართებსა და სხვა მუქ ადგილებში ახლა მუქად იხატება, როგორც მათ გვერდით", + "Arrange Panel": "პანელის დალაგება", + "Arrange the left sidebar your way: drag Albums, Local, By Date, Folders or Keywords by its header to move it (a line": "დაალაგეთ მარცხენა გვერდითი ზოლი თქვენებურად: გადაათრიეთ ალბომები, ლოკალური, თარიღის მიხედვით, საქაღალდეები ან საკვანძო სიტყვები სათაურით (ხაზი", + "ArtCraft Website": "ArtCraft-ის ვებსაიტი", + "ArtCraft — more creative apps": "ArtCraft — სხვა შემოქმედებითი აპლიკაციები", + "As Shot": "როგორც გადაღებულია", + "As shot": "როგორც გადაღებულია", + "Ascending": "ზრდადობით", + "Aspect": "თანაფარდობა", + "Aspect Ratio": "თანაფარდობა", + "Aspect Ratio (a 24 MP photo cropped to a square is 16 MP).": "თანაფარდობა (24 MP ფოტო, კვადრატად ჩამოჭრილი, 16 MP-ია).", + "Assisted Culling": "დამხმარე გადარჩევა", + "Assisted Culling…": "დამხმარე გადარჩევა…", + "Auto": "ავტომატური", + "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 Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "ავტოსინქრონიზაცია: ცვლილებები ყველა მონიშნულ ფოტოზე ვრცელდება. ავტომატური შავ-თეთრი მიქსი. ავტომატური ვერსიები.", + "Auto settings applied": "ავტომატური პარამეტრები გამოყენებულია", + "Auto use the same model. Custom white balances on these photos render slightly differently than before.": "ავტომატური იმავე მოდელს იყენებს. ამ ფოტოებზე თეთრის მორგებული ბალანსი ადრინდელისგან ოდნავ განსხვავებულად რენდერდება.", + "Auto-Stack by Capture Time": "ავტომატური დასტებად დაჯგუფება გადაღების დროით", + "Auto-Stack by Capture Time…": "ავტომატური დასტებად დაჯგუფება გადაღების დროით…", + "Auto-Tag Photos from Tracklog": "ფოტოებისთვის მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან: GPS მდებარეობები თქვენი ფოტოებისთვის GPX მარშრუტის ჟურნალიდან, გადაღების დროის მიხედვით.", + "Auto-Tag from Tracklog…": "მდებარეობის მინიჭება მარშრუტის ჟურნალიდან…", + "Automatic": "ავტომატური", + "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.": "ავტომატური რეჟიმი დეტალურ ხედს ასახავს იმ ზომით, რომლითაც ის ჩანს, ფოტოს საკუთარ პიქსელებამდე. ზღვრისთვის აირჩიეთ ზომა: პატარა უფრო სწრაფია.", + "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.": "ავტომატური რეჟიმი იყენებს ვიდეობარათს, როცა ის პროცესორზე სწრაფია: ორივეს დრო ამ კომპიუტერზე იზომება, როცა ფოტო პირველად მზადდება.", + "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 Up Library…": "ბიბლიოთეკის სარეზერვო ასლი…", + "Back to Edit": "რედაქტირებაზე დაბრუნება", + "Back to Edit (E)": "რედაქტირებაზე დაბრუნება (E)", + "Back to Grid": "ბადეზე დაბრუნება", + "Back to this person": "ამ ადამიანთან დაბრუნება", + "Background": "ფონი", + "Background Tasks": "ფონური ამოცანები", + "Balance": "ბალანსი", + "Basic": "საბაზო", + "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": "მსგავსი კადრებიდან საუკეთესო", + "Binary assets added": "დამატებული ბინარული ფაილები", + "Binary assets removed": "წაშლილი ბინარული ფაილები", + "Bit depth": "ბიტური სიღრმე", + "Black & White": "შავ-თეთრი", + "Blacks": "შავები", + "Bleach Bypass": "გაუთეთრებელი დამუშავება", + "Bleached": "გაუფერულებული", + "Blending": "შერევა", + "Blown highlights in CR2 files from the Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),": "გადაჭარბებით განათებული ღია ტონები Canon EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i),", + "Blue": "ლურჯი", + "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.": "ზღვარზე დაბალი შეფასების ბუნდოვან ფოტოებს უარყოფილის დროშა ედება.", + "Bold text in other scripts": "მუქი ტექსტი სხვა დამწერლობებში", + "Boundary Warp": "საზღვრების დეფორმაცია", + "Bridge and Photo Supreme (its Formatted Vocabulary File) use the same files, so keyword lists move between them and": "Bridge და Photo Supreme (მისი Formatted Vocabulary File) იმავე ფაილებს იყენებს, ასე რომ საკვანძო სიტყვების სიები მათსა და", + "Bright & Airy": "ნათელი და ჰაეროვანი", + "Bring All to Front": "ყველას წინა პლანზე გადატანა", + "Bring the library up to date with this folder and the folders inside it:": "ბიბლიოთეკის განახლება ამ საქაღალდისა და მასში არსებული საქაღალდეების მიხედვით:", + "Browse Folder": "საქაღალდის დათვალიერება", + "Browse Folder…": "საქაღალდის დათვალიერება…", + "Browse Profiles": "პროფილების დათვალიერება", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "ბრაუზერის ვერსია (ექსპერიმენტული), ბიბლიოთეკის დაცვა (ხარვეზი #107): ფაილი → ბიბლიოთეკის სარეზერვო ასლი… ჩამოტვირთავს კატალოგს", + "Browse…": "არჩევა…", + "Brush": "ფუნჯი", + "Brush Settings": "ფუნჯის პარამეტრები", + "Brush Stroke": "ფუნჯის მონასმი", + "Build Previews": "წინასწარი ხედების შექმნა", + "Build Smart Previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "Building previews": "წინასწარი ხედების შექმნა", + "Building smart previews": "ჭკვიანი წინასწარი ხედების შექმნა", + "By Date": "თარიღით", + "By day (YYYY/YYYY-MM-DD)": "დღის მიხედვით (YYYY/YYYY-MM-DD)", + "By month (YYYY/YYYY-MM)": "თვის მიხედვით (YYYY/YYYY-MM)", + "C-RAWs imported earlier, Photo ▸ Reload from Disk picks up the raw data.": "R7 C-RAW-ებისთვის ფოტო → დისკიდან ხელახლა ჩატვირთვა RAW მონაცემებს წაიკითხავს.", + "CPU threads": "CPU ნაკადები", + "Cache": "ქეში", + "Calibration": "კალიბრაცია", + "Camera": "კამერა", + "Camera Info": "კამერის ინფორმაცია", + "Camera local tone mapping": "კამერის ლოკალური ტონალური ასახვა", + "Camera make and model": "კამერის მწარმოებელი და მოდელი", + "Camera-look deep shadows": "ღრმა ჩრდილები კამერის იერში", + "Cameras and Cards": "კამერები და ბარათები", + "Cancel": "გაუქმება", + "Cancel AI Mask Model Download": "AI ნიღბების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Background Task": "ფონური ამოცანის გაუქმება", + "Cancel Denoise": "ხმაურის მოცილების გაუქმება", + "Cancel Denoise Model Download": "ხმაურის მოცილების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Download": "ჩამოტვირთვის გაუქმება", + "Cancel Face Model Download": "სახეების მოდელის ჩამოტვირთვის გაუქმება", + "Cancel Interaction": "ურთიერთქმედების გაუქმება", + "Cancel Pending Photo Action": "ფოტოს მოლოდინში მყოფი მოქმედების გაუქმება", + "Cancel Preview Build": "წინასწარი ხედების შექმნის გაუქმება", + "Cancel pending action": "მოლოდინში მყოფი მოქმედების გაუქმება", + "Candidate": "კანდიდატი", + "Canon CR2 and Pentax PEF raws get the same starting look fitted to the camera's own JPEG as ARW, NEF, RW2, RAF": "Canon CR2 და Pentax PEF RAW-ები კამერის საკუთარ JPEG-ზე მორგებულ იმავე საწყის იერს იღებს, როგორც ARW, NEF, RW2, RAF", + "Canon CR2 highlights": "Canon CR2-ის ღია ტონები", + "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 CR2 raws no longer open too dark with blocked-up shadows: the black level was measured over border columns": "Canon CR2 RAW-ები აღარ იხსნება ზედმეტად მუქად ჩაკეტილი ჩრდილებით: შავის დონე იზომებოდა კიდის სვეტებზე,", + "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-ზე) ნაცვლად", + "Canon CR3 raws now develop from their sensor data: lossless RAW and C-RAW, checked sample for sample on the EOS": "Canon CR3 RAW-ები ახლა სენსორის მონაცემებიდან მუშავდება: უდანაკარგო RAW და C-RAW, ნიმუშ-ნიმუშ შემოწმებული EOS", + "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS and Epson ERF files that LightCraft can't decode yet": "Canon CRW, Minolta MRW, Sigma X3F, Kodak KDC, Leaf MOS და Epson ERF ფაილები, რომელთა დეკოდირება LightCraft-ს ჯერ არ შეუძლია,", + "Canon EOS R5 Mark II": "Canon EOS R5 Mark II", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Canon EOS R5 Mark II C-RAW ფაილები, რომლებიც „მხოლოდ წინასწარი ნახვის“ რეჟიმში იხსნებოდა (ნახევარზე მეტი,", + "Canon EOS R7 C-RAW files develop from their sensor data too, instead of opening from their embedded JPEG. For R7": "Canon EOS R7-ის C-RAW ფაილებიც სენსორის მონაცემებიდან მუშავდება, ჩაშენებული JPEG-იდან გახსნის ნაცვლად. ადრე იმპორტირებული", + "Canon PowerShot G9 raws open with the camera's white balance (White Balance ▸ As Shot); it used to start from a": "Canon PowerShot G9-ის RAW ფაილები კამერის თეთრის ბალანსით იხსნება (თეთრის ბალანსი → როგორც გადაღებულია); ადრე ისინი", + "Caption": "წარწერა", + "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": "შეცვლა", + "Change Component Mode": "კომპონენტის რეჟიმის შეცვლა", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "ფაილის სახელებში დაუშვებელი სიმბოლოები (/ \\ : * ? \" < > |) იცვლება -ით; არსებულ სახელს ემატება -1, -2….", + "Charcoal": "ნახშირი", + "Check All": "ყველას შემოწმება", + "Check Output Path": "გამოსატანი მისამართის შემოწმება", + "Check the files of this type": "ამ ტიპის ფაილების შემოწმება", + "Checking and installing the model…": "მოდელის შემოწმება და დაინსტალირება…", + "Checking names…": "სახელების შემოწმება…", + "Checking…": "შემოწმება…", + "Chinese (Simplified and Traditional), Japanese and Brazilian Portuguese.": "ჩინურის (გამარტივებული და ტრადიციული), იაპონურის და ბრაზილიური პორტუგალიურის გვერდით.", + "Choice values read as words in every language: \"Public Domain\", \"Picked\", \"Black & White\" rather than ids like": "არჩევანის მნიშვნელობები ყველა ენაზე სიტყვებად იკითხება: „საზოგადოებრივი საკუთრება“, „რჩეული“, „შავ-თეთრი“ და არა იდენტიფიკატორები, როგორიცაა", + "Choose Another Library…": "სხვა ბიბლიოთეკის არჩევა…", + "Choose Denoise Model": "ხმაურის მოცილების მოდელის არჩევა", + "Choose Edit Settings to Copy": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა", + "Choose Edit Settings to Copy…": "დასაკოპირებელი რედაქტირების პარამეტრების არჩევა…", + "Choose Face Recognition Model": "სახის ამოცნობის მოდელის არჩევა", + "Choose Folder…": "საქაღალდის არჩევა…", + "Choose Monitor Profile": "მონიტორის პროფილის არჩევა", + "Choose Profile…": "პროფილის არჩევა…", + "Choose a model below (Use) to start.": "დასაწყებად ქვემოთ აირჩიეთ მოდელი („გამოყენება“).", + "Choose a photo in the grid or filmstrip": "აირჩიეთ ფოტო ბადეში ან კადრების ზოლში", + "Choose an album…": "აირჩიეთ ალბომი…", + "Choose…": "არჩევა…", + "Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` /": "Lightroom Classic-ის კატალოგის საქაღალდის არჩევა მენიუში ფაილი → ბიბლიოთეკის გახსნა… (ან მისი `Previews.lrdata` /", + "Choosing a date under By Date or a keyword under Keywords shows those photos from All Photos, as their counts": "თარიღის არჩევა „თარიღით“ განყოფილებაში ან საკვანძო სიტყვისა „საკვანძო სიტყვებში“ ამ ფოტოებს „ყველა ფოტოდან“ აჩვენებს, როგორც მათი რაოდენობა", + "Cinematic": "კინემატოგრაფიული", + "City": "ქალაქი", + "Clarity": "სიცხადე", + "Clean Studio": "სუფთა სტუდია", + "Clear": "გასუფთავება", + "Clear Cache": "ქეშის გასუფთავება", + "Clear Denoise Cache": "ხმაურის მოცილების ქეშის გასუფთავება", + "Clear Filters": "ფილტრების გასუფთავება", + "Clear Guides": "დამხმარე ხაზების გასუფთავება", + "Clear History": "ისტორიის გასუფთავება", + "Clear Preview Cache": "წინასწარი ხედების ქეშის გასუფთავება", + "Clear Quick Collection": "სწრაფი კოლექციის გასუფთავება", + "Clear Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება", + "Clear Reference": "ეტალონის გასუფთავება", + "Clear all": "ყველას გასუფთავება", + "Clear cache": "ქეშის გასუფთავება", + "Clear form": "ფორმის გასუფთავება", + "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-ს, გადაღებულს 2026 წლის 14 იანვარს, 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": "დაწკაპეთ აღსადგენად · ორჯერ დაწკაპეთ სახელის გადასარქმევად", + "Click to type a value": "დაწკაპეთ მნიშვნელობის შესაყვანად", + "Clone": "კლონირება", + "Close": "დახურვა", + "Close Panel": "პანელის დახურვა", + "Close Window": "ფანჯრის დახურვა", + "Close all": "ყველას დახურვა", + "Close file": "ფაილის დახურვა", + "Close panel": "პანელის დახურვა", + "Closing a library releases its lock explicitly (issue #680). On Linux and macOS the lock used to go with the file": "ბიბლიოთეკის დახურვა მის ბლოკს ცხადად ათავისუფლებს (ხარვეზი #680). Linux-სა და macOS-ზე ბლოკი ადრე ფაილის", + "Cloudy": "მოღრუბლული", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R, ფოტოს ინარჩუნებს და მის ცვლილებებს აუქმებს.)", + "Collapse": "ჩაკეცვა", + "Collapse All Stacks": "ყველა დასტის ჩაკეცვა", + "Collapse Stack": "დასტის ჩაკეცვა", + "Color": "ფერი", + "Color Grading": "ფერის გრადაცია", + "Color Label": "ფერადი იარლიყი", + "Color Label Names": "ფერადი იარლიყების სახელები", + "Color Label Sets": "ფერადი იარლიყების ნაკრებები", + "Color Mixer": "ფერების მიქშერი", + "Color Noise Reduction": "ფერის ხმაურის შემცირება", + "Color Priority": "ფერის პრიორიტეტი", + "Color Range": "ფერების დიაპაზონი", + "Color label cleared": "ფერადი იარლიყი მოხსნილია", + "Color space": "ფერთა სივრცე", + "Color space: sRGB (AVIF)": "ფერთა სივრცე: sRGB (AVIF)", + "Colors": "ფერები", + "Colour labels tint thumbnail surrounds in Square Grid and the Detail filmstrip, and a translucent footer along the bottom of the photo in Photo Grid. Label confirmations use a matching pale colour.": "ფერადი იარლიყები კვადრატულ ბადესა და დეტალური ხედის კადრების ზოლში ესკიზების გარშემოს ღებავს, ფოტოების ბადეში კი ფოტოს ქვედა კიდეზე გამჭვირვალე ზოლს ქმნის. იარლიყის დადასტურებები შესაბამის ღია ფერს იყენებს.", + "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.": "ფერების დიაპაზონის ნიღბები: ნიმუშისთვის დაწკაპეთ ფოტოზე. სიკაშკაშის დიაპაზონები: დიაპაზონის ზოლი, სიგლუვე, სიკაშკაშის რუკა.", + "Combine files…": "ფაილების გაერთიანება…", + "Command Journal": "ბრძანებების ჟურნალი", + "Comments panel": "კომენტარების პანელი", + "Commits": "კომიტები", + "Company": "კომპანია", + "Compare": "შედარება", + "Compare (Shift+C)": "შედარება (Shift+C)", + "Compare Before and After": "მანამდე და შემდეგ შედარება", + "Compare and Reference View panes say \"Rendering…\" (or that the photo can't be opened) the same way, so a": "შედარებისა და ეტალონური ხედის პანელები ასევე წერს „რენდერდება…“ (ან რომ ფოტოს გახსნა შეუძლებელია), ასე რომ", + "Compare needs at least two photos": "შედარებას სულ მცირე ორი ფოტო სჭირდება", + "Component options": "კომპონენტის პარამეტრები", + "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)", + "Compression": "შეკუმშვა", + "Concrete Cool": "ცივი ბეტონი", + "Confirm before deleting": "წაშლამდე დადასტურება", + "Confirm before moving photos to Recently Deleted": "ფოტოების ახლახან წაშლილში გადატანამდე დადასტურება", + "Constrain Crop": "ჩამოჭრის შეზღუდვა", + "Contact Sheet PDF…": "საკონტაქტო ფურცელი PDF…", + "Content-Aware": "შიგთავსის გათვალისწინებით", + "Continue": "გაგრძელება", + "Continue Without Saving": "გაგრძელება შენახვის გარეშე", + "Contrast": "კონტრასტი", + "Contributors": "მონაწილეები", + "Convert to DNG": "DNG-ად გარდაქმნა", + "Convert to DNG, Copy as DNG, Photo Merge and smart previews no longer write straight to the final file (issue": "DNG-ად გარდაქმნა, DNG-ად კოპირება, ფოტოების გაერთიანება და ჭკვიანი წინასწარი ხედები აღარ წერს პირდაპირ საბოლოო ფაილში (ხარვეზი", + "Cool Fade": "ცივი მიქრობა", + "Cool Morning": "ცივი დილა", + "Copy": "კოპირება", + "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 Name": "ასლის სახელი", + "Copy as DNG": "DNG-ად კოპირება", + "Copy into the library (else use the files where they are)": "ბიბლიოთეკაში კოპირება (თორემ ფაილები მათ ადგილას გამოიყენება)", + "Copy pages": "გვერდების კოპირება", + "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.": "კოპირება: ფაილები კოპირდება ქვემოთ მითითებულ საქაღალდეში და ბიბლიოთეკა ასლებს იყენებს; ორიგინალები უცვლელი რჩება.", + "Copyright": "საავტორო უფლება", + "Copyright Info URL": "საავტორო უფლებების ინფორმაციის URL", + "Copyright Status": "საავტორო უფლებების სტატუსი", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "საავტორო უფლებების სტატუსი, უფლებების გამოყენების პირობები და საავტორო უფლებების ინფორმაციის URL ინფორმაციაში, მეტამონაცემების პრესეტებსა და ექსპორტში.", + "Copyrighted": "დაცულია საავტორო უფლებით", + "Corrections, on by default for newly imported photos, with the same framing as the camera's JPEG. At 12 mm the": "კორექციების ჩართვის ქვეშ, ახლად იმპორტირებული ფოტოებისთვის ნაგულისხმევად ჩართული, კამერის JPEG-ის იმავე კადრირებით. 12 მმ-ზე", + "Country": "ქვეყანა", + "Create": "შექმნა", + "Create Album": "ალბომის შექმნა", + "Create Album…": "ალბომის შექმნა…", + "Create Folder": "საქაღალდის შექმნა", + "Create Folder…": "საქაღალდის შექმნა…", + "Create Keyword": "საკვანძო სიტყვის შექმნა", + "Create Keyword Tag": "საკვანძო სიტყვის შექმნა", + "Create New Mask": "ახალი ნიღბის შექმნა", + "Create PDF from clipboard": "PDF-ის შექმნა გაცვლის ბუფერიდან", + "Create PDF from file…": "PDF-ის შექმნა ფაილიდან…", + "Create PDF from images…": "PDF-ის შექმნა გამოსახულებებიდან…", + "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": "ვირტუალური ასლის შექმნა", + "Create keywords with + (inside the one you picked, with synonyms, and given to the selection if you like), edit one": "საკვანძო სიტყვების შექმნა +-ით (არჩეულის შიგნით, სინონიმებით, და სურვილისამებრ მონიშნულისთვის მინიჭებით), რედაქტირება", + "Creator": "ავტორი", + "Creator metadata": "ავტორის მეტამონაცემები", + "Crisp Landscape": "მკვეთრი პეიზაჟი", + "Crisp Vista": "მკვეთრი ხედი", + "Crop": "ჩამოჭრა", + "Crop & Rotate": "ჩამოჭრა და შემობრუნება", + "Crop & Rotate (C)": "ჩამოჭრა და შემობრუნება (C)", + "Crop (issue #295): a Lock toggle keeps the aspect ratio on every handle, Custom takes your own ratio (Apply), and": "ჩამოჭრა (ხარვეზი #295): ჩაკეტვის გადამრთველი თანაფარდობას ყველა სახელურზე ინარჩუნებს, მორგებული თქვენს თანაფარდობას იღებს (გამოყენება), და", + "Crop Aspect": "ჩამოჭრის თანაფარდობა", + "Crop rotation": "ჩამოჭრის შემობრუნება", + "Crop, Rotate, Geometry": "ჩამოჭრა, შემობრუნება, გეომეტრია", + "Cropped": "ჩამოჭრილი", + "Cross Process": "ჯვარედინი დამუშავება", + "Culling": "გადარჩევა", + "Culling.": "გადარჩევა.", + "Curve": "მრუდი", + "Custom": "მორგებული", + "Custom template…": "მორგებული შაბლონი…", + "Custom…": "მომხმარებლის…", + "Cut": "ამოჭრა", + "Cut pages": "გვერდების ამოჭრა", + "Cut, Copy, Paste and Select All. Esc in them gives back the text from before you started typing (in the Info panel it": "ხელმისაწვდომია ამოჭრა, კოპირება, ჩასმა და ყველაფრის მონიშვნა. Esc მათში აკრეფის დაწყებამდე არსებულ ტექსტს აბრუნებს (ინფორმაციის პანელში", + "Cycle Crop Overlay": "ჩამოჭრის ზედდების მონაცვლეობა", + "Cycle Crop Overlay Orientation": "ჩამოჭრის ზედდების ორიენტაციის მონაცვლეობა", + "Cycle Info Overlay": "ინფორმაციის ზედდების მონაცვლეობა", + "Cycle Mask Overlay Color": "ნიღბის ზედდების ფერის მონაცვლეობა", + "Cycle Mask Overlay Mode": "ნიღბის ზედდების რეჟიმის მონაცვლეობა", + "Cylindrical": "ცილინდრული", + "Dark & Moody": "მუქი და პირქუში", + "Dark Mode": "ბნელი რეჟიმი", + "Dark Theme": "ბნელი თემა", + "Darken": "ჩამუქება", + "Darks": "მუქი ტონები", + "Date": "თარიღი", + "Date Groups": "თარიღის ჯგუფები", + "Date rules have a calendar button: pick a day, a month or a whole year (Year / Month / Day), in your language; the": "თარიღის წესებს აქვს კალენდრის ღილაკი: აირჩიეთ დღე, თვე ან მთელი წელი (წელი / თვე / დღე) თქვენს ენაზე;", + "Day": "დღე", + "Daylight": "დღის სინათლე", + "Daylight, Cloudy, Shade, Tungsten, Fluorescent and Flash presets set real temperatures (issue #730). The matrices": "პრესეტები დღის სინათლე, მოღრუბლული, ჩრდილი, ვარვარების ნათურა, ფლუორესცენტური და ფლეში ნამდვილ ტემპერატურას აყენებს (ხარვეზი #730). მატრიცები", + "Decrease Brush Feather": "ფუნჯის კიდეების შერბილების შემცირება", + "Decrease Brush Size": "ფუნჯის ზომის შემცირება", + "Decrease Rating": "შეფასების შემცირება", + "Deep Sky": "ღრმა ცა", + "Defringe": "კიდის შარავანდის მოცილება", + "Deghost Amount": "აჩრდილების მოცილების სიძლიერე", + "Dehaze": "ბურუსის მოცილება", + "Delete": "წაშლა", + "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 Keyword…": "საკვანძო სიტყვის წაშლა…", + "Delete Mask": "ნიღბის წაშლა", + "Delete Metadata Preset": "მეტამონაცემების პრესეტის წაშლა", + "Delete Permanently": "სამუდამოდ წაშლა", + "Delete Photo": "ფოტოს წაშლა", + "Delete Photos": "ფოტოების წაშლა", + "Delete Point Color": "წერტილოვანი ფერის წაშლა", + "Delete Point Color Sample": "წერტილოვანი ფერის ნიმუშის წაშლა", + "Delete Point Curve Preset": "წერტილოვანი მრუდის პრესეტის წაშლა", + "Delete Preset": "პრესეტის წაშლა", + "Delete Red Eye": "წითელი თვალის წაშლა", + "Delete Red Eye Correction": "წითელი თვალების კორექციის წაშლა", + "Delete Spot": "ლაქის წაშლა", + "Delete Version": "ვერსიის წაშლა", + "Delete all": "ყველას წაშლა", + "Delete all spots": "ყველა ლაქის წაშლა", + "Delete mask": "ნიღბის წაშლა", + "Delete them": "მათი წაშლა", + "Deleted, read the metadata updates. A folder on a disk that isn't connected is reported as such. Synchronizing": "წაშლილში, მეტამონაცემების განახლებების წაკითხვა. გამორთულ დისკზე არსებული საქაღალდის შესახებ ამას იტყობინება. სინქრონიზაცია", + "Denoise Model Downloads": "ხმაურის მოცილების მოდელების ჩამოტვირთვა", + "Denoise Models": "ხმაურის მოცილების მოდელები", + "Denoise Photos": "ფოტოებიდან ხმაურის მოცილება", + "Denoise Pump": "ხმაურის მოცილების რიგის გაშვება", + "Denoise Settings": "ხმაურის მოცილების პარამეტრები", + "Denoise Status": "ხმაურის მოცილების მდგომარეობა", + "Denoise all photos with an Amount": "ხმაურის მოცილება ყველა ფოტოზე, რომელსაც სიძლიერე აქვს", + "Density": "სიმკვრივე", + "Depth Range": "სიღრმის დიაპაზონი", + "Descending": "კლებადობით", + "Describe": "აღწერა", + "Describe what to keep": "აღწერეთ, რა დარჩეს", + "Describe what to leave out": "აღწერეთ, რა გამოირიცხოს", + "Describe what to select": "აღწერეთ, რა მოინიშნოს", + "Describe…": "აღწერა…", + "Description": "აღწერა", + "Deselect": "მონიშვნის მოხსნა", + "Deselect All": "ყველა მონიშვნის მოხსნა", + "Desert Heat": "უდაბნოს პაპანაქება", + "Desert Warmth": "უდაბნოს სითბო", + "Desktop": "სამუშაო მაგიდა", + "Detail": "დეტალი", + "Detail (D)": "დეტალური ხედი (D)", + "Detail has a per-photo AI Denoise switch and Amount. Bayer RAW photos use a disposable cache, with matching previews and exports and pure-Rust CPU/GPU inference.": "დეტალს აქვს ფოტოს დონის AI ხმაურის მოცილების გადამრთველი და სიძლიერე. Bayer RAW ფოტოები ერთჯერად ქეშს იყენებს, შესაბამისი წინასწარი ხედებით, ექსპორტით და სუფთა Rust-ზე CPU/GPU ინფერენსით.", + "Details": "დეტალები", + "Detect Faces": "სახეების ამოცნობა", + "Detect Faces (Photo menu) needs it: a small one-time download.": "სახეების ამოცნობას (მენიუ „ფოტო“) ეს სჭირდება: მცირე, ერთჯერადი ჩამოტვირთვა.", + "Develop": "დამუშავება", + "Diagonal": "დიაგონალური", + "Dialogs": "დიალოგები", + "Dialogs fit the window": "დიალოგები ფანჯარაში ეტევა", + "Dialogs that fit look as before.": "დიალოგები, რომლებიც ეტევა, ისევე გამოიყურება, როგორც ადრე.", + "Discard Preview Cache": "წინასწარი ხედების ქეშის წაშლა", + "Discarding smart previews": "ჭკვიანი წინასწარი ხედები იშლება", + "Display": "ეკრანი", + "Display P3": "Display P3", + "Display Profile": "ეკრანის პროფილი", + "Display name": "საჩვენებელი სახელი", + "Display profile not used": "ეკრანის პროფილი არ გამოიყენება", + "Display theme": "ინტერფეისის თემა", + "Distortion": "დეფორმაცია", + "Dock can be attached to a bug report. `LIGHTCRAFT_LOG` works as before; `RUST_LOG` takes env_logger-style": "შეცდომის ანგარიშს შეიძლება დაერთოს. `LIGHTCRAFT_LOG` ისე მუშაობს, როგორც ადრე; `RUST_LOG` იღებს env_logger-ის სტილის", + "Document properties…": "დოკუმენტის თვისებები…", + "Don't enlarge": "გადიდების გარეშე", + "Done": "მზადაა", + "Download": "ჩამოტვირთვა", + "Download AI Mask Model": "AI ნიღბების მოდელის ჩამოტვირთვა", + "Download Denoise Model": "ხმაურის მოცილების მოდელის ჩამოტვირთვა", + "Download Face Model": "სახეების მოდელის ჩამოტვირთვა", + "Download Place Names (GeoNames)": "ადგილების სახელების ჩამოტვირთვა (GeoNames)", + "Download the SAM 3 Model?": "ჩამოიტვირთოს SAM 3 მოდელი?", + "Downloaded and checked. Installing…": "ჩამოტვირთულია და შემოწმებულია. ინსტალირდება…", + "Downloading model": "მოდელი იტვირთება", + "Downloading place names": "ადგილების სახელები იტვირთება", + "Downloads": "ჩამოტვირთვები", + "Drag Crop Handle": "ჩამოჭრის სახელურის გადათრევა", + "Drag a keyword onto another to nest it, or onto the list's title to bring it back to the top level; photos dragged": "გადაათრიეთ საკვანძო სიტყვა მეორეზე მის შიგნით მოსათავსებლად, ან სიის სათაურზე ზედა დონეზე დასაბრუნებლად; ბადიდან", + "Drag along the horizon; double-click for Auto": "გაატარეთ ჰორიზონტის გასწვრივ; ორმაგი დაწკაპუნება — ავტომატურად", + "Drag an album or folder onto a folder to move it there (a closed folder opens after 0.6 s under the pointer; a": "გადაათრიეთ ალბომი ან საქაღალდე საქაღალდეზე მის იქ გადასატანად (დახურული საქაღალდე იხსნება, თუ მაჩვენებელი მასზე 0.6 წმ გაჩერდება;", + "Drag anywhere inside the selected radial gradient's ellipse to move it, including rotated": "მონიშნული რადიალური გრადიენტის ელიფსის შიგნით ნებისმიერ ადგილას გადათრევით ის გადაადგილდება, მათ შორის შემობრუნებული", + "Drag over an eye on the photo; the pupil inside is found automatically.": "გაატარეთ ფოტოზე თვალის თავზე; შიგნით გუგა ავტომატურად მოიძებნება.", + "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.": "გადაათრიეთ ფერის ტონისა და გაჯერების დასაყენებლად. დააჭირეთ Shift-ს ფერის ტონის შესანარჩუნებლად, Cmd/Ctrl-ს — გაჯერების შესანარჩუნებლად, Alt/Option-ს — ზუსტი რეგულირებისთვის. ორჯერ დაწკაპეთ ჩამოსაყრელად.", + "Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you": "სლაიდერის გადათრევისას რედაქტირებული ფოტოს ესკიზი კადრების ზოლში აღარ რენდერდება ყოველ კადრზე; ის განახლდება, როცა", + "Draw Guides": "დამხმარე ხაზების დახატვა", + "Drop here for the top level": "ჩააგდეთ აქ ზედა დონისთვის", + "Duplicate": "დუბლირება", + "Duplicate Component": "კომპონენტის დუბლირება", + "Duplicate Mask": "ნიღბის დუბლირება", + "Duplicate and Invert Mask": "ნიღბის დუბლირება და ინვერსია", + "E-1, E-400 and XZ-2 corpus files: all accepted), with white balance relative to the as-shot look as for the other": "E-1, E-400 და XZ-2 კორპუსის ფაილები: ყველა მიღებულია), თეთრის ბალანსით გადაღებისას იერთან მიმართებით, როგორც სხვა", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "ყოველი / საქაღალდის ახალ დონეს იწყებს; ტეგები თითოეული ფოტოსთვის ივსება (დონე, რომელსაც მეტამონაცემები აკლია, არის \"unknown\").", + "Each such bug used to take one of the render threads with it, so after a few of them no photo rendered until": "ადრე ყოველი ასეთი ხარვეზი რენდერის ერთ ნაკადს თან იყოლიებდა, ამიტომ რამდენიმე მათგანის შემდეგ არცერთი ფოტო არ რენდერდებოდა,", + "Edge": "კიდე", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "კიდის პარამეტრი და ფონურ რეჟიმში უფრო მკვეთრ, გადიდებულ დამუშავებას იღებენ. მოდელი LightCraft-ის შიგნით, სუფთა Rust-ზე მუშაობს", + "Edge, Short Edge, Aspect Ratio (landscape / portrait / square), Cropped and Treatment (in color / black & white).": "გვერდი, მოკლე გვერდი, თანაფარდობა (ჰორიზონტალური / ვერტიკალური / კვადრატული), ჩამოჭრილი და დამუშავება (ფერადი / შავ-თეთრი).", + "Edit": "რედაქტირება", + "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 In": "რედაქტირება სხვა პროგრამაში", + "Edit Info": "ინფორმაციის რედაქტირება", + "Edit Keyword": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Set": "საკვანძო სიტყვების ნაკრების რედაქტირება", + "Edit Keyword Tag": "საკვანძო სიტყვის რედაქტირება", + "Edit Keyword Tag…": "საკვანძო სიტყვის რედაქტირება…", + "Edit Label Names": "იარლიყების სახელების რედაქტირება", + "Edit Label Names…": "იარლიყების სახელების რედაქტირება…", + "Edit Mask": "ნიღბის რედაქტირება", + "Edit Mask Component": "ნიღბის კომპონენტის რედაქტირება", + "Edit Sections": "რედაქტირების სექციები", + "Edit Set…": "ნაკრების რედაქტირება…", + "Edit Smart Album": "სმარტ-ალბომის რედაქტირება", + "Edit Smart Album…": "სმარტ-ალბომის რედაქტირება…", + "Edit Spot": "ლაქის რედაქტირება", + "Edit in External Editor": "რედაქტირება გარე რედაქტორში", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "რედაქტირება გარე რედაქტორში (⇧⌘E): 16-ბიტიანი TIFF ასლი, დასტაში, განახლდება, როცა დაბრუნდებით.", + "Edit in HDR: highlights above SDR white, for HDR exports and displays": "რედაქტირება HDR-ში: SDR თეთრზე ღია ტონები, HDR ექსპორტისა და ეკრანებისთვის", + "Edit in Other Editor": "რედაქტირება სხვა რედაქტორში", + "Edit in Other Editor (⌥⌘E) opens the copy in this application (an app name on macOS, a program path elsewhere).": "„რედაქტირება სხვა რედაქტორში“ (⌥⌘E) ასლს ამ პროგრამაში ხსნის (macOS-ზე — აპის სახელი, სხვაგან — პროგრამის მისამართი).", + "Edit in PhotoCraft": "რედაქტირება PhotoCraft-ში", + "Edit settings copied": "რედაქტირების პარამეტრები დაკოპირდა", + "Edited": "შეცვლილია", + "Edited or not": "რედაქტირებული თუ არა", + "Editing": "რედაქტირება", + "Editing a smart album's name and rules is one step: both or neither, and one undo takes both back": "სმარტ-ალბომის სახელისა და წესების რედაქტირება ერთი ნაბიჯია: ან ორივე, ან არცერთი, და ერთი დაბრუნება ორივეს აბრუნებს", + "Effect": "ეფექტი", + "Effects": "ეფექტები", + "Empty Recently Deleted": "ახლახან წაშლილის გასუფთავება", + "Enable Lens Corrections": "ობიექტივის კორექციების ჩართვა", + "Enable Profile Corrections": "პროფილის კორექციების ჩართვა", + "End Interaction": "ურთიერთქმედების დასრულება", + "English": "English", + "Enter Full Screen": "სრულეკრანიან რეჟიმში გადასვლა", + "Enter a date as YYYY-MM-DD HH:MM:SS": "შეიყვანეთ თარიღი ფორმატით YYYY-MM-DD HH:MM:SS", + "Erase": "წაშლა", + "Evaluate Face Recognition": "სახეების ამოცნობის შეფასება", + "Every EXIF, GPS and XMP field in the file": "ფაილის ყველა EXIF, GPS და XMP ველი", + "Existing previews": "არსებული წინასწარი ხედები", + "Existing smart previews are refreshed when their originals are online, and cached renders are redrawn.": "არსებული ჭკვიანი წინასწარი ხედები ახლდება, როცა მათი ორიგინალები ხელმისაწვდომია, ხოლო ქეშირებული რენდერები ხელახლა იხატება.", + "Exit": "გასვლა", + "Expand": "გაშლა", + "Expand All Stacks": "ყველა დასტის ჩამოშლა", + "Expand Stack": "დასტის გაშლა", + "Expand/Collapse Stack": "დასტის გაშლა/ჩაკეცვა", + "Export": "ექსპორტი", + "Export Album with Preset": "ალბომის ექსპორტი პრესეტით", + "Export Album…": "ალბომის ექსპორტი…", + "Export As…": "ექსპორტი სხვა ფორმატით…", + "Export Contact Sheet PDF": "საკონტაქტო ფურცლის PDF-ად ექსპორტი", + "Export Containing Keywords": "მშობელი საკვანძო სიტყვების ექსპორტი", + "Export Group…": "ჯგუფის ექსპორტი…", + "Export Keywords": "საკვანძო სიტყვების ექსპორტი", + "Export Keywords…": "საკვანძო სიტყვების ექსპორტი…", + "Export Now": "ექსპორტი ახლავე", + "Export Point Curve Presets": "წერტილოვანი მრუდის პრესეტების ექსპორტი", + "Export Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების ექსპორტი…", + "Export Presets": "ექსპორტის პრესეტები", + "Export Presets…": "პრესეტების ექსპორტი…", + "Export Synonyms": "სინონიმების ექსპორტი", + "Export User Presets…": "მომხმარებლის პრესეტების ექსპორტი…", + "Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: \"jxl\"` for agents and the CLI": "ექსპორტი JPEG XL-ად (ხარვეზი #247): ფორმატი → JPEG XL ექსპორტის დიალოგში, `format: \"jxl\"` აგენტებისა და CLI-სთვის", + "Export can write PSD too (flat, 8- or 16-bit).": "ექსპორტს PSD-ის ჩაწერაც შეუძლია (ერთშრიანი, 8 ან 16 ბიტი).", + "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}-ს.", + "Export stopped unexpectedly": "ექსპორტი მოულოდნელად შეწყდა", + "Export with Preset": "ექსპორტი პრესეტით", + "Export with Previous": "ექსპორტი წინა პარამეტრებით", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "ექსპორტირებული ფაილები ჯერ დროებით ფაილში იწერება და შემდეგ ადგილზე გადაერქმევა, ასე რომ სავსე დისკი ან გამორთული დისკი", + "Exporting": "ექსპორტირდება", + "Exporting contact sheet": "საკონტაქტო ფურცელი ექსპორტირდება", + "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": "ექსპორტი GPU-ის გამო აღარასოდეს არის შავი (ხარვეზი #78): GPU რენდერი, რომელსაც მოწყობილობის", + "Exports with noise reduction, Texture, Clarity, Dehaze or Highlights and Shadows can be much faster on the GPU,": "ექსპორტი ხმაურის შემცირებით, ტექსტურით, სიცხადით, ბურუსის მოცილებით ან ღია ტონებითა და ჩრდილებით GPU-ზე შეიძლება გაცილებით სწრაფი იყოს,", + "Export…": "ექსპორტი…", + "Exposure": "ექსპოზიცია", + "Exposure (shutter · aperture · ISO)": "ექსპოზიცია (ჩამკეტი · დიაფრაგმა · ISO)", + "Extended Description": "ვრცელი აღწერა", + "External Editing": "გარე რედაქტირება", + "External Editor": "გარე რედაქტორი", + "Face Boxes": "სახეების ჩარჩოები", + "Face Model Downloads": "სახეების მოდელების ჩამოტვირთვა", + "Face Models": "სახეების მოდელები", + "Face Recognition On/Off": "სახეების ამოცნობის ჩართვა/გამორთვა", + "Face boxes": "სახეების ჩარჩოები", + "Face detector": "სახეების დეტექტორი", + "Face models (ONNX)": "სახის მოდელები (ONNX)", + "Face names written to XMP by Lightroom and other apps show up here.": "სახეების სახელები, რომლებიც Lightroom-მა და სხვა აპებმა XMP-ში ჩაწერეს, აქ ჩანს.", + "Face recognition": "სახეების ამოცნობა", + "Faces": "სახეები", + "Faces of a Person": "ადამიანის სახეები", + "Faded Matte": "გაცრეცილი მატი", + "Faded Slide": "გაცრეცილი სლაიდი", + "Faded Urban": "გაცრეცილი ქალაქი", + "Failed": "ვერ მოხერხდა", + "Failed its last test": "ბოლო შემოწმება ვერ გაიარა", + "Faster GPU exports": "უფრო სწრაფი GPU ექსპორტი", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "უფრო სწრაფი ექსპორტი და ბარათიდან იმპორტი, იმავე დაცვით იმისთვის, რისი აღდგენაც შეუძლებელია (ხარვეზი #134): ექსპორტი,", + "Favorite Preset": "პრესეტის რჩეულებში დამატება", + "Favorite Profile": "პროფილის რჩეულებში დამატება", + "Favorites": "რჩეულები", + "Feather": "კიდეების შერბილება", + "File": "ფაილი", + "File & date": "ფაილი და თარიღი", + "File Extension": "ფაილის გაფართოება", + "File Format": "ფაილის ფორმატი", + "File Name": "ფაილის სახელი", + "File Path": "ფაილის მისამართი", + "File Size": "ფაილის ზომა", + "File Type": "ფაილის ტიპი", + "File extensions match whatever their letter case: `IMG_0001.JPG` and `DSC_0002.NEF` as Nikon writes them, `.Jpg`": "ფაილის გაფართოებები ემთხვევა ასოთა რეგისტრის მიუხედავად: `IMG_0001.JPG` და `DSC_0002.NEF`, როგორც Nikon წერს, `.Jpg`", + "File name": "ფაილის სახელი", + "File types:": "ფაილის ტიპები:", + "File → Import Lightroom Catalog… opens `.lrcat` directly, with originals referenced in place.": "ფაილი → Lightroom-ის კატალოგის იმპორტი… პირდაპირ ხსნის `.lrcat`-ს, ორიგინალებზე მათ ადგილას მიმართვით.", + "File ▸ Import Keywords… and Export Keywords… read and write keyword list files as Lightroom Classic does (a keyword": "ფაილი → საკვანძო სიტყვების იმპორტი… და საკვანძო სიტყვების ექსპორტი… კითხულობს და წერს საკვანძო სიტყვების სიის ფაილებს, როგორც Lightroom Classic (თითო საკვანძო სიტყვა", + "File ▸ New Library… asks for a name, then where to put it, and opens the new library (issue #902).": "ფაილი → ახალი ბიბლიოთეკა… ითხოვს სახელს, შემდეგ მდებარეობას და ხსნის ახალ ბიბლიოთეკას (ხარვეზი #902).", + "File ▸ Open Recent Library lists the last 8 libraries; ones that are gone (moved, drive disconnected) are greyed": "ფაილი → ბოლოს გახსნილი ბიბლიოთეკის გახსნა ბოლო 8 ბიბლიოთეკას აჩვენებს; გამქრალი (გადატანილი, დისკი გამორთულია) ბიბლიოთეკები", + "Filename": "ფაილის სახელი", + "Files with the same content as another file in this import: only the first is imported.": "ფაილები, რომელთა შიგთავსი ამ იმპორტის სხვა ფაილს ემთხვევა: იმპორტდება მხოლოდ პირველი.", + "Fill": "შევსება", + "Fill Edges": "კიდეების შევსება", + "Fill Location": "მდებარეობის შევსება", + "Fill Location from GPS": "მდებარეობის შევსება GPS-ით", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "ცარიელი ქალაქის, შტატისა და ქვეყნის შევსება GPS-ით (GeoNames-ის ადგილების სახელები, ოფლაინ)", + "Film": "ფირი", + "Filmstrip": "კადრების ზოლი", + "Filmstrip (/)": "კადრების ზოლი (/)", + "Filter": "ფილტრი", + "Filter Bar": "ფილტრების ზოლი", + "Filter Keywords": "საკვანძო სიტყვების გაფილტვრა", + "Filter Presets": "ფილტრის პრესეტები", + "Filter bar": "ფილტრების ზოლი", + "Filter fields": "ფილტრის ველები", + "Find Blemishes": "ლაქების მოძებნა", + "Find Dust Spots": "მტვრის ლაქების მოძებნა", + "Find Missing Photos": "დაკარგული ფოტოების მოძებნა", + "Find Missing Photos…": "დაკარგული ფოტოების მოძებნა…", + "Find Similar Photos": "მსგავსი ფოტოების მოძებნა", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "ამ საქაღალდეში დისკზე დამატებული ან მისგან გამქრალი ფოტოების და სხვა აპების მიერ შეცვლილი XMP თანმხლები ფაილების მოძებნა", + "Find tools and commands…": "ხელსაწყოებისა და ბრძანებების ძებნა…", + "Finding faces": "სახეების ძებნა", + "Find…": "ძებნა…", + "First": "პირველი", + "First commit": "პირველი კომიტი", + "Fit": "ჩატევა", + "Fit (F)": "ჩატევა (F)", + "Fit on Screen": "ეკრანში ჩატევა", + "Fit to width": "სიგანეში ჩატევა", + "Fit visible": "ხილულში ჩატევა", + "Five stars": "ხუთი ვარსკვლავი", + "Fix the marked rules to save this album.": "ალბომის შესანახად გაასწორეთ მონიშნული წესები.", + "Fixed keys": "ფიქსირებული კლავიშები", + "Flag": "დროშა", + "Flag as Pick": "რჩეულად მონიშვნა", + "Flag as Reject": "უარყოფილად მონიშვნა", + "Flagged as Pick": "მონიშნულია რჩეულად", + "Flagged as Reject": "მონიშნულია უარყოფილად", + "Flash": "ფლეში", + "Flip H": "ჰორიზ. არეკვლა", + "Flip Horizontal": "ჰორიზონტალურად არეკვლა", + "Flip Overlay (⇧O)": "ზედდების არეკვლა (⇧O)", + "Flip V": "ვერტ. არეკვლა", + "Flip Vertical": "ვერტიკალურად არეკვლა", + "Float panel": "მცურავი პანელი", + "Flow": "ნაკადი", + "Fluorescent": "ფლუორესცენტური", + "Focal Length": "ფოკუსური მანძილი", + "Focal length": "ფოკუსური მანძილი", + "Focus (assisted culling)": "ფოკუსი (დამხმარე გადარჩევა)", + "Folder": "საქაღალდე", + "Folder colour labels": "საქაღალდის ფერადი იარლიყები", + "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} გადაღების დროს იყენებს; ფოტო მის გარეშე დღევანდელ თარიღს იყენებს.", + "Folder…. The folder and the folders inside it are scanned in the background for photos added on disk, photos whose": "სინქრონიზაცია…. საქაღალდე და მისი ქვესაქაღალდეები ფონურად სკანირდება დისკზე დამატებული ფოტოების, ისეთი ფოტოების, რომელთა", + "Food": "საჭმელი", + "Forget Unchanged Local Photos": "უცვლელი ლოკალური ფოტოების დავიწყება", + "Forget unchanged photos": "უცვლელი ფოტოების დავიწყება", + "Form fields panel": "ფორმის ველების პანელი", + "Format": "ფორმატი", + "Formats": "ფორმატები", + "Forward": "წინ", + "Found automatically": "ავტომატურად მოიძებნა", + "Fr": "პარ", + "Frame time": "კადრის დრო", + "Frame update": "კადრის განახლება", + "Free": "თავისუფალი", + "Fresh & Bright": "სუფთა და კაშკაშა", + "Friday": "პარასკევი", + "Fujifilm RAFs from 59 camera models now get the lens correction the camera stores in the file: distortion (with the": "59 კამერის მოდელის Fujifilm RAF-ები ახლა იღებს ობიექტივის კორექციას, რომელსაც კამერა ფაილში ინახავს: დეფორმაცია (", + "Fujifilm lens corrections": "Fujifilm-ის ობიექტივის კორექციები", + "Full": "სრული", + "Full Screen Preview": "სრულეკრანიანი გადახედვა", + "Full screen mode": "სრულეკრანიანი რეჟიმი", + "Full size": "სრული ზომა", + "GPS Track Log": "GPS მარშრუტის ჟურნალი", + "GPU Rendering": "GPU რენდერირება", + "GPU rendering": "GPU რენდერირება", + "General": "ზოგადი", + "Generated demo photo": "გენერირებული სადემონსტრაციო ფოტო", + "Geometry": "გეომეტრია", + "Get Develop Settings": "დამუშავების პარამეტრების აღება", + "Global Hue": "გლობალური ფერის ტონი", + "Global Luminance": "გლობალური სიკაშკაშე", + "Global Saturation": "გლობალური გაჯერება", + "Glossy": "პრიალა", + "Go back to listing them by name": "სახელით ჩამონათვალზე დაბრუნება", + "Go to History Step": "ისტორიის ნაბიჯზე გადასვლა", + "Golden": "ოქროსფერი", + "Golden Glow": "ოქროსფერი ნათება", + "Golden Hour": "ოქროს საათი", + "Grab bag": "სხვადასხვა", + "Grain": "მარცვლოვნება", + "Graphic": "გრაფიკა", + "Graphics card": "ვიდეობარათი", + "Green": "მწვანე", + "Green Amount": "მწვანის სიძლიერე", + "Green Hue": "მწვანის ფერის ტონი", + "Green Hue High": "მწვანის ფერის ტონის ზედა ზღვარი", + "Green Hue Low": "მწვანის ფერის ტონის ქვედა ზღვარი", + "Green Luminance": "მწვანის სიკაშკაშე", + "Green Primary": "მწვანე ძირითადი", + "Green Saturation": "მწვანის გაჯერება", + "Green channel": "მწვანე არხი", + "Green label": "მწვანე იარლიყი", + "Grid": "ბადე", + "Grid Info": "ინფორმაცია ბადეში", + "Gritty Street": "უხეში ქუჩა", + "Group": "ჯგუფი", + "Group by Date: Automatic": "თარიღით დაჯგუფება: ავტომატური", + "Group by Day": "დღით დაჯგუფება", + "Group by Month": "თვით დაჯგუფება", + "Group by Year": "წლით დაჯგუფება", + "Group by date": "თარიღით დაჯგუფება", + "Group into Stack": "დასტაში გაერთიანება", + "Group name": "ჯგუფის სახელი", + "Guided": "მართვადი", + "Guided Upright": "მართვადი Upright", + "Guided Upright Guides": "მართვადი Upright-ის დამხმარე ხაზები", + "HDR": "HDR", + "HDR Headroom": "HDR მარაგი", + "HDR Merge": "HDR გაერთიანება", + "HDR Merge Preview": "HDR გაერთიანების გადახედვა", + "HDR PNG export": "HDR PNG ექსპორტი", + "HDR Panorama Merge": "HDR პანორამის გაერთიანება", + "HDR Panorama Merge Preview": "HDR პანორამის გაერთიანების გადახედვა", + "HDR Panorama with Last Settings": "HDR პანორამა ბოლო პარამეტრებით", + "HDR Panoramas": "HDR პანორამები", + "HDR Panorama…": "HDR პანორამა…", + "HDR and panorama merges of CR2 keep their colour": "CR2-ის HDR და პანორამული გაერთიანება ფერს ინარჩუნებს", + "HDR panorama": "HDR პანორამა", + "HDR panoramas": "HDR პანორამები", + "HDR with Last Settings": "HDR ბოლო პარამეტრებით", + "HDR…": "HDR…", + "HEIC / HEIF photos (iPhone and Mac) open now: the optional `lightcraft-heif` crate (heic-rs, pure Rust) behind": "HEIC / HEIF ფოტოები (iPhone და Mac) ახლა იხსნება: არასავალდებულო `lightcraft-heif` crate (heic-rs, სუფთა Rust)", + "HEIC colours now match libheif to within one code value (they were up to ~10 off): the HEVC stream's own": "HEIC-ის ფერები ახლა libheif-ს ერთი კოდური მნიშვნელობის სიზუსტით ემთხვევა (ადრე ~10-მდე სცდებოდა): HEVC ნაკადის საკუთარი", + "Has Edits": "აქვს ცვლილებები", + "Has GPS": "აქვს GPS", + "Heal": "აღდგენა", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "სენსორის მტვრის მიერ გლუვ არეებზე დატოვებული პატარა, რბილი მუქი ლაქების აღდგენა", + "Height": "სიმაღლე", + "Height (px)": "სიმაღლე (პქს)", + "Help": "დახმარება", + "Help → Keyboard Shortcuts includes the number-key bindings. See [library shortcuts](library-shortcuts.md).": "დახმარება → კლავიატურის მალსახმობები რიცხვითი კლავიშების მინიჭებებსაც შეიცავს. იხ. [ბიბლიოთეკის მალსახმობები](library-shortcuts.md).", + "Help → Open Log Folder shows that log file in the file manager (Finder, Explorer, or the folder on Linux), so it": "დახმარება → ჟურნალის საქაღალდის გახსნა ამ ჟურნალის ფაილს ფაილების მენეჯერში აჩვენებს (Finder, Explorer ან საქაღალდე Linux-ზე), ასე რომ ის", + "Hide LightCraft": "LightCraft-ის დამალვა", + "Hide Mask": "ნიღბის დამალვა", + "Hide Others": "სხვების დამალვა", + "Hide This Section": "ამ სექციის დამალვა", + "Hide the sections you don't use: View ▸ Sidebar Sections, or right-click a header ▸ Hide This Section. Right-click": "დამალეთ სექციები, რომლებსაც არ იყენებთ: ხედი → გვერდითი ზოლის სექციები, ან მარჯვენა დაწკაპუნება სათაურზე → ამ სექციის დამალვა. მარჯვენა დაწკაპუნებით", + "Hides this shortcut only; the folder and its photos stay as they are": "მხოლოდ ამ მალსახმობს მალავს; საქაღალდე და მისი ფოტოები ისე რჩება, როგორც არის", + "High": "მაღალი", + "High Contrast B&W": "მაღალკონტრასტული შავ-თეთრი", + "Highlight": "ღია ტონები", + "Highlights": "ღია ტონები", + "Highlights Hue": "ღია ტონების ფერის ტონი", + "Highlights Luminance": "ღია ტონების სიკაშკაშე", + "Highlights Saturation": "ღია ტონების გაჯერება", + "Highlights and Shadows (process 2)": "ღია ტონები და ჩრდილები (პროცესი 2)", + "Highlights and Shadows keep the fine detail of the tones they move (issue #632): pulling Highlights down on a bright": "ღია ტონები და ჩრდილები ინარჩუნებს იმ ტონების წვრილ დეტალებს, რომლებსაც ცვლის (ხარვეზი #632): ნათელ ცაზე ღია ტონების", + "Highlights split": "ღია ტონების გაყოფა", + "Histogram": "ჰისტოგრამა", + "History": "ისტორია", + "History & Activity (Y)": "ისტორია და აქტივობა (Y)", + "Home": "საშინაო საქაღალდე", + "Horizontal": "ჰორიზონტალური", + "Horizontal inset (% of short edge)": "ჰორიზონტალური შეწევა (მოკლე გვერდის %)", + "Horizontal text": "ჰორიზონტალური ტექსტი", + "Hours": "საათი", + "How to install the model": "როგორ დავაინსტალიროთ მოდელი", + "Hue": "ფერის ტონი", + "Hue Range": "ფერის ტონის დიაპაზონი", + "I have read these terms and accept them for my own use": "წავიკითხე ეს პირობები და ვეთანხმები მათ ჩემი საკუთარი გამოყენებისთვის", + "ICC Profiles": "ICC პროფილები", + "ISO Speed": "ISO მგრძნობიარობა", + "ISO speed": "ISO მგრძნობიარობა", + "If LightCraft can't open a photo (its original is on a drive that isn't connected, or the file is damaged) but has": "თუ LightCraft ვერ ხსნის ფოტოს (მისი ორიგინალი გამორთულ დისკზეა, ან ფაილი დაზიანებულია), მაგრამ აქვს", + "If file exists": "თუ ფაილი არსებობს", + "If the desktop app can't open its window (for example when no graphics device can be used), it now says so in a": "თუ სამუშაო მაგიდის აპს ფანჯრის გახსნა არ შეუძლია (მაგალითად, როცა არცერთი გრაფიკული მოწყობილობის გამოყენება შეუძლებელია), ის ახლა ამას", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "თუ ბიბლიოთეკა გაშვებისას ვერ იხსნება (გახსნილია სხვა პროგრამაში, წაუკითხავია, მიუერთებელ დისკზეა,", + "Image": "გამოსახულება", + "Import": "იმპორტი", + "Import Date": "იმპორტის თარიღი", + "Import Keywords": "საკვანძო სიტყვების იმპორტი", + "Import Keywords…": "საკვანძო სიტყვების იმპორტი…", + "Import Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Import Lightroom Catalog…": "Lightroom-ის კატალოგის იმპორტი…", + "Import Photos": "ფოტოების იმპორტი", + "Import Photos…": "ფოტოების იმპორტი…", + "Import Point Curve Presets": "წერტილოვანი მრუდის პრესეტების იმპორტი", + "Import Point Curve Presets…": "წერტილოვანი მრუდის პრესეტების იმპორტი…", + "Import Presets": "პრესეტების იმპორტი", + "Import Presets…": "პრესეტების იმპორტი…", + "Import Profiles": "პროფილების იმპორტი", + "Import Profiles & Presets…": "პროფილებისა და პრესეტების იმპორტი…", + "Import as new": "იმპორტი ახლის სახით", + "Import from Device": "იმპორტი მოწყობილობიდან", + "Import from Folder…": "იმპორტი საქაღალდიდან…", + "Import new photos": "ახალი ფოტოების იმპორტი", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "ფოტოების იმპორტისთვის გამოიყენეთ ფაილი → ფოტოების იმპორტი… (Cmd+Shift+I), ან ჩააგდეთ ისინი აქ", + "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-ად.", + "Imported": "იმპორტირებული", + "Imported Photos": "იმპორტირებული ფოტოები", + "Imported `.cube` LUT profiles appear in the Profile menu and the profile browser, grouped by their folder, and stay": "იმპორტირებული `.cube` LUT პროფილები ჩანს პროფილის მენიუსა და პროფილების ბრაუზერში, საქაღალდის მიხედვით დაჯგუფებული, და", + "Importing": "იმპორტი", + "Importing Lightroom Catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing Lightroom catalog": "Lightroom-ის კატალოგის იმპორტი", + "Importing XMP presets no longer lists bookkeeping fields (`Cluster`, `SortName`, `SupportsAmount2`, the as-shot": "XMP პრესეტების იმპორტი აღარ ჩამოთვლის სამსახურებრივ ველებს (`Cluster`, `SortName`, `SupportsAmount2`, გადაღებისას", + "Importing a deleted photo again": "წაშლილი ფოტოს ხელახლა იმპორტი", + "Importing a file whose photo is in Recently Deleted brings the photo back, with its edits, rating and albums": "ფაილის იმპორტი, რომლის ფოტოც ახლახან წაშლილშია, ფოტოს აბრუნებს მისი ცვლილებებით, შეფასებითა და ალბომებით", + "Importing a large collection and opening its library no longer compare every preset with every other.": "დიდი კოლექციის იმპორტი და მისი ბიბლიოთეკის გახსნა აღარ ადარებს ყველა პრესეტს ყველა დანარჩენს.", + "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 Color": "ფერადი", + "In Photo Grid and Square Grid: Flag as Pick and advance": "ფოტოების ბადესა და კვადრატულ ბადეში: რჩეულად მონიშვნა და შემდეგზე გადასვლა", + "In Photo Grid and Square Grid: expand or collapse the stack": "ფოტოების ბადესა და კვადრატულ ბადეში: დასტის გაშლა ან ჩაკეცვა", + "In Recently Deleted": "ახლახან წაშლილში", + "In Use": "გამოიყენება", + "In a Stack": "დასტაში", + "In a long section, the header stays at the top of the sidebar while you scroll through its rows, so you always see": "გრძელ სექციაში სათაური გვერდითი ზოლის თავში რჩება, სანამ მის სტრიქონებს გადაახვევთ, ასე რომ ყოველთვის ხედავთ,", + "In library": "ბიბლიოთეკაში", + "In memory — nothing is saved": "მეხსიერებაში — არაფერი ინახება", + "In use": "გამოიყენება", + "In-memory session: these settings last until LightCraft quits.": "სესია მეხსიერებაში: ეს პარამეტრები LightCraft-იდან გასვლამდე მოქმედებს.", + "Include on Export": "ექსპორტისას ჩართვა", + "Include selected photos": "მონიშნული ფოტოების ჩართვა", + "Include subfolders": "ქვესაქაღალდეების ჩართვა", + "Increase Brush Feather": "ფუნჯის კიდეების შერბილების გაზრდა", + "Increase Brush Size": "ფუნჯის ზომის გაზრდა", + "Increase Rating": "შეფასების გაზრდა", + "Index Faces": "სახეების ინდექსირება", + "Index Faces in the Background": "სახეების ინდექსირება ფონურ რეჟიმში", + "Info": "ინფორმაცია", + "Info (I)": "ინფორმაცია (I)", + "Info Overlay Off": "ინფორმაციის ზედდების გამორთვა", + "Info Overlay: Exposure": "ინფორმაციის ზედდება: ექსპოზიცია", + "Info Overlay: File": "ინფორმაციის ზედდება: ფაილი", + "Info overlay": "ინფორმაციის ზედდება", + "Insert at the cursor": "კურსორის ადგილას ჩასმა", + "Inside This Keyword, Purge Unused Keywords.": "ამ საკვანძო სიტყვის შიგნით, გამოუყენებელი საკვანძო სიტყვების გაწმენდა.", + "Inspect Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Inspect Face Model File": "სახეების მოდელის ფაილის შემოწმება", + "Inspect Lightroom Catalog": "Lightroom-ის კატალოგის შემოწმება", + "Inspect Photo": "ფოტოს შემოწმება", + "Inspecting Lightroom Catalog": "Lightroom-ის კატალოგი მოწმდება", + "Install Denoise Model": "ხმაურის მოცილების მოდელის ინსტალაცია", + "Install Face Model": "სახეების მოდელის ინსტალაცია", + "Install a denoise model to enable AI Denoise on this photo": "ამ ფოტოზე AI ხმაურის მოცილების ჩასართავად დააინსტალირეთ ხმაურის მოცილების მოდელი", + "Install from file…": "ინსტალაცია ფაილიდან…", + "Install the SAM 3 Model": "SAM 3 მოდელის ინსტალაცია", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "დააინსტალირეთ საკუთარი ONNX მოდელი, გვერდით denoise-model.json ფაილით. ეს ვერსია მოდელების ჩამოტვირთვას არ გთავაზობთ.", + "Instant ’70s": "მყისიერი ’70-იანები", + "Intent:": "განზრახვა:", + "Interface": "ინტერფეისი", + "Interface language": "ინტერფეისის ენა", + "Intersect": "გადაკვეთა", + "Intersect with": "გადაკვეთა:", + "Into one folder": "ერთ საქაღალდეში", + "Invert": "ინვერსია", + "Invert Component": "კომპონენტის ინვერსია", + "Invert Mask": "ნიღბის ინვერსია", + "It tests this album, so testing it back would loop": "ის ამ ალბომს ამოწმებს, ამიტომ მისი უკუშემოწმება ჩაიციკლებოდა", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "ხმაურმოცილებული სურათი ჯერ არ შექმნილა, ხოლო სურათები თავისით არ იქმნება (პარამეტრები, AI ხმაურის მოცილება).", + "JPEG (Large)": "JPEG (დიდი)", + "JPEG (Small)": "JPEG (პატარა)", + "JPEG XL compressed DNGs (DNG 1.7) now open: lossless tiles decode sample for sample (checked on synthetic files);": "JPEG XL-ით შეკუმშული DNG-ები (DNG 1.7) ახლა იხსნება: უდანაკარგო ფილები ნიმუშ-ნიმუშ დეკოდირდება (შემოწმებულია სინთეზურ ფაილებზე);", + "JPEG XL export": "JPEG XL ექსპორტი", + "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`-ს,", + "Join the ArtCraft Discord": "შემოუერთდით ArtCraft-ის Discord-ს", + "Join the ArtCraft Discord…": "შემოუერთდით ArtCraft-ის Discord-ს…", + "Join the ArtCraft community on Discord": "შემოუერთდით ArtCraft-ის საზოგადოებას Discord-ზე", + "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 MB-ია თითო ფოტოზე. ესკიზების ქეში ბიბლიოთეკაში რჩება.", + "Kelvin white balance for more Sony cameras": "თეთრის ბალანსი კელვინებში მეტი Sony კამერისთვის", + "Kelvin white balance for raws whose camera has colour matrices": "თეთრის ბალანსი კელვინებში იმ RAW-ებისთვის, რომელთა კამერასაც ფერთა მატრიცები აქვს", + "Keyboard Shortcuts": "კლავიატურის მალსახმობები", + "Keyboard shortcuts": "კლავიატურის მალსახმობები", + "Keyword": "საკვანძო სიტყვა", + "Keyword Count": "საკვანძო სიტყვების რაოდენობა", + "Keyword Info": "საკვანძო სიტყვის ინფორმაცია", + "Keyword List": "საკვანძო სიტყვების სია", + "Keyword Lists": "საკვანძო სიტყვების სიები", + "Keyword Name": "საკვანძო სიტყვის სახელი", + "Keyword Painter": "საკვანძო სიტყვების ფუნჯი", + "Keyword Set": "საკვანძო სიტყვების ნაკრები", + "Keyword Set ▸ Edit Set… edits a set's nine keywords in place, one field per ⌥ key, and renames it (or saves the edits as a new": "საკვანძო სიტყვების ნაკრები → ნაკრების რედაქტირება… არედაქტირებს ნაკრების ცხრა საკვანძო სიტყვას ადგილზე, თითო ველი თითო ⌥ კლავიშზე, და გადაარქმევს მას (ან ცვლილებებს ახალ", + "Keyword Sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword Suggestions": "შემოთავაზებული საკვანძო სიტყვები", + "Keyword Tag Options": "საკვანძო სიტყვის პარამეტრები", + "Keyword lists": "საკვანძო სიტყვების სიები", + "Keyword sets": "საკვანძო სიტყვების ნაკრებები", + "Keyword tag options decide what exported files carry: Include on Export, Export Containing Keywords, Export": "საკვანძო სიტყვის პარამეტრები განსაზღვრავს, რას შეიცავს ექსპორტირებული ფაილები: ექსპორტში ჩართვა, მშობელი საკვანძო სიტყვების ექსპორტი,", + "Keyword to paint": "მისანიჭებელი საკვანძო სიტყვა", + "Keywording box": "საკვანძო სიტყვების ველი", + "Keywords": "საკვანძო სიტყვები", + "Keywords & People": "საკვანძო სიტყვები და ადამიანები", + "Keywords (K)": "საკვანძო სიტყვები (K)", + "Keywords (⌥1–⌥9)": "საკვანძო სიტყვები (⌥1–⌥9)", + "Keywords has a + in its header, like Albums: Create Keyword Tag without opening the Keyword List.": "საკვანძო სიტყვების სათაურს აქვს +, როგორც ალბომებს: საკვანძო სიტყვის შექმნა საკვანძო სიტყვების სიის გაუხსნელად.", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "თქვენ მიერ დამატებული საკვანძო სიტყვები აქ ჩნდება; ⌥1–⌥9 მათ მიანიჭებს.", + "LUT-based": "LUT-ზე დაფუძნებული", + "Label": "იარლიყი", + "Label Set": "იარლიყების ნაკრები", + "Landscape": "პეიზაჟი", + "Landscape (wide)": "ჰორიზონტალური (ფართო)", + "Language": "ენა", + "Languages": "ენები", + "Large preset collections": "პრესეტების დიდი კოლექციები", + "Last": "ბოლო", + "Last GPU fallback": "GPU-დან ბოლო გადართვა", + "Last commit": "ბოლო კომიტი", + "Last loupe render": "ლუპის ბოლო რენდერი", + "Last view": "ბოლო ხედი", + "Lateral Chromatic Aberration": "განივი ქრომატული აბერაცია", + "Layer": "შრე", + "Layers": "შრეები", + "Leaf and Hasselblad raws": "Leaf-ისა და Hasselblad-ის RAW ფაილები", + "Leave them": "მათი დატოვება", + "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.": "მარცხნივ: სტანდარტული (SDR) დიაპაზონი SDR თეთრამდე. მარჯვნივ: HDR დიაპაზონი, თითო ზოლი SDR თეთრზე ზემოთ თითო საფეხურზე (ნარინჯისფერი ხაზი: HDR მარაგი). როცა HDR დიაპაზონის ვიზუალიზაცია ჩართულია, ზოლები მის ფერებს იღებს.", + "Lens": "ობიექტივი", + "Lens Blur": "ლინზის ბუნდოვნება", + "Lens Corrections": "ობიექტივის კორექციები", + "Level": "დონე", + "Library": "ბიბლიოთეკა", + "Library Folders": "ბიბლიოთეკის საქაღალდეები", + "Library Info": "ბიბლიოთეკის ინფორმაცია", + "Library Originals": "ბიბლიოთეკის ორიგინალები", + "Library Preferences": "ბიბლიოთეკის პარამეტრები", + "Library State": "ბიბლიოთეკის მდგომარეობა", + "Library and views": "ბიბლიოთეკა და ხედები", + "Library keyboard culling": "ბიბლიოთეკაში კლავიატურით გადარჩევა", + "Library name": "ბიბლიოთეკის სახელი", + "Library saved": "ბიბლიოთეკა შენახულია", + "Library sorting": "ბიბლიოთეკის სორტირება", + "Licence:": "ლიცენზია:", + "Light": "სინათლე", + "Light Mode": "ღია რეჟიმი", + "Light Theme": "ღია თემა", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 და უფრო ძველი ვერსიები მას არ იღებს (\"unsupported format … v2\").", + "LightCraft Default": "LightCraft-ის ნაგულისხმევი", + "LightCraft Help": "LightCraft-ის დახმარება", + "LightCraft Preset": "LightCraft-ის პრესეტი", + "LightCraft Presets": "LightCraft-ის პრესეტები", + "LightCraft Website": "LightCraft-ის ვებსაიტი", + "LightCraft could not start": "LightCraft ვერ გაეშვა", + "LightCraft couldn't find where to keep your library.": "LightCraft-მა ვერ იპოვა, სად შეინახოს თქვენი ბიბლიოთეკა.", + "LightCraft couldn't open the library at": "LightCraft-მა ვერ გახსნა ბიბლიოთეკა მისამართზე", + "LightCraft does not know this model, so it assumed:": "LightCraft-მა ეს მოდელი არ იცის, ამიტომ ივარაუდა:", + "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", + "LightCraft on GitHub": "LightCraft GitHub-ზე", + "LightCraft program\" although nothing had it open. Nothing in the shipped app is known to hit the window today; it": "LightCraft-ის პროგრამაში“, თუმცა არაფერს ჰქონდა ის გახსნილი. გავრცელებული აპი, რამდენადაც ცნობილია, დღეს ამ ფანჯარას ვერ ხვდება;", + "LightCraft the copy shows what you saved.": "LightCraft-ში, ასლი აჩვენებს იმას, რაც შეინახეთ.", + "LightCraft was restarted. The bug is still written to the log (Help ▸ Open Log Folder) for a report.": "სანამ LightCraft ხელახლა არ გაეშვებოდა. ხარვეზი კვლავ იწერება ჟურნალში (დახმარება → ჟურნალების საქაღალდის გახსნა) ანგარიშისთვის.", + "LightCraft website": "LightCraft-ის ვებსაიტი", + "LightCraft — Second Window": "LightCraft — მეორე ფანჯარა", + "LightCraft. Importing adds the keywords you don't have, in one undo step; a file it can't read says which line, or": "LightCraft-ს შორის გადადის. იმპორტი ამატებს საკვანძო სიტყვებს, რომლებიც არ გაქვთ, ერთ დაბრუნების ნაბიჯად; ფაილი, რომელსაც ვერ კითხულობს, ამბობს რომელ სტრიქონზე, ან", + "Lightroom Classic Catalog": "Lightroom Classic-ის კატალოგი", + "Lightroom Classic catalogs": "Lightroom Classic-ის კატალოგები", + "Lightroom import cancelled": "Lightroom-ის იმპორტი გაუქმდა", + "Lightroom inspection cancelled": "Lightroom-ის შემოწმება გაუქმდა", + "Lightroom's `crs:Temperature` for these raws is read as the Kelvin it is (sidecars, Lightroom catalogs, presets);": "Lightroom-ის `crs:Temperature` ამ RAW-ებისთვის იკითხება როგორც კელვინი (თანმხლები ფაილები, Lightroom-ის კატალოგები, პრესეტები);", + "Lights": "ნათელი ტონები", + "Lights Out": "სინათლის ჩაქრობა", + "Limit": "ზღვარი", + "Limit file size (KB, 0 = off)": "ფაილის ზომის შეზღუდვა (KB, 0 = გამორთული)", + "Line delta": "სტრიქონების სხვაობა", + "Linear": "წრფივი", + "Linear Gradient": "წრფივი გრადიენტი", + "Lines +/−": "სტრიქონები +/−", + "Lines added": "დამატებული სტრიქონები", + "Lines deleted": "წაშლილი სტრიქონები", + "List Albums": "ალბომების სია", + "List Develop Controls": "დამუშავების მართვის ელემენტების სია", + "List History": "ისტორიის სია", + "List Presets": "პრესეტების სია", + "List Profiles": "პროფილების სია", + "List this folder in Local from now on": "ამიერიდან ამ საქაღალდის ჩვენება ლოკალურში", + "Loading preview…": "წინასწარი ხედი იტვირთება…", + "Local": "ლოკალური", + "Local folders": "ლოკალური საქაღალდეები", + "Local library — no cloud account needed": "ლოკალური ბიბლიოთეკა — ღრუბლოვანი ანგარიში საჭირო არ არის", + "Locate Missing Files…": "დაკარგული ფაილების მოძებნა…", + "Locate Missing File…": "დაკარგული ფაილის მოძებნა…", + "Locate Photo": "ფოტოს მოძებნა", + "Location": "მდებარეობა", + "Lock": "ჩაკეტვა", + "Lock Crop Aspect": "ჩამოჭრის თანაფარდობის დაფიქსირება", + "Locked": "ჩაკეტილი", + "Long Edge": "გრძელი გვერდი", + "Long-running tasks show in one place, top left under the top bar, as in Lightroom Classic (issue #345): imports and": "ხანგრძლივი ამოცანები ერთ ადგილას ჩანს, ზედა მარცხნივ, ზედა ზოლის ქვეშ, როგორც Lightroom Classic-ში (ხარვეზი #345): იმპორტი და", + "Looking for changes": "ცვლილებების ძებნა", + "Looking for photos…": "ფოტოების ძებნა…", + "Looking up places": "ადგილების ძებნა", + "Lossless": "უდანაკარგო", + "Lossy DNG files and Smart Previews open as raw photos.": "დანაკარგიანი DNG ფაილები და ჭკვიანი წინასწარი ხედები იხსნება როგორც raw ფოტოები.", + "Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in": "დანაკარგიანი JPEG XL ფაილები Display P3-ში ან Rec. 2020-ში ახლა ნამდვილი ფერებით იხსნება; გაჯერებული ფერები ადრე", + "Low": "დაბალი", + "Luminance": "სიკაშკაშე", + "Luminance Range": "სიკაშკაშის დიაპაზონი", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Luminar-ის სტილები: `.lmp` ფაილები და `.mplumpack` კოლექციები იმპორტირდება როგორც პრესეტები (დაჯგუფებული კოლექციის მიხედვით); სლაიდერები", + "Lush Greens": "ხასხასა სიმწვანე", + "M50, R100 and R8. CR3 files the decoder can't read yet still open from their embedded JPEG, as before.": "M50-ზე, R100-სა და R8-ზე. CR3 ფაილები, რომლებსაც დეკოდერი ჯერ ვერ კითხულობს, კვლავ ჩაშენებული JPEG-იდან იხსნება, როგორც ადრე.", + "Magenta": "მეწამული", + "Magenta Hue": "მეწამულის ფერის ტონი", + "Magenta Luminance": "მეწამულის სიკაშკაშე", + "Magenta Saturation": "მეწამულის გაჯერება", + "Make Candidate the Select": "კანდიდატის მონიშნულად დაყენება", + "Make it now": "ახლავე შექმნა", + "Make the denoised picture of the photos I am looking at": "ხმაურმოცილებული სურათის შექმნა იმ ფოტოებისთვის, რომლებსაც ვათვალიერებ", + "Manual": "ხელით", + "Marquee zoom": "მასშტაბირება ჩარჩოთი", + "Mask Adjustment": "ნიღბის კორექცია", + "Masking": "ნიღბვა", + "Masking (M)": "ნიღბვა (M)", + "Masks": "ნიღბები", + "Match": "დამთხვევა", + "Match Total Exposures": "ჯამური ექსპოზიციების გათანაბრება", + "Matrix/TRC": "Matrix/TRC", + "Matte": "მატი", + "Matte Soft": "რბილი მატი", + "Med": "საშ.", + "Medium Contrast": "საშუალო კონტრასტი", + "Megapixels": "მეგაპიქსელი", + "Memory Budget": "მეხსიერების ლიმიტი", + "Memory Usage": "მეხსიერების გამოყენება", + "Memory budget": "მეხსიერების ლიმიტი", + "Memory for caches": "მეხსიერება ქეშისთვის", + "Menu": "მენიუ", + "Merge": "შერწყმა", + "Merge Keywords": "საკვანძო სიტყვების შერწყმა", + "Merge into…": "შერწყმა სხვასთან…", + "Merged": "შერწყმულია", + "Merged PRs": "გაერთიანებული PR-ები", + "Merging": "შერწყმა", + "Merging…": "გაერთიანება…", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Meta-ს პირობები და არა LightCraft-ის. ჩამოტვირთვა მათ მიღებას ნიშნავს.", + "Metadata": "მეტამონაცემები", + "Metadata Preset": "მეტამონაცემების პრესეტი", + "Metadata Presets": "მეტამონაცემების პრესეტები", + "Metadata preset": "მეტამონაცემების პრესეტი", + "Metadata read": "მეტამონაცემები წაკითხულია", + "Midnight": "შუაღამე", + "Midpoint": "შუა წერტილი", + "Midtones": "შუალედური ტონები", + "Midtones Hue": "შუალედური ტონების ფერის ტონი", + "Midtones Luminance": "შუალედური ტონების სიკაშკაშე", + "Midtones Saturation": "შუალედური ტონების გაჯერება", + "Midtones split": "შუალედური ტონების გაყოფა", + "Minimize": "ჩაკეცვა", + "Missing Photos": "დაკარგული ფოტოები", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "დაკარგული მეტამონაცემები ({camera}, {title}…) ცარიელ ადგილს ტოვებს; თუ სახელი ცარიელი გამოვა, ორიგინალური სახელი რჩება.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "დაკარგული ფოტოები გადადის ახლახან წაშლილში და მათი აღდგენა შეიძლება; დისკზე არცერთ ფაილს არაფერი ეხება.", + "Misty Morning": "ნისლიანი დილა", + "Mo": "ორშ", + "Mode": "რეჟიმი", + "Model": "მოდელი", + "Models": "მოდელები", + "Models are installed separately after accepting their terms; no weights are bundled. See [setup and limitations](denoise.md).": "მოდელები ცალკე დგება მათი პირობების მიღების შემდეგ; წონები არ არის ჩაშენებული. იხ. [დაყენება და შეზღუდვები](denoise.md).", + "Modified Date": "ცვლილების თარიღი", + "Monday": "ორშაბათი", + "Monitor profile": "მონიტორის პროფილი", + "Monitor profiles": "მონიტორის პროფილები", + "Mono Red Filter": "მონოქრომული წითელი ფილტრი", + "Mono Rich": "მდიდარი მონოქრომი", + "Mono Soft": "რბილი მონოქრომი", + "Monochrome": "მონოქრომული", + "Month": "თვე", + "Moody": "პირქუში", + "Moody Portrait": "პირქუში პორტრეტი", + "More": "მეტი", + "More preset options": "პრესეტის სხვა პარამეტრები", + "Move": "გადაადგილება", + "Move Album": "ალბომის გადატანა", + "Move Down": "ჩამოწევა", + "Move Down in Stack": "დასტაში ჩამოწევა", + "Move Folder": "საქაღალდის გადატანა", + "Move Folder To…": "საქაღალდის გადატანა…", + "Move Keyword": "საკვანძო სიტყვის გადატანა", + "Move Mask": "ნიღბის გადაადგილება", + "Move Preset to Group": "პრესეტის ჯგუფში გადატანა", + "Move Preset to New Group": "პრესეტის ახალ ჯგუფში გადატანა", + "Move Selected Photos Here": "მონიშნული ფოტოების აქ გადატანა", + "Move Sidebar Section": "გვერდითი ზოლის სექციის გადატანა", + "Move Up": "აწევა", + "Move Up in Stack": "დასტაში აწევა", + "Move 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": "გადატანა", + "Move to Folder": "საქაღალდეში გადატანა", + "Move to Folder…": "საქაღალდეში გადატანა…", + "Move to Group": "ჯგუფში გადატანა", + "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 თანმხლები ფაილი) წყაროდან მხოლოდ ასლის შემოწმების შემდეგ იშლება. დუბლიკატები და ფაილები, რომელთა გადატანაც ვერ მოხერხდა, ადგილზე რჩება.", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "ამ დისკიდან იმპორტირებულ ყველა ფოტოს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Moves the files and their XMP sidecars into this folder; never overwrites": "ფაილებს და მათ XMP თანმხლებ ფაილებს ამ საქაღალდეში გადაიტანს; არასოდეს გადაწერს", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "ამ საქაღალდიდან იმპორტირებულ ფოტოებს ახლახან წაშლილში გადაიტანს; ფაილებს არაფერი ეხება", + "Muted": "მკრთალი", + "Muted Film": "მკრთალი ფირი", + "My Photos": "ჩემი ფოტოები", + "My Photos Panel": "ჩემი ფოტოების პანელი", + "Name": "სახელი", + "Name (A–Z)": "სახელი (ა–ჰ)", + "Name Face": "სახისთვის სახელის მიცემა", + "Name Faces": "სახეებისთვის სახელების მიცემა", + "Name of the folder the original is in": "საქაღალდის სახელი, რომელშიც ორიგინალია", + "Named": "სახელიანი", + "Named People": "სახელიანი ადამიანები", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "სახელები ჩანს იარლიყების მენიუში, ფილტრების ზოლსა და ინფორმაციის პანელში და იწერება XMP-ში. ცარიელი = ფერის სახელი.", + "Navigator": "ნავიგატორი", + "Needs Work": "დასახვეწია", + "Neon Dusk": "ნეონის ბინდი", + "Neon Night": "ნეონის ღამე", + "Nested group": "ჩადგმული ჯგუფი", + "Neutral": "ნეიტრალური", + "Never": "არასოდეს", + "New": "ახალი", + "New Album": "ახალი ალბომი", + "New Album…": "ახალი ალბომი…", + "New Folder on Disk": "ახალი საქაღალდე დისკზე", + "New Folder on Disk…": "ახალი საქაღალდე დისკზე…", + "New Folder…": "ახალი საქაღალდე…", + "New Group…": "ახალი ჯგუფი…", + "New Library": "ახალი ბიბლიოთეკა", + "New Library: Choose Where to Put It": "ახალი ბიბლიოთეკა: აირჩიეთ, სად განთავსდეს", + "New Library…": "ახალი ბიბლიოთეკა…", + "New Smart Album": "ახალი სმარტ-ალბომი", + "New Smart Album from Filter…": "ახალი სმარტ-ალბომი ფილტრიდან…", + "New Smart Album…": "ახალი სმარტ-ალბომი…", + "New album": "ახალი ალბომი", + "New album…": "ახალი ალბომი…", + "New blank PDF": "ახალი ცარიელი PDF", + "New fields: Keyword Count (\"2 or more\", \"exactly 2\"…), People and People Count (named faces), Shutter Speed (written": "ახალი ველები: საკვანძო სიტყვების რაოდენობა („2 ან მეტი“, „ზუსტად 2“…), ადამიანები და ადამიანების რაოდენობა (სახელდებული სახეები), ჩამკეტის სიჩქარე (იწერება", + "New name": "ახალი სახელი", + "New time": "ახალი დრო", + "New…": "ახალი…", + "Next Appearance Mode": "შემდეგი იერსახის რეჟიმი", + "Next History Step": "ისტორიის შემდეგი ნაბიჯი", + "Next Photo": "შემდეგი ფოტო", + "Next up for AI Denoise…": "შემდეგია AI ხმაურის მოცილების რიგში…", + "Night Blue": "ღამის ლურჯი", + "Nikon D850, Canon EOS 5D Mark III and others) now show white balance in Kelvin, as a DNG does and as Lightroom": "Nikon D850, Canon EOS 5D Mark III და სხვები), ახლა თეთრის ბალანსს კელვინებში აჩვენებს, როგორც DNG და როგორც Lightroom:", + "Nikon High Efficiency NEF (HE / HE★) uses intoPIX TicoRAW compression. LightCraft does not currently have a license for this codec, so only the camera's embedded JPEG preview is available, not the raw sensor data. To edit raw data, shoot Lossless compressed NEF or convert existing HE files to DNG using a licensed converter.": "Nikon High Efficiency NEF (HE / HE★) იყენებს intoPIX TicoRAW შეკუმშვას. LightCraft-ს ამ კოდეკის ლიცენზია ამჟამად არ აქვს, ამიტომ ხელმისაწვდომია მხოლოდ კამერის ჩაშენებული JPEG წინასწარი ხედი და არა სენსორის RAW მონაცემები. RAW მონაცემების რედაქტირებისთვის გადაიღეთ უდანაკარგოდ შეკუმშულ NEF-ში ან არსებული HE ფაილები ლიცენზირებული კონვერტერით DNG-ად გარდაქმენით.", + "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 ფაილები, Sony ARW-ის მსგავსად, კამერის საკუთარ JPEG-ზე მორგებული ფერითა და ტონით იწყება, ნაცვლად მკრთალი,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Nikon-ის ფაილები ორად გაყოფილი, განსხვავებულად შეკუმშული ნაწილებით, ჯერჯერობით წინასწარ ხედს იყენებს.)", + "No": "არა", + "No Camera or Card Found": "კამერა ან ბარათი ვერ მოიძებნა", + "No Date Groups": "თარიღის მიხედვით დაჯგუფების გარეშე", + "No Label": "იარლიყის გარეშე", + "No Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები არ არის", + "No Stars": "ვარსკვლავების გარეშე", + "No Tool": "ხელსაწყოს გარეშე", + "No albums match": "შესაბამისი ალბომი არ არის", + "No albums yet": "ალბომები ჯერ არ არის", + "No automatic versions.": "ავტომატური ვერსიები არ არის.", + "No camera": "კამერის გარეშე", + "No contributor data was built into this copy.": "ამ ასლში მონაწილეების მონაცემები არ არის ჩაშენებული.", + "No date": "თარიღის გარეშე", + "No dust spots found": "მტვრის ლაქები ვერ მოიძებნა", + "No edits yet.": "ცვლილებები ჯერ არ არის.", + "No exposure information": "ექსპოზიციის ინფორმაცია არ არის", + "No filter presets yet": "ფილტრის პრესეტები ჯერ არ არის", + "No folders to browse": "დასათვალიერებელი საქაღალდეები არ არის", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "გრაფიკული პროცესორი ვერ მოიძებნა: LightCraft რენდერს CPU-ზე ასრულებს, რაც გაცილებით ნელია. დეტალები: დახმარება → სისტემის ინფორმაცია.", + "No imported folders yet": "იმპორტირებული საქაღალდეები ჯერ არ არის", + "No keywords are exported": "საკვანძო სიტყვები არ ექსპორტირდება", + "No keywords match": "შესაბამისი საკვანძო სიტყვა არ არის", + "No keywords yet": "საკვანძო სიტყვები ჯერ არ არის", + "No lens data embedded in this file (DNG lens opcodes only).": "ამ ფაილში ობიექტივის მონაცემები ჩაშენებული არ არის (მხოლოდ DNG-ის ობიექტივის ოპკოდები).", + "No masks yet. Choose a mask type above.": "ნიღბები ჯერ არ არის. ზემოთ აირჩიეთ ნიღბის ტიპი.", + "No matching commands": "შესაბამისი ბრძანება ვერ მოიძებნა", + "No matching photos": "შესაბამისი ფოტოები არ არის", + "No metadata found": "მეტამონაცემები ვერ მოიძებნა", + "No model credits were built into this copy.": "ამ ასლში მოდელების მონაცემები არ არის ჩაშენებული.", + "No named people in these photos": "ამ ფოტოებზე სახელიანი ადამიანები არ არის", + "No named people yet": "სახელიანი ადამიანები ჯერ არ არის", + "No new presets": "ახალი პრესეტები არ არის", + "No photo has it; the keywords below it go too.": "არცერთ ფოტოს არ აქვს; მასში ჩადგმული საკვანძო სიტყვებიც წაიშლება.", + "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 photos yet": "ფოტოები ჯერ არ არის", + "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": "გამოსადეგი GPU ვერ მოიძებნა: რენდერი CPU-ზე", + "No versions yet. Create one to keep this look.": "ვერსიები ჯერ არ არის. ამ იერსახის შესანახად შექმენით ვერსია.", + "Noise": "ხმაური", + "Noise Reduction": "ხმაურის შემცირება", + "Non-raw photos": "არა-raw ფოტოები", + "None": "არცერთი", + "None (the display is treated as sRGB)": "არცერთი (ეკრანი sRGB-ად ითვლება)", + "Not Now": "ახლა არა", + "Not saved": "არ არის შენახული", + "Nothing already edited changes: a Custom white balance set on the old relative scale renders pixel for pixel as": "არაფერი უკვე დამუშავებული არ იცვლება: ძველ ფარდობით შკალაზე დაყენებული მორგებული თეთრის ბალანსი პიქსელ-პიქსელ ისევე რენდერდება, როგორც", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "თქვენს თანხმობამდე არაფერი ჩამოიტვირთება. მოდელი მხოლოდ ამ კომპიუტერზე ინახება; LightCraft მას არასოდეს ატვირთავს და არავის უზიარებს.", + "Nothing to compare": "შესადარებელი არაფერია", + "Nothing to survey": "მიმოსახილველი არაფერია", + "Nudge Develop Value": "დამუშავების მნიშვნელობის ბიჯით შეცვლა", + "OK": "OK", + "ONNX model": "ONNX მოდელი", + "Object": "ობიექტი", + "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", + "Off": "გამორთული", + "Off by default (HEVC patents are the distributor's call, same as PhotoCraft); official builds pass `--features heif`.": "ნაგულისხმევად გამორთულია (HEVC-ის პატენტები გამავრცელებლის გადასაწყვეტია, როგორც PhotoCraft-ში); ოფიციალური აწყობები `--features heif`-ს იყენებს.", + "Offset X": "X-წანაცვლება", + "Offset Y": "Y-წანაცვლება", + "Older raw cameras": "ძველი RAW კამერები", + "Olympus E-10 raws open with the camera's white balance (White Balance ▸ As Shot) and without the grey haze in": "Olympus E-10-ის RAW ფაილები კამერის თეთრის ბალანსით (თეთრის ბალანსი → როგორც გადაღებულია) და ჩრდილებში", + "Olympus ORF raws get the starting look fitted to the camera's own JPEG too, instead of opening flat and grey (the": "Olympus ORF RAW-ებიც კამერის საკუთარ JPEG-ზე მორგებულ საწყის იერს იღებს, ბრტყლად და ნაცრისფრად გახსნის ნაცვლად (", + "On Linux and FreeBSD, file dialogs (Import Photos…, Open Library, Export, presets, keyword lists…) are children of": "Linux-სა და FreeBSD-ზე ფაილის დიალოგები (ფოტოების იმპორტი…, ბიბლიოთეკის გახსნა, ექსპორტი, პრესეტები, საკვანძო სიტყვების სიები…)", + "On NixOS, start it from `nix develop`.": "NixOS-ზე გაუშვით `nix develop`-იდან.", + "On Windows and Linux, Ctrl+C outside a text field copies edit settings and Ctrl+V pastes them (⇧Ctrl+V: Paste": "Windows-სა და Linux-ზე Ctrl+C ტექსტური ველის გარეთ რედაქტირების პარამეტრებს აკოპირებს, Ctrl+V კი ჩასვამს (⇧Ctrl+V: მონიშნული", + "On macOS, photos are shown through the profile of the screen the window is on while no monitor profile is chosen in": "macOS-ზე ფოტოები ნაჩვენებია იმ ეკრანის პროფილით, რომელზეც ფანჯარაა, სანამ მონიტორის პროფილი არჩეული არ არის", + "On macOS, ratings `0–5`, labels `6–9` and pick/unflag `P/U` now reach the app even when shown in the native menu (issue #283; adapted from PR #261).": "macOS-ზე შეფასებები `0–5`, იარლიყები `6–9` და რჩეული/დროშის მოხსნა `P/U` ახლა აპს აღწევს მაშინაც, როცა ისინი მშობლიურ მენიუშია (ხარვეზი #283; ადაპტირებულია PR #261-დან).", + "On macOS, single-key shortcuts that appear in the menu bar now work: E, C, H, M, ⇧P, I, K, D, ratings 0–5,": "macOS-ზე მენიუს ზოლში ნაჩვენები ერთკლავიშიანი მალსახმობები ახლა მუშაობს: E, C, H, M, ⇧P, I, K, D, შეფასებები 0–5,", + "Only": "მხოლოდ", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "მუშავდება მხოლოდ ის ფოტოები, რომელთა AI ხმაურის მოცილების სიძლიერე 0-ზე მეტია. ექსპორტი ყოველთვის ქმნის თავისას, თუ ის აკლია.", + "Opacity": "გაუმჭვირვალობა", + "Opacity (%)": "გაუმჭვირვალობა (%)", + "Open Library": "ბიბლიოთეკის გახსნა", + "Open Library knows a Lightroom catalog": "ბიბლიოთეკის გახსნა ცნობს Lightroom-ის კატალოგს", + "Open Library…": "ბიბლიოთეკის გახსნა…", + "Open Log Folder": "ჟურნალების საქაღალდის გახსნა", + "Open Original in Other Editor (RAW, no copy)": "ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე)", + "Open Recent Library": "ბოლოს გახსნილი ბიბლიოთეკის გახსნა", + "Open in": "გახსნა:", + "Open in Detail": "დეტალურ ხედში გახსნა", + "Open page": "გვერდის გახსნა", + "Open the model's page": "მოდელის გვერდის გახსნა", + "Open the place in OpenStreetMap": "ადგილის გახსნა OpenStreetMap-ში", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "მოდელის საკუთარ გვერდს თქვენს ბრაუზერში ხსნის, რომ მის შესახებ წაიკითხოთ ან ფაილი თავად აიღოთ.", + "Open…": "გახსნა…", + "Optics": "ოპტიკა", + "Optics, Geometry and Calibration (issue #316).": "ოპტიკის, გეომეტრიის და კალიბრაციისთვის (ხარვეზი #316).", + "Optimize Library": "ბიბლიოთეკის ოპტიმიზაცია", + "Optional name": "არასავალდებულო სახელი", + "Optional remote SAM 3: keep the native editor local and run Object, Describe, and detail": "არასავალდებულო დისტანციური SAM 3: მშობლიური რედაქტორი ლოკალურად დატოვეთ, ობიექტის, აღწერის და დეტალის", + "Options": "პარამეტრები", + "Orange": "ნარინჯისფერი", + "Orange Hue": "ნარინჯისფერის ფერის ტონი", + "Orange Luminance": "ნარინჯისფერის სიკაშკაშე", + "Orange Saturation": "ნარინჯისფერის გაჯერება", + "Orientation": "ორიენტაცია", + "Original": "ორიგინალი", + "Original + Settings": "ორიგინალი + პარამეტრები", + "Original extension without the dot": "ორიგინალური გაფართოება წერტილის გარეშე", + "Original file name without its extension": "ორიგინალური ფაილის სახელი გაფართოების გარეშე", + "Original files": "ორიგინალური ფაილები", + "Original offline · editing the smart preview": "ორიგინალი მიუწვდომელია · რედაქტირდება ჭკვიანი წინასწარი ხედი", + "Original offline · no smart preview": "ორიგინალი მიუწვდომელია · ჭკვიანი წინასწარი ხედი არ არის", + "Originals": "ორიგინალები", + "Other editor": "სხვა რედაქტორი", + "Other images (JPEG, PNG, TIFF, HEIC…)": "სხვა გამოსახულებები (JPEG, PNG, TIFF, HEIC…)", + "Other…": "სხვა…", + "Outside the crop box, where dragging rotates the photo, the pointer is a curved double arrow, and the angle is shown": "ჩამოჭრის ჩარჩოს გარეთ, სადაც გადათრევა ფოტოს აბრუნებს, მაჩვენებელი მრუდი ორმაგი ისარია, ხოლო კუთხე", + "Overlay": "ზედდება", + "Overwrite": "გადაწერა", + "Pages": "გვერდები", + "Paint": "ხატვა", + "Paint only areas like the one under the brush": "მხოლოდ ფუნჯის ქვეშ მყოფის მსგავსი არეების დახატვა", + "Paint over a distraction on the photo to remove it.": "ფოტოზე ზედმეტ დეტალს გადაუსვით ფუნჯი მის მოსაშორებლად.", + "Paint overlay": "საღებავით გადაფარვა", + "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 მონაცემებიდან", + "Panasonic and Leica raws (RW2, RWL) are now corrected for lens distortion the way the camera corrects its own JPEG": "Panasonic-ისა და Leica-ს RAW-ები (RW2, RWL) ახლა ობიექტივის დეფორმაციისთვის ისე სწორდება, როგორც კამერა საკუთარ JPEG-ს ასწორებს", + "Panels": "პანელები", + "Panning a photo zoomed to 1:1, or moving a slider while zoomed, no longer decodes the whole raw again each time on": "1:1-ზე გადიდებული ფოტოს გადაადგილება ან სლაიდერის მოძრაობა გადიდებისას აღარ დეკოდირებს მთელ RAW-ს ყოველ ჯერზე", + "Panorama": "პანორამა", + "Panorama Merge": "პანორამად გაერთიანება", + "Panorama Merge Preview": "პანორამად გაერთიანების გადახედვა", + "Panorama with Last Settings": "პანორამა ბოლო პარამეტრებით", + "Panoramas": "პანორამები", + "Panorama…": "პანორამა…", + "Paper": "ქაღალდი", + "Parametric curve": "პარამეტრული მრუდი", + "Paste": "ჩასმა", + "Paste Edit Settings": "რედაქტირების პარამეტრების ჩასმა", + "Paste Metadata": "მეტამონაცემების ჩასმა", + "Paste Selected Settings": "არჩეული პარამეტრების ჩასმა", + "Paste Selected Settings…": "არჩეული პარამეტრების ჩასმა…", + "Paste Settings": "პარამეტრების ჩასმა", + "Paste Settings from Previous": "წინა ფოტოს პარამეტრების ჩასმა", + "Paste from Previous": "წინადან ჩასმა", + "Paste pages": "გვერდების ჩასმა", + "Pastel Haze": "პასტელური ბურუსი", + "Pending photo action cancelled": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა", + "Pending photo action cancelled because its library or photo changed": "ფოტოს მოლოდინში მყოფი მოქმედება გაუქმდა, რადგან მისი ბიბლიოთეკა ან ფოტო შეიცვალა", + "Pentax raws without black borders": "Pentax-ის RAW ფაილები შავი კიდეების გარეშე", + "People": "ადამიანები", + "People Count": "ადამიანების რაოდენობა", + "Percent": "პროცენტული", + "Percentage": "პროცენტული", + "Perceptual": "აღქმითი", + "Performance": "წარმადობა", + "Person": "ადამიანი", + "Perspective": "პერსპექტივა", + "Pet Eye": "ცხოველის თვალები", + "Pet Eye Catchlight": "ცხოველის თვალის ანარეკლი", + "Photo": "ფოტო", + "Photo Above": "ზემოთ მდებარე ფოტო", + "Photo Below": "ქვემოთ მდებარე ფოტო", + "Photo Grid": "ფოტოების ბადე", + "Photo Grid (G)": "ფოტოების ბადე (G)", + "Photo Merge": "ფოტოების გაერთიანება", + "Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and": "Canon CR2-ის (და სხვა RAW ფაილების, რომელთა ფერსაც LightCraft კამერის საკუთარი JPEG-იდან არგებს, როგორიცაა NEF და", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "„ფოტო“ მენიუში. კადრების ზოლსაც აქვს ფოტოს კონტექსტური მენიუ. ახლახან წაშლილში მყოფი ფაილის ხელახლა დამატება აღარ", + "Photo ▸ Edit In ▸ Edit in PhotoCraft (⇧⌘E, or right-click a photo) renders a copy with your edits, stacks it with the original and opens it in PhotoCraft. Save there and the copy updates here. As PSD, PhotoCraft saves straight back to it. Leave the location empty unless you use a portable copy.": "ფოტო → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (⇧⌘E, ან მარჯვენა დაწკაპუნება ფოტოზე) არენდერებს ასლს თქვენი ცვლილებებით, ორიგინალთან ერთ დასტაში აერთიანებს და PhotoCraft-ში ხსნის. იქ შენახვისას ასლი აქაც განახლდება. PSD-ის შემთხვევაში PhotoCraft პირდაპირ მასში ინახავს. მდებარეობა ცარიელი დატოვეთ, თუ პორტატულ ასლს არ იყენებთ.", + "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 are shown through your monitor's ICC profile, so colours are right on wide-gamut and calibrated displays, and the Detail view uses the display's whole gamut. The histogram, the preview caches and exports are not affected.": "ფოტოები თქვენი მონიტორის ICC პროფილით ნაჩვენებია, ამიტომ ფერები სწორია ფართო ფერთა გამისა და დაკალიბრებულ ეკრანებზე, ხოლო დეტალური ხედი ეკრანის მთელ ფერთა გამას იყენებს. ჰისტოგრამაზე, წინასწარი ხედების ქეშებსა და ექსპორტზე ეს გავლენას არ ახდენს.", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "ახლახან წაშლილში მყოფი ფოტოების აღდგენა შეიძლება აპიდან: მარჯვენა დაწკაპუნება → აღდგენა (ან სამუდამოდ წაშლა), აგრეთვე", + "Photos per bracket (0 = auto)": "ფოტო ექსპოზიციის სერიაში (0 = ავტომატური)", + "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 stay in All Photos. Deleting a folder also deletes its albums. You can undo this.": "ფოტოები „ყველა ფოტოში“ რჩება. საქაღალდის წაშლა მის ალბომებსაც შლის. ამის დაბრუნება შეიძლება.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "ამ საქაღალდეში შემოსული ფოტოები (კამერასთან პირდაპირი კავშირი, სკანერი, სინქრონიზაციის აპი) ემატება, როგორც კი სრულად ჩაიწერება.", + "Photos that tie on the sort key (equal ratings, one import batch, a burst shot within the same second) now sort by": "ფოტოები, რომლებიც სორტირების გასაღებით თანაბარია (თანაბარი შეფასება, ერთი იმპორტის პარტია, ერთსა და იმავე წამში გადაღებული სერია), ახლა სორტირდება", + "Photos with deep shadows no longer have to extend the first measured tone in a straight line to black.": "ღრმა ჩრდილების მქონე ფოტოებში პირველი გაზომილი ტონი აღარ უნდა გაგრძელდეს სწორი ხაზით შავამდე.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "ფოტოები); იმპორტი (გადათრევითაც), დაკარგული ფოტოების მოძებნა, ჭკვიანი წინასწარი ხედების შექმნა / მოცილება, გადარქმევის გადახედვა,", + "Pick": "რჩეული", + "Pick Flag": "რჩეულის დროშა", + "Pick a colour on the photo with the eyedropper.": "ფოტოზე ფერის არჩევა პიპეტით.", + "Pick a date": "თარიღის არჩევა", + "Pick the sharpest photo of each burst": "თითოეული სერიიდან ყველაზე მკვეთრი ფოტოს არჩევა", + "Picked": "რჩეული", + "Picks": "რჩეულები", + "Pictures": "სურათები", + "Pixels": "პიქსელი", + "Place Names Status": "ადგილების სახელების სტატუსი", + "Place names download cancelled": "ადგილების სახელების ჩამოტვირთვა გაუქმდა", + "Place names downloaded": "ადგილების სახელები ჩამოტვირთულია", + "Point Color": "წერტილის ფერი", + "Point Curve": "წერტილოვანი მრუდი", + "Point Curve Presets": "წერტილოვანი მრუდის პრესეტები", + "Point curve": "წერტილოვანი მრუდი", + "Portrait": "პორტრეტი", + "Portrait (tall)": "ვერტიკალური (მაღალი)", + "Position": "პოზიცია", + "Preferences": "პარამეტრები", + "Preferences…": "პარამეტრები…", + "Prepare AI Masks": "AI ნიღბების მომზადება", + "Preset": "პრესეტი", + "Preset name": "პრესეტის სახელი", + "Presets": "პრესეტები", + "Presets & Profiles": "პრესეტები და პროფილები", + "Presets (Shift+P)": "პრესეტები (Shift+P)", + "Presets and profiles": "პრესეტები და პროფილები", + "Presets ▾": "პრესეტები →", + "Press keys…": "დააჭირეთ კლავიშებს…", + "Preview": "გადახედვა", + "Preview Build Progress": "წინასწარი ხედების შექმნის პროგრესი", + "Preview only": "მხოლოდ წინასწარი ხედი", + "Preview only: editing the camera's embedded JPEG": "მხოლოდ წინასწარი ხედი: რედაქტირდება კამერის ჩაშენებული JPEG", + "Preview or Photos. Moving the window to another screen is followed; **Use Screen Profile** goes back to it after": "Preview-სა ან Photos-ში. ფანჯრის სხვა ეკრანზე გადატანაც გაითვალისწინება; **ეკრანის პროფილის გამოყენება** მას უბრუნდება", + "Preview size": "წინასწარი ხედის ზომა", + "Preview unavailable": "წინასწარი ხედი მიუწვდომელია", + "Previews": "წინასწარი ხედები", + "Previews match exports on Mac screens": "წინასწარი ხედები ემთხვევა ექსპორტს Mac-ის ეკრანებზე", + "Previous History Step": "ისტორიის წინა ნაბიჯი", + "Previous Photo": "წინა ფოტო", + "Primaries (x, y)": "ძირითადი ფერები (x, y)", + "Print": "ბეჭდვა", + "Print…": "დაბეჭდვა…", + "ProRAW the way the iPhone renders it, turn on Profile ▸ Camera local tone mapping (shown for photos that carry the": "ProRAW-ის ისე სანახავად, როგორც iPhone რენდერავს, ჩართეთ პროფილი → კამერის ლოკალური ტონალური ასახვა (ჩანს ფოტოებზე, რომლებსაც", + "Process. See [process versions](process-versions.md).": "პროცესზე განახლებას. იხ. [პროცესის ვერსიები](process-versions.md).", + "Processor": "პროცესორი", + "Profile": "პროფილი", + "Profile Browser": "პროფილების ბრაუზერი", + "Profile Distortion": "დეფორმაცია პროფილით", + "Profile Menu": "პროფილების მენიუ", + "Profile Vignetting": "ვინიეტირება პროფილით", + "Profile:": "პროფილი:", + "Profiles": "პროფილები", + "Profiles assigned in your desktop's colour settings are usually in ~/.local/share/icc.": "სამუშაო მაგიდის ფერის პარამეტრებში მინიჭებული პროფილები ჩვეულებრივ ~/.local/share/icc-შია.", + "Progressive": "პროგრესული", + "Projection": "პროექცია", + "Proof copy created": "საცდელი ასლი შექმნილია", + "Properties": "თვისებები", + "Public Domain": "საზოგადოებრივი საკუთრება", + "Public domain": "საზოგადოებრივი საკუთრება", + "Pupil Size": "გუგის ზომა", + "Purge Unused Keywords": "გამოუყენებელი საკვანძო სიტყვების გაწმენდა", + "Purple": "იისფერი", + "Purple Amount": "იისფერის სიძლიერე", + "Purple Hue": "იისფერის ფერის ტონი", + "Purple Hue High": "იისფერის ფერის ტონის ზედა ზღვარი", + "Purple Hue Low": "იისფერის ფერის ტონის ქვედა ზღვარი", + "Purple Luminance": "იისფერის სიკაშკაშე", + "Purple Saturation": "იისფერის გაჯერება", + "Put New Keywords Inside": "ახალი საკვანძო სიტყვების შიგნით მოთავსება", + "Put New Keywords Inside This Keyword": "ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა", + "Put the locations you removed from Local back (no files change)": "ლოკალურიდან მოშორებული მდებარეობების დაბრუნება (ფაილები არ იცვლება)", + "Quality": "ხარისხი", + "Query Photos": "ფოტოების მოთხოვნა", + "Quick Collection": "სწრაფი კოლექცია", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "სწრაფი კოლექცია და სამიზნე ალბომი (B ბადეში), საკვანძო სიტყვების ნაკრებები (⌥1–⌥9), ფერადი იარლიყების ნაკრებები.", + "Quick Develop": "სწრაფი დამუშავება", + "Quiet Green": "მშვიდი სიმწვანე", + "Quit": "გასვლა", + "Quit Anyway": "მაინც გასვლა", + "Quit LightCraft": "LightCraft-იდან გასვლა", + "Quit while tasks are running?": "გავიდეთ, სანამ ამოცანები სრულდება?", + "Quit with unsaved changes?": "გავიდეთ შეუნახავი ცვლილებებით?", + "Quitting while a task that can be stopped is running asks first (\"Quit Anyway\" stops it).": "გასვლა, როცა შეჩერებადი ამოცანა მიმდინარეობს, ჯერ კითხულობს („მაინც გასვლა“ მას აჩერებს).", + "RAW decoding": "RAW-ის დეკოდირება", + "Radial": "რადიალური", + "Radial Gradient": "რადიალური გრადიენტი", + "Radius": "რადიუსი", + "Random": "შემთხვევითი", + "Range": "დიაპაზონი", + "Rate ★": "შეფასება ★", + "Rate ★ and advance": "შეფასება ★ და გადასვლა", + "Rate ★★": "შეფასება ★★", + "Rate ★★ and advance": "შეფასება ★★ და გადასვლა", + "Rate ★★★": "შეფასება ★★★", + "Rate ★★★ and advance": "შეფასება ★★★ და გადასვლა", + "Rate ★★★★": "შეფასება ★★★★", + "Rate ★★★★ and advance": "შეფასება ★★★★ და გადასვლა", + "Rate ★★★★★": "შეფასება ★★★★★", + "Rate ★★★★★ and advance": "შეფასება ★★★★★ და გადასვლა", + "Rating": "შეფასება", + "Rating cleared": "შეფასება გასუფთავდა", + "Ratings & flags": "შეფასებები და დროშები", + "Ratings, flags, labels, keywords, collections/sets, virtual copies and supported edits migrate;": "შეფასებები, დროშები, იარლიყები, საკვანძო სიტყვები, კოლექციები/ნაკრებები, ვირტუალური ასლები და მხარდაჭერილი ცვლილებები გადმოდის;", + "Raw": "Raw", + "Raw default": "raw-ის ნაგულისხმევი", + "Raw defaults": "raw-ის ნაგულისხმევები", + "Raw files": "raw ფაილები", + "Raw files whose raw data sits in a private block of a TIFF (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and Kodak DCS": "RAW ფაილები, რომელთა RAW მონაცემები TIFF-ის კერძო ბლოკშია (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds და Kodak DCS", + "Raw photos": "raw ფოტოები", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "raw ფოტოები DNG-ად, ჩაშენებული ცვლილებებით. ზომის, ფერისა და გამოტანის პარამეტრები არ მოქმედებს.", + "Raws from 52 camera models (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus their other names) whose": "52 კამერის მოდელის RAW-ები (22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm და 5 სხვა, მათი სხვა სახელებითურთ), რომელთა", + "Raws from the 52 camera models whose colour matrices LightCraft has measured (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,": "52 კამერის მოდელის RAW-ები, რომელთა ფერთა მატრიცებიც LightCraft-მა გაზომა (Sony ILCE-7M3, ILCE-7M4, ILCE-7RM3,", + "Raws from the Pentax KP, KF, K-3 Mark III (and Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 and 645Z no longer": "Pentax KP, KF, K-3 Mark III (და Monochrome), K-70, K-1, K-1 Mark II, K-S1, K-S2 და 645Z კამერების RAW ფაილები აღარ", + "Read Metadata from File": "მეტამონაცემების წაკითხვა ფაილიდან", + "Read metadata updates from XMP sidecars": "მეტამონაცემების განახლებების წაკითხვა XMP თანმხლები ფაილებიდან", + "Read mode": "კითხვის რეჟიმი", + "Read the SAM License": "SAM-ის ლიცენზიის წაკითხვა", + "Reading": "იკითხება", + "Reading Lightroom catalog": "Lightroom-ის კატალოგის კითხვა", + "Reading Lightroom catalog…": "Lightroom-ის კატალოგის კითხვა…", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "თანმხლები ფაილის წაკითხვა ფოტოს შეფასებას, იარლიყებს, მეტამონაცემებსა და ცვლილებებს მასში ჩაწერილით ჩაანაცვლებს.", + "Reading folder": "საქაღალდის კითხვა", + "Reading folder…": "საქაღალდის კითხვა…", + "Reading photos…": "ფოტოების წაკითხვა…", + "Real name": "ნამდვილი სახელი", + "Recent": "ბოლოდროინდელი", + "Recent Keywords": "ბოლოდროინდელი საკვანძო სიტყვები", + "Recent Keywords is built in: choose another name": "„ბოლოდროინდელი საკვანძო სიტყვები“ ჩაშენებულია: აირჩიეთ სხვა სახელი", + "Recent Libraries": "ბოლოს გახსნილი ბიბლიოთეკები", + "Recently Added": "ბოლოს დამატებული", + "Recently Deleted": "ახლახან წაშლილი", + "Recently Deleted is listed with All Photos, Recently Added and Picks under My Photos.": "ახლახან წაშლილი ჩამოთვლილია ყველა ფოტოსთან, ბოლოს დამატებულსა და რჩეულებთან ერთად „ჩემი ფოტოების“ ქვეშ.", + "Recognise faces (experimental)": "სახეების ამოცნობა (ექსპერიმენტული)", + "Recognition models": "ამოცნობის მოდელები", + "Red": "წითელი", + "Red Eye": "წითელი თვალები", + "Red Hue": "წითლის ფერის ტონი", + "Red Luminance": "წითლის სიკაშკაშე", + "Red Primary": "წითელი ძირითადი", + "Red Saturation": "წითლის გაჯერება", + "Red channel": "წითელი არხი", + "Red label": "წითელი იარლიყი", + "Red/Cyan Fringe": "წითელი/ცისფერი შარავანდი", + "Redo": "ხელახლა შესრულება", + "Reference": "ეტალონი", + "Reference View": "ეტალონური ხედი", + "Reference the files where they are": "ფაილებზე მიმართვა მათ ადგილას", + "Refine": "დაზუსტება", + "Refine AI Mask Detail": "AI ნიღბის დეტალების დაზუსტება", + "Refine Edges": "კიდეების დაზუსტება", + "Refine Mask Edges": "ნიღბის კიდეების დაზუსტება", + "Refine Saturation": "გაჯერების დაზუსტება", + "Refining the mask's detail…": "ნიღბის დეტალები ზუსტდება…", + "Refresh Source": "წყაროს განახლება", + "Refresh Source (/)": "წყაროს განახლება (/)", + "Reject": "უარყოფა", + "Reject below focus": "ფოკუსის ზღვარს ქვემოთ უარყოფა", + "Rejected": "უარყოფილი", + "Rejects": "უარყოფილები", + "Relative": "ფარდობითი", + "Reliability": "საიმედოობა", + "Relink Photo": "ფოტოს ხელახლა დაკავშირება", + "Relink photos whose file was renamed or moved": "იმ ფოტოების ხელახლა დაკავშირება, რომელთა ფაილი გადაერქვა ან გადაიტანეს", + "Relinked": "ხელახლა დაკავშირებულია", + "Reload Changed Files": "შეცვლილი ფაილების ხელახლა ჩატვირთვა", + "Reload from Disk": "დისკიდან ხელახლა ჩატვირთვა", + "Remove": "მოშორება", + "Remove (H)": "მოშორება (H)", + "Remove CA": "ქრომატული აბერაციის მოცილება", + "Remove Chromatic Aberration": "ქრომატული აბერაციის მოცილება", + "Remove Denoise Model": "ხმაურის მოცილების მოდელის წაშლა", + "Remove Disk from Library": "დისკის ბიბლიოთეკიდან ამოშლა", + "Remove Disk from Library…": "დისკის ბიბლიოთეკიდან ამოშლა…", + "Remove Face Box": "სახის ჩარჩოს წაშლა", + "Remove Face Model": "სახეების მოდელის წაშლა", + "Remove Folder from Library": "საქაღალდის ბიბლიოთეკიდან ამოშლა", + "Remove a filter above, or choose Clear all": "მოაშორეთ ზემოთ მოცემული ფილტრი ან აირჩიეთ „ყველას გასუფთავება“", + "Remove from Album": "ალბომიდან ამოშლა", + "Remove from Favorites": "რჩეულებიდან ამოშლა", + "Remove from Library…": "ბიბლიოთეკიდან ამოშლა…", + "Remove from Local": "ლოკალურიდან ამოშლა", + "Remove from Photo": "ფოტოდან ამოშლა", + "Remove from Selected Photos": "მონიშნული ფოტოებიდან ამოშლა", + "Remove from Stack": "დასტიდან ამოშლა", + "Remove from Target Album": "სამიზნე ალბომიდან ამოშლა", + "Remove from favorites": "რჩეულებიდან ამოშლა", + "Remove from survey": "მიმოხილვიდან ამოშლა", + "Remove location info": "მდებარეობის ინფორმაციის წაშლა", + "Remove missing photos from the library": "დაკარგული ფოტოების ბიბლიოთეკიდან ამოშლა", + "Remove shortcut": "მალსახმობის წაშლა", + "Remove this face box (undo with Edit ▸ Undo)": "ამ სახის ჩარჩოს წაშლა (დაბრუნება: რედაქტირება → დაბრუნება)", + "Remove this filter": "ამ ფილტრის მოშორება", + "Remove this group": "ამ ჯგუფის წაშლა", + "Remove this rule": "ამ წესის წაშლა", + "Removed": "ამოშლილია", + "Rename": "სახელის შეცვლა", + "Rename Album": "ალბომის სახელის შეცვლა", + "Rename Component": "კომპონენტის სახელის შეცვლა", + "Rename Folder": "საქაღალდის სახელის შეცვლა", + "Rename Folder…": "საქაღალდის სახელის შეცვლა…", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "საქაღალდის გადარქმევა… და საქაღალდის გადატანა… (ლოკალური) შეიძლება დაბრუნდეს და ხელახლა შესრულდეს (ხარვეზი #97): საქაღალდე დისკზე ბრუნდება", + "Rename Keyword": "საკვანძო სიტყვის სახელის შეცვლა", + "Rename Keyword…": "საკვანძო სიტყვის სახელის შეცვლა…", + "Rename Mask": "ნიღბის სახელის შეცვლა", + "Rename Photos": "ფოტოების სახელის შეცვლა", + "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), მაგ. როცა ქსელური საქაღალდე ითიშება", + "Rename Photos…": "ფოტოების სახელის შეცვლა…", + "Rename Preset": "პრესეტის სახელის შეცვლა", + "Rename Preview": "სახელის შეცვლის გადახედვა", + "Rename Template Tags": "სახელის შეცვლის შაბლონის ტეგები", + "Rename Version": "ვერსიის სახელის შეცვლა", + "Rename…": "სახელის შეცვლა…", + "Rendering": "რენდერირება", + "Rendering…": "რენდერდება…", + "Reorder Album": "ალბომის გადალაგება", + "Reorder Masks": "ნიღბების გადალაგება", + "Reset": "ჩამოყრა", + "Reset All": "ყველაფრის ჩამოყრა", + "Reset All Curves": "ყველა მრუდის ჩამოყრა", + "Reset All Keyboard Shortcuts": "კლავიატურის ყველა მალსახმობის ჩამოყრა", + "Reset Before to Import State": "„მანამდე“ იმპორტის მდგომარეობაზე დაბრუნება", + "Reset Control": "მართვის ელემენტის ჩამოყრა", + "Reset Crop": "ჩამოჭრის გაუქმება", + "Reset Edits": "ცვლილებების ჩამოყრა", + "Reset Panel Layout": "პანელების განლაგების ჩამოყრა", + "Reset Parametric Curve": "პარამეტრული მრუდის ჩამოყრა", + "Reset Section": "სექციის საწყისზე დაბრუნება", + "Reset Sidebar Sections": "გვერდითი ზოლის სექციების ჩამოყრა", + "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)", + "Reshuffle": "ხელახლა არევა", + "Resize Face Box": "სახის ჩარჩოს ზომის შეცვლა", + "Resize panel window": "პანელის ფანჯრის ზომის შეცვლა", + "Resize panels": "პანელების ზომის შეცვლა", + "Resize to": "ზომის შეცვლა:", + "Resolution (ppi)": "გარჩევადობა (ppi)", + "Restore": "აღდგენა", + "Restore (no shortcut)": "აღდგენა (მალსახმობის გარეშე)", + "Restore Library from Backup…": "ბიბლიოთეკის აღდგენა სარეზერვო ასლიდან…", + "Restore Version": "ვერსიის აღდგენა", + "Restore keeps their edits; Import as new deletes the old record first.": "აღდგენა ინარჩუნებს მათ ცვლილებებს; ახლის სახით იმპორტი ჯერ ძველ ჩანაწერს შლის.", + "Restore them": "მათი აღდგენა", + "Retouch": "რეტუში", + "Return presses OK in Choose Edit Settings to Copy (⇧⌘C) and Paste Selected Settings (⇧⌘V), and Esc cancels them": "Return აჭერს OK-ს დიალოგებში „კოპირებისთვის რედაქტირების პარამეტრების არჩევა“ (⇧⌘C) და „მონიშნული პარამეტრების ჩასმა“ (⇧⌘V), Esc კი აუქმებს მათ", + "Reverse the order": "რიგის შებრუნება", + "Revert": "წინა ვერსიაზე დაბრუნება", + "Review": "განხილვა", + "Review Import": "იმპორტის გადახედვა", + "Right-click Search Photos, a slider's typed value, the Info panel's fields, the name field of New Album, Rename": "მარჯვენა დაწკაპუნებით ფოტოების ძიებაზე, სლაიდერის აკრეფილ მნიშვნელობაზე, ინფორმაციის პანელის ველებზე, ახალი ალბომის, ალბომის", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Set Color Label ▸ a colour or None. The row": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → ფერადი იარლიყის დაყენება → ფერი ან არცერთი. სტრიქონი", + "Right-click a folder (or a disk other than the startup disk) in the sidebar's Folders section ▸ Synchronize": "მარჯვენა დაწკაპუნება საქაღალდეზე (ან სასტარტოს გარდა სხვა დისკზე) გვერდითი ზოლის საქაღალდეების სექციაში → საქაღალდის", + "Right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder…": "მარჯვენა დაწკაპუნება საქაღალდეზე → ახალი → ალბომის შექმნა… / სმარტ-ალბომის შექმნა… / სმარტ-ალბომის შექმნა ფილტრიდან… / საქაღალდის შექმნა…", + "Right-click a photo ▸ Edit In ▸ Edit in PhotoCraft (or Photo ▸ Edit In, ⇧⌘E) renders a copy with your edits, stacks it": "მარჯვენა დაწკაპუნება ფოტოზე → რედაქტირება სხვა პროგრამაში → რედაქტირება PhotoCraft-ში (ან ფოტო → რედაქტირება სხვა პროგრამაში, ⇧⌘E) არენდერებს ასლს თქვენი ცვლილებებით, ათავსებს მას", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "დაწკაპეთ ფოტოზე მარჯვენა ღილაკით → ეტალონურ ფოტოდ დაყენება, შემდეგ ხედი → ეტალონური ხედი (Shift+R)", + "Right-click a preset to delete it": "პრესეტის წასაშლელად დაწკაპეთ მასზე მარჯვენა ღილაკით", + "Right-click ▸ Put New Keywords Inside This Keyword: new keywords, made with + or typed in the box above, go inside": "მარჯვენა დაწკაპუნება → ახალი საკვანძო სიტყვების ამ საკვანძო სიტყვაში ჩასმა: + ღილაკით ან ზემოთ ველში აკრეფილი ახალი საკვანძო სიტყვები", + "Rights Usage Terms": "უფლებების გამოყენების პირობები", + "Rotate": "შემობრუნება", + "Rotate Crop": "ჩამოჭრის შემობრუნება", + "Rotate Crop Aspect": "ჩამოჭრის თანაფარდობის შემობრუნება", + "Rotate Left": "მარცხნივ შემობრუნება", + "Rotate Right": "მარჯვნივ შემობრუნება", + "Roughness": "უხეშობა", + "Roundness": "სიმრგვალე", + "Rule problems are shown in your language and name the field the way the menu does (\"Title: needs something to look": "წესების პრობლემები ნაჩვენებია თქვენს ენაზე და ველს ისე ასახელებს, როგორც მენიუ („დასახელება: საჭიროა საძიებო", + "Rule values are checked: a rating of 9, a date like 2026-13 or \"banana\", a \"between\" whose dates are the wrong": "წესების მნიშვნელობები მოწმდება: შეფასება 9, თარიღი, როგორიცაა 2026-13 ან „banana“, „შორის“, რომლის თარიღებიც არასწორი", + "Rules": "წესები", + "SAM 3 download stopped: it resumes where it left off next time.": "SAM 3-ის ჩამოტვირთვა შეწყდა: შემდეგ ჯერზე იქიდან გაგრძელდება, სადაც შეჩერდა.", + "SDR": "SDR", + "SDR Brightness": "SDR სიკაშკაშე", + "SDR Clarity": "SDR სიცხადე", + "SDR Contrast": "SDR კონტრასტი", + "SDR Highlights": "SDR ღია ტონები", + "SDR Shadows": "SDR ჩრდილები", + "SDR Whites": "SDR თეთრები", + "Sa": "შაბ", + "Same as raw default": "როგორც RAW-ის ნაგულისხმევ პარამეტრებში", + "Sample Mask Color": "ნიღბის ფერის ნიმუშის აღება", + "Sample a colour on the photo": "ფოტოზე ფერის ნიმუშის აღება", + "Samsung SRW files without compression now open as raws: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1": "Samsung SRW ფაილები შეკუმშვის გარეშე ახლა RAW-ებად იხსნება: NX5, NX10, NX11, NX20, NX200, NX210, NX1000, NX1100, EX1", + "Saturation": "გაჯერება", + "Saturation Range": "გაჯერების დიაპაზონი", + "Saturday": "შაბათი", + "Save": "შენახვა", + "Save As…": "შენახვა როგორც…", + "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 a new set": "ახალ ნაკრებად შენახვა", + "Save as set": "ნაკრებად შენახვა", + "Save as…": "შენახვა როგორც…", + "Save preset": "პრესეტის შენახვა", + "Saving Lightroom import index…": "Lightroom-ის იმპორტის ინდექსის შენახვა…", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "სხვა აპლიკაციის მიერ ჩაწერილ XMP თანმხლებ ფაილში მეტამონაცემების შენახვა მას აღარ ანაცვლებს (ხარვეზი #92): LightCraft აერთიანებს თავის", + "Scale": "მასშტაბი", + "Scan Folder for Changes": "საქაღალდის ცვლილებების სკანირება", + "Scan failed": "სკანირება ვერ მოხერხდა", + "Scanning": "სკანირება", + "Scanning folder": "საქაღალდის სკანირება", + "Scanning…": "სკანირება…", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "ქულები ფოტოებზე რჩება: გაფილტრეთ ან შექმენით სმარტ-ალბომები ფოკუსისა და მსგავს კადრებს შორის საუკეთესოს მიხედვით.", + "Screen": "ეკრანი", + "Scroll menu down": "მენიუს ქვემოთ გადახვევა", + "Scroll menu up": "მენიუს ზემოთ გადახვევა", + "Search": "ძიება", + "Search Photos": "ფოტოების ძიება", + "Search albums": "ალბომების ძიება", + "Search commands or keys": "ბრძანებების ან კლავიშების ძიება", + "Search…": "ძიება…", + "Seasons": "სეზონები", + "Second Window": "მეორე ფანჯარა", + "Sections are easier to tell apart: a hairline between them, the fold chevron in the same column as the rows'": "სექციები ერთმანეთისგან უკეთ გაირჩევა: მათ შორის თხელი ხაზია, ჩაკეცვის ისარი სტრიქონების", + "See [remote Metal inference](ai-masks.md#remote-metal-inference).": "იხ. [დისტანციური Metal ინფერენსი](ai-masks.md#remote-metal-inference).", + "Select": "მონიშვნა", + "Select All": "ყველაფრის მონიშვნა", + "Select All and multi-selection show every selected photo in the grid and filmstrip, not only the active one": "ყველაფრის მონიშვნა და მრავლობითი მონიშვნა ბადესა და კადრების ზოლში ყველა მონიშნულ ფოტოს აჩვენებს და არა მხოლოდ აქტიურს", + "Select Luminance Range": "სიკაშკაშის დიაპაზონის არჩევა", + "Select Mask": "ნიღბის არჩევა", + "Select Only Active Photo": "მხოლოდ აქტიური ფოტოს მონიშვნა", + "Select Photos": "ფოტოების მონიშვნა", + "Select Spot": "ლაქის არჩევა", + "Select a photo to edit": "აირჩიეთ ფოტო რედაქტირებისთვის", + "Select by": "მონიშვნა:", + "Select by Flag, Rating or Label": "მონიშვნა დროშის, შეფასების ან იარლიყის მიხედვით", + "Select photos to give them keywords.": "მონიშნეთ ფოტოები, რომ მათ საკვანძო სიტყვები მიანიჭოთ.", + "Select photos, then choose View → Survey (N)": "მონიშნეთ ფოტოები, შემდეგ აირჩიეთ ხედი → მიმოხილვა (N)", + "Select two photos, then choose View → Compare (Shift+C)": "მონიშნეთ ორი ფოტო, შემდეგ აირჩიეთ ხედი → შედარება (Shift+C)", + "Selected": "მონიშნული", + "Selected Settings). Ctrl+V only reaches LightCraft while the system clipboard holds text; otherwise use Edit ▸ Paste Edit": "პარამეტრების ჩასმა). Ctrl+V LightCraft-ს მხოლოდ მაშინ აღწევს, როცა სისტემის ბუფერში ტექსტია; სხვა შემთხვევაში გამოიყენეთ რედაქტირება → რედაქტირების", + "Selecting…": "მონიშვნა…", + "Selenium Tone": "სელენის ტონირება", + "Send Feedback…": "გამოხმაურების გაგზავნა…", + "Separated by commas": "მძიმით გამოყოფილი", + "Sepia Tone": "სეპიის ტონი", + "Sequence number zero-padded to N digits (1–9)": "რიგითი ნომერი, ნულებით შევსებული N ციფრამდე (1–9)", + "Sequence number, counting from the start number": "რიგითი ნომერი, საწყისი რიცხვიდან დათვლით", + "Services": "სერვისები", + "Set": "დაყენება", + "Set Album Cover": "ალბომის ყდის დაყენება", + "Set Before": "„მანამდე“-ს დაყენება", + "Set Color Label": "ფერადი იარლიყის დაყენება", + "Set Crop": "ჩამოჭრის დაყენება", + "Set Develop Values": "დამუშავების მნიშვნელობების დაყენება", + "Set Flag": "დროშის დაყენება", + "Set Folder Color Label": "საქაღალდის ფერადი იარლიყის მინიჭება", + "Set Image Zoom and Pan": "გამოსახულების მასშტაბისა და გადაადგილების დაყენება", + "Set Keyboard Shortcut": "კლავიატურის მალსახმობის მინიჭება", + "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": "მიქსის დაყენება ფოტოს ფერების მიხედვით", + "Set up AI Denoise…": "AI ხმაურის მოცილების გამართვა…", + "Setting or clearing a colour label shows a brief bottom confirmation, like rating a photo; custom label names appear in the message too.": "ფერადი იარლიყის დაყენება ან მოხსნა ქვემოთ მოკლე დადასტურებას აჩვენებს, როგორც ფოტოს შეფასება; შეტყობინებაში იარლიყების მორგებული სახელებიც ჩანს.", + "Settings": "პარამეტრები", + "Settings file problem": "პარამეტრების ფაილის პრობლემა", + "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` ინახება", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "პარამეტრები, რომლებითაც ახალი RAW ფოტოები იწყება. მათი შეცვლა ბიბლიოთეკაში უკვე არსებულ ფოტოებს არ ეხება.", + "Settings pasted": "პარამეტრები ჩასმულია", + "Settings to include": "ჩასართავი პარამეტრები", + "Settings ▸ Display (issue #787). On P3 screens previews used to look more saturated than the exported files in": "პარამეტრები → ეკრანში (ხარვეზი #787). P3 ეკრანებზე წინასწარი ხედები ადრე უფრო გაჯერებული ჩანდა, ვიდრე ექსპორტირებული ფაილები", + "Settings ▸ Display ▸ Choose Profile… shows photos through your monitor's ICC profile (matrix/TRC or LUT-based), so": "პარამეტრები → ეკრანი → პროფილის არჩევა… ფოტოებს თქვენი მონიტორის ICC პროფილით აჩვენებს (მატრიცა/TRC ან LUT), ასე რომ", + "Settings ▸ General ▸ External Editing: the format (PSD or TIFF), colour space and bit depth for PhotoCraft and for": "პარამეტრები → ზოგადი → გარე რედაქტირება: ფორმატი (PSD ან TIFF), ფერთა სივრცე და ბიტური სიღრმე PhotoCraft-ისა და", + "Settings ▸ Interface ▸ Appearance Mode: Sync with System, Dark Mode or Light Mode, above a light": "პარამეტრები → ინტერფეისი → იერსახის რეჟიმი: სისტემის მიხედვით, ბნელი რეჟიმი ან ღია რეჟიმი, ღია", + "Settings.": "პარამეტრების ჩასმა.", + "Settings…": "პარამეტრები…", + "Shade": "ჩრდილი", + "Shadow": "ჩრდილი", + "Shadows": "ჩრდილები", + "Shadows Hue": "ჩრდილების ფერის ტონი", + "Shadows Luminance": "ჩრდილების სიკაშკაშე", + "Shadows Saturation": "ჩრდილების გაჯერება", + "Shadows Tint": "ჩრდილების ტონირება", + "Shadows split": "ჩრდილების გაყოფა", + "Sharpen": "გამკვეთრება", + "Sharpening": "გამკვეთრება", + "Sharpness": "სიმკვეთრე", + "Shift": "Shift", + "Shift by": "წანაცვლება:", + "Short Edge": "მოკლე გვერდი", + "Shortcuts are editable: Help ▸ Keyboard Shortcuts (⌘/) lists every command with a search box; click a shortcut and": "მალსახმობები რედაქტირებადია: დახმარება → კლავიატურის მალსახმობები (⌘/) ყველა ბრძანებას საძიებო ველით ჩამოთვლის; დაწკაპეთ მალსახმობზე და", + "Show": "ჩვენება", + "Show All": "ყველას ჩვენება", + "Show All Mask Overlays": "ნიღბების ყველა ზედდების ჩვენება", + "Show Clipping": "ჩამოჭრილი ტონების ჩვენება", + "Show Deghost Overlay": "აჩრდილების მოცილების ზედდების ჩვენება", + "Show Hidden Local Locations": "დამალული ლოკალური მდებარეობების ჩვენება", + "Show Luminance Map": "სიკაშკაშის რუკის ჩვენება", + "Show Mask": "ნიღბის ჩვენება", + "Show Mask Overlay": "ნიღბის ზედდების ჩვენება", + "Show Mask Pins": "ნიღბის მარკერების ჩვენება", + "Show Original": "ორიგინალის ჩვენება", + "Show Person": "ადამიანის ჩვენება", + "Show Photo Counts": "ფოტოების რაოდენობის ჩვენება", + "Show Photos with Keyword": "საკვანძო სიტყვის მქონე ფოტოების ჩვენება", + "Show Pins": "მარკერების ჩვენება", + "Show Sidebar Section": "გვერდითი ზოლის სექციის ჩვენება", + "Show Source": "წყაროს ჩვენება", + "Show Thumbnails": "ესკიზების ჩვენება", + "Show all overlays": "ყველა ზედდების ჩვენება", + "Show commands without a shortcut": "მალსახმობის არმქონე ბრძანებების ჩვენება", + "Show destination gamut warning": "დანიშნულების ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show display gamut warning": "ეკრანის ფერთა გამიდან გასვლის გაფრთხილების ჩვენება", + "Show file names": "ფაილების სახელების ჩვენება", + "Show in Compare": "შედარებაში ჩვენება", + "Show in Explorer": "Explorer-ში ჩვენება", + "Show in File Manager": "ფაილების მენეჯერში ჩვენება", + "Show in Finder": "Finder-ში ჩვენება", + "Show less": "ნაკლების ჩვენება", + "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 this wheel's effect": "ამ ბორბლის ეფექტის ჩვენება", + "Show/Hide Mask": "ნიღბის ჩვენება/დამალვა", + "Show/hide My Photos panel": "„ჩემი ფოტოების“ პანელის ჩვენება/დამალვა", + "Shutter Speed": "ჩამკეტის სიჩქარე", + "Shutter speed": "ჩამკეტის სიჩქარე", + "Side panels": "გვერდითი პანელები", + "Sidebar Sections": "გვერდითი ზოლის სექციები", + "Sidebar sections": "გვერდითი ზოლის სექციები", + "Sidecar names": "თანმხლები ფაილების სახელები", + "Silver": "ვერცხლისფერი", + "Simulate Paper & Ink": "ქაღალდისა და მელნის იმიტაცია", + "Size": "ზომა", + "Size (% of short edge)": "ზომა (მოკლე გვერდის %)", + "Size limit": "ზომის ზღვარი", + "Size rules measure the photo as shown: Megapixels now counts the cropped photo, like Long Edge, Short Edge and": "ზომის წესები ფოტოს ზომავს ისე, როგორც ჩანს: მეგაპიქსელი ახლა ჩამოჭრილ ფოტოს ითვლის, როგორც გრძელი გვერდი, მოკლე გვერდი და", + "Skip": "გამოტოვება", + "Skipped: files already in the library elsewhere": "გამოტოვებულია: ფაილები, რომლებიც ბიბლიოთეკაში უკვე სხვა ადგილასაა", + "Skipped: files that can't be read": "გამოტოვებულია: ფაილები, რომელთა წაკითხვა შეუძლებელია", + "Sky": "ცა", + "Sky, Subject and Background masks instead of our heuristics, so the sky is selected where the camera found it": "ცის, საგნის და ფონის მონიშვნის ნიღბებისთვის ჩვენი ევრისტიკის ნაცვლად, ასე რომ ცა იქ მოინიშნება, სადაც კამერამ იპოვა", + "Slider drags follow the pointer more closely": "სლაიდერის გადათრევა მაჩვენებელს უფრო ზუსტად მისდევს", + "Slider drags on large raws": "სლაიდერის გადათრევა დიდ RAW ფაილებზე", + "Slideshow": "სლაიდშოუ", + "Slideshow paused": "სლაიდშოუ შეჩერებულია", + "Slideshow resumed": "სლაიდშოუ განახლდა", + "Slideshow · Space pauses · Esc ends": "სლაიდშოუ · Space აჩერებს · Esc ასრულებს", + "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 Album": "სმარტ-ალბომი", + "Smart Album Rule Fields": "სმარტ-ალბომის წესის ველები", + "Smart Preview Status": "ჭკვიანი წინასწარი ხედის სტატუსი", + "Smart Previews Location": "ჭკვიანი წინასწარი ხედების მდებარეობა", + "Smart album rules": "სმარტ-ალბომის წესები", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "სმარტ-ალბომები წესების რედაქტორით: ყველა / რომელიმე / არცერთი დამთხვევა, ჩადგმული ჯგუფები, 26 ველი.", + "Smart preview available": "ჭკვიანი წინასწარი ხედი ხელმისაწვდომია", + "Smart previews": "ჭკვიანი წინასწარი ხედები", + "Smart previews folder changed": "ჭკვიანი წინასწარი ხედების საქაღალდე შეიცვალა", + "Smoothness": "სიგლუვე", + "Soft B&W": "რბილი შავ-თეთრი", + "Soft Pastel": "რბილი პასტელი", + "Soft Proofing": "საცდელი ჩვენება", + "Soft Skin": "რბილი კანი", + "Some rules need fixing (Edit Smart Album…):": "ზოგიერთ წესს გასწორება სჭირდება (სმარტ-ალბომის რედაქტირება…):", + "Sony A7R II (and other) raws whose camera JPEG is lens-corrected no longer open grey and too dark (issue #232): the": "Sony A7R II-ის (და სხვა) RAW-ები, რომელთა კამერის JPEG ობიექტივისთვის კორექტირებულია, აღარ იხსნება ნაცრისფრად და ზედმეტად მუქად (ხარვეზი #232):", + "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 ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 and ILCE-7S raws now show white balance in Kelvin too,": "Sony ILCA-99M2, ILCA-77M2, SLT-A33, DSC-RX10, ILCE-3500 და ILCE-7S კამერების RAW ფაილებიც ახლა თეთრის ბალანსს კელვინებში აჩვენებს,", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Sony ILCE-7M4-ის შემცირებული ზომის უდანაკარგო ARW-ები ახლა დისკრეტიზაციაშემცირებულ YCbCr ფილებს წრფივ RGB-ში დეკოდირებს,", + "Sort": "სორტირება", + "Sort Albums A–Z": "ალბომების დალაგება ა–ჰ", + "Sort Albums by Name": "ალბომების დალაგება სახელის მიხედვით", + "Sort Contents A–Z": "შიგთავსის დალაგება ა–ჰ", + "Sort Contents A–Z (or + ▸ Sort Albums A–Z at the top level) goes back to by name. To put a folder after an open": "შიგთავსის სორტირება ა–ჰ (ან ზედა დონეზე + → ალბომების სორტირება ა–ჰ) სახელით დალაგებას აბრუნებს. საქაღალდის ღია", + "Sort by": "სორტირება:", + "Source": "წყარო", + "Source code on GitHub": "საწყისი კოდი GitHub-ზე", + "Speed": "სიჩქარე", + "Spherical": "სფერული", + "Spiral": "სპირალი", + "Split Stack": "დასტის გაყოფა", + "Spring Fresh": "გაზაფხულის სიახლე", + "Square": "კვადრატული", + "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": "დასტები", + "Standard": "სტანდარტული", + "Star rating (0–5)": "ვარსკვლავებით შეფასება (0–5)", + "Start at": "დაწყება:", + "Start number": "საწყისი რიცხვი", + "Starting": "იწყება", + "Starting…": "იწყება…", + "Startup": "გაშვება", + "State / Province": "შტატი / პროვინცია", + "Stop": "შეწყვეტა", + "Stop Building Previews": "წინასწარი ხედების შექმნის შეწყვეტა", + "Stop Using as Target Album": "სამიზნე ალბომად გამოყენების შეწყვეტა", + "Stopped": "შეწყდა", + "Stopped by an error (see the log)": "შეწყდა შეცდომის გამო (იხილეთ ჟურნალი)", + "Stopping…": "ჩერდება…", + "Straighten": "გასწორება", + "Straighten Tool": "გასწორების ხელსაწყო", + "Strong Contrast": "ძლიერი კონტრასტი", + "Style": "სტილი", + "Su": "კვი", + "Subfolder": "ქვესაქაღალდე", + "Subfolder name": "ქვესაქაღალდის სახელი", + "Subject": "საგანი", + "Subtract": "გამოკლება", + "Subtract from this mask": "ამ ნიღბიდან გამოკლება", + "Suggest Names for Faces": "სახეებისთვის სახელების შეთავაზება", + "Suggestions": "შეთავაზებები", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "გვთავაზობს, ვინ არის ფოტოზე, თქვენ მიერ დასახელებული სახეების მიხედვით. ყველაფერი თქვენს კომპიუტერზე რჩება.", + "Summer Haze": "ზაფხულის ბურუსი", + "Sunday": "კვირა", + "Survey": "მიმოხილვა", + "Survey (N)": "მიმოხილვა (N)", + "Swap": "გაცვლა", + "Swap Aspect (X)": "თანაფარდობის გაცვლა (X)", + "Swap Before and After": "„მანამდე“-სა და „შემდეგ“-ის გაცვლა", + "Swap Before and After Settings": "„მანამდე“-სა და „შემდეგ“-ის პარამეტრების გაცვლა", + "Swap Compare Photos": "შესადარებელი ფოტოების გაცვლა", + "Switch light / dark theme": "ღია / ბნელი თემის გადართვა", + "Switch to & Containing to see the keywords with the keywords containing them, or to Will Export to see what": "გადართეთ „და შემცველი“-ზე, რომ საკვანძო სიტყვები მათი შემცველი საკვანძო სიტყვებით ნახოთ, ან „ექსპორტირდება“-ზე, რომ ნახოთ, რას", + "Switching libraries": "ბიბლიოთეკების გადართვა", + "Sync Settings": "პარამეტრების სინქრონიზაცია", + "Sync with System": "სისტემასთან სინქრონიზაცია", + "Synchronize": "სინქრონიზაცია", + "Synchronize Folder": "საქაღალდის სინქრონიზაცია", + "Synchronize Folder…": "საქაღალდის სინქრონიზაცია…", + "Synchronizing folder": "საქაღალდის სინქრონიზაცია", + "Synchronizing…": "სინქრონიზაცია…", + "Synonyms": "სინონიმები", + "Synonyms. Exports now write keyword names to `dc:subject` and their paths to `lr:hierarchicalSubject`, and keywords": "სინონიმების ექსპორტი. ექსპორტი ახლა საკვანძო სიტყვების სახელებს `dc:subject`-ში წერს, მათ გზებს კი `lr:hierarchicalSubject`-ში, ხოლო", + "System": "სისტემა", + "System Info": "სისტემის ინფორმაცია", + "System Info, All Metadata and What's New end with one Close button, like About, instead of Cancel and OK": "სისტემის ინფორმაცია, ყველა მეტამონაცემი და სიახლეები სრულდება ერთი „დახურვის“ ღილაკით, როგორც „პროგრამის შესახებ“, „გაუქმებისა“ და OK-ის ნაცვლად", + "System Info…": "სისტემის ინფორმაცია…", + "System default": "სისტემის ნაგულისხმევი", + "T) now matches.": "T-ის ნაცვლად) ახლა ემთხვევა.", + "TIFFs) are no longer opened as a thumbnail-sized ordinary image. They are recognised as raws LightCraft can't decode": "TIFF-ები), აღარ იხსნება ესკიზის ზომის ჩვეულებრივ გამოსახულებად. ისინი ამოიცნობა როგორც RAW-ები, რომელთა დეკოდირება LightCraft-ს", + "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 თაობა. ისინი იწყება", + "Table": "ცხრილი", + "Tags": "ტეგები", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "ტეგები: იხილეთ „ტეგები“ შაბლონის გვერდით. ფაილებს სახელი დისკზე ეცვლება (მათ XMP თანმხლებ ფაილებთან ერთად); არსებულ სახელებს ემატება -1, -2…", + "Take a snapshot": "მომენტალური ასლის შექმნა", + "Targeted Adjustment": "მიზნობრივი კორექცია", + "Targeted adjustment on: drag up or down on the photo · Esc to stop": "მიზნობრივი კორექცია ჩართულია: გადაათრიეთ ფოტოზე ზემოთ ან ქვემოთ · Esc — შეწყვეტა", + "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": "ტემპ.", + "Temperature": "ტემპერატურა", + "Template": "შაბლონი", + "Temporary session — your library isn't open, and nothing you do here is saved.": "დროებითი სესია — თქვენი ბიბლიოთეკა გახსნილი არ არის და აქ არაფერი ინახება.", + "Test Denoise Model": "ხმაურის მოცილების მოდელის შემოწმება", + "Test Face Model": "სახეების მოდელის შემოწმება", + "Tested and works on this computer": "შემოწმებულია და მუშაობს ამ კომპიუტერზე", + "Text": "ტექსტი", + "Text direction": "ტექსტის მიმართულება", + "Text fields": "ტექსტური ველები", + "Texture": "ტექსტურა", + "Th": "ხუთ", + "That includes the photos in the folders inside it.": "მასში შემავალ საქაღალდეებში არსებული ფოტოების ჩათვლით.", + "That key can't be used as a shortcut": "ეს კლავიში მალსახმობად ვერ გამოიყენება", + "The Export dialog has **Add to Apple Photos**, with an optional album. The files are exported to your folder as": "ექსპორტის დიალოგს აქვს **Apple Photos-ში დამატება**, არასავალდებულო ალბომით. ფაილები ჩვეულებრივად ექსპორტირდება თქვენს საქაღალდეში", + "The Hasselblad Lunar's raws (a Sony NEX-7 inside) now develop from their sensor data with every editing tool,": "Hasselblad Lunar-ის (შიგნით Sony NEX-7) RAW ფაილები ახლა სენსორის მონაცემებიდან მუშავდება ყველა რედაქტირების ხელსაწყოთი,", + "The Import Photos review opens bigger and can be resized; its photo grid fills it (issue #337). Shift-click checks": "ფოტოების იმპორტის მიმოხილვა უფრო დიდი იხსნება და მისი ზომის შეცვლა შეიძლება; ფოტოების ბადე მას ავსებს (ხარვეზი #337). Shift-დაწკაპუნება", + "The Keywords panel ends with a Keyword List, as in Lightroom Classic: every keyword with its photo count, those no": "საკვანძო სიტყვების პანელი საკვანძო სიტყვების სიით სრულდება, როგორც Lightroom Classic-ში: ყველა საკვანძო სიტყვა ფოტოების რაოდენობით, მათ შორის ისეთებიც,", + "The Presets panel builds only the rows in view (and their thumbnails) instead of every preset on every frame; the": "პრესეტების პანელი აგებს მხოლოდ ხილულ რიგებს (და მათ ესკიზებს) ყოველ კადრზე ყველა პრესეტის ნაცვლად;", + "The SAM 3 model is installed: Object and Describe masks are ready.": "SAM 3 მოდელი დაინსტალირებულია: „ობიექტის“ და „აღწერის“ ნიღბები მზადაა.", + "The Tint slider works the right way round (issues #188, #321): left adds green, right adds magenta, as its track": "ტონირების სლაიდერი სწორი მიმართულებით მუშაობს (ხარვეზები #188, #321): მარცხნივ მწვანეს ამატებს, მარჯვნივ მეწამულს, როგორც მისი ზოლი", + "The Windows app no longer quits at launch with \"Parent device is lost\" when another program's `dxcompiler.dll` is": "Windows-ის აპი გაშვებისას აღარ ითიშება შეტყობინებით „Parent device is lost“, როცა სხვა პროგრამის `dxcompiler.dll`", + "The Windows installer asks where to install LightCraft (Program Files by default; upgrades keep the folder you": "Windows-ის ინსტალატორი კითხულობს, სად დაყენდეს LightCraft (ნაგულისხმევად Program Files; განახლებები თქვენ მიერ არჩეულ", + "The catalog format is now version 3 (`Album.order`, `Op::SetAlbumOrder`). Once this version has opened a library,": "კატალოგის ფორმატი ახლა მე-3 ვერსიაა (`Album.order`, `Op::SetAlbumOrder`). როგორც კი ეს ვერსია ბიბლიოთეკას გახსნის,", + "The catalog format is now version 4 (`Catalog.folder_records`, `Op::SetFolderRecord`). Once this version has": "კატალოგის ფორმატი ახლა მე-4 ვერსიაა (`Catalog.folder_records`, `Op::SetFolderRecord`). როგორც კი ეს ვერსია", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "კატალოგს აქვს ფორმატის ვერსია (ხარვეზი #102). ძველი LightCraft-ის ბიბლიოთეკის გახსნა მას განაახლებს; ბიბლიოთეკა", + "The chips at the top of the Keywords panel are the keywords of every selected photo, not only the active one's; a": "საკვანძო სიტყვების პანელის თავში ჩიპები ყველა მონიშნული ფოტოს საკვანძო სიტყვებია და არა მხოლოდ აქტიურისა;", + "The colour is described by the PNG `cICP` chunk, not an ICC profile. HDR-aware viewers (current Chromium-based": "ფერი აღწერილია PNG-ის `cICP` ნაწილით და არა ICC პროფილით. HDR-ის მცოდნე მაყურებლები (მაგ., მიმდინარე Chromium-ზე დაფუძნებული", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "მართვის პორტი (`--control`) კავშირს ხურავს, როგორც კი მიიღებს რამეს, რაც JSON მოთხოვნა არ არის", + "The copy is a 16-bit PSD by default, which PhotoCraft saves straight back to with File ▸ Save; when you come back to": "ასლი ნაგულისხმევად 16-ბიტიანი PSD-ია, რომელშიც PhotoCraft პირდაპირ ინახავს ბრძანებით ფაილი → შენახვა; როცა დაბრუნდებით", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "ხმაურის მოცილების მოდელი დაინსტალირებული და გამოყენებაშია: AI ხმაურის მოცილების სიძლიერის სლაიდერი „დეტალში“ ახლა მუშაობს", + "The desktop app keeps a log file: `logs/lightcraft.log` in its settings folder (Linux `~/.config/lightcraft/logs/`),": "სამუშაო მაგიდის აპი ჟურნალის ფაილს ინახავს: `logs/lightcraft.log` თავის პარამეტრების საქაღალდეში (Linux `~/.config/lightcraft/logs/`),", + "The download didn't work:": "ჩამოტვირთვა ვერ მოხერხდა:", + "The eye on the Light, Color and Detail section headers now hides their adjustments, as it already did for Effects,": "სინათლის, ფერის და დეტალის სექციების სათაურებზე თვალი ახლა მათ კორექციებს მალავს, როგორც უკვე ხდებოდა ეფექტების,", + "The first time, macOS asks whether LightCraft may control Photos.": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა.", + "The first time, macOS asks whether LightCraft may control Photos. Photos may ask about duplicates; files it skips": "პირველად macOS იკითხავს, შეუძლია თუ არა LightCraft-ს Photos-ის მართვა. Photos-მა შეიძლება დუბლიკატებზე იკითხოს; ფაილები, რომლებსაც გამოტოვებს,", + "The graphics card is set up when the next photo is made.": "ვიდეობარათი გაიმართება, როცა შემდეგი ფოტო დამუშავდება.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "იმპორტი კატალოგს კითხულობს მისი შეცვლის გარეშე და მის ფოტოებს, ცვლილებებს, საკვანძო სიტყვებსა და კოლექციებს ახლა გახსნილ ბიბლიოთეკას ამატებს.", + "The interface is available in Spanish (issue #371), German and Russian (Edit ▸ Language), alongside English,": "ინტერფეისი ხელმისაწვდომია ესპანურად (ხარვეზი #371), გერმანულად და რუსულად (რედაქტირება → ენა), ინგლისურის,", + "The kept original no longer pushes everything else out of the cache: on a 64 MP raw it filled the cache share": "შენახული ორიგინალი აღარ აძევებს ქეშიდან ყველაფერ დანარჩენს: 64 MP RAW-ზე ის ქეშის წილს", + "The keyword set's buttons and the suggestions follow the selection too; the typing boxes get the Cut / Copy / Paste": "საკვანძო სიტყვების ნაკრების ღილაკები და შეთავაზებებიც მონიშნულს მიჰყვება; ასაკრეფ ველებს აქვს ამოჭრა / კოპირება / ჩასმის", + "The library is up to date with this folder.": "ბიბლიოთეკა ამ საქაღალდესთან სინქრონიზებულია.", + "The loupe says \"Rendering…\" at the bottom of the photo when what it shows isn't the finished photo yet: the": "ლუპა ფოტოს ქვედა ნაწილში წერს „რენდერდება…“, როცა ის, რასაც აჩვენებს, ჯერ დასრულებული ფოტო არ არის:", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "მოდელი მხოლოდ ამ კომპიუტერზე ინახება. LightCraft მას არასოდეს ატვირთავს და არ აზიარებს.", + "The number at the end of the original name (IMG_0042 → 0042)": "რიცხვი ორიგინალი სახელის ბოლოს (IMG_0042 → 0042)", + "The open library's name shows under My Photos at the top of the left panel and in the window title.": "გახსნილი ბიბლიოთეკის სახელი ჩანს „ჩემი ფოტოების“ ქვეშ, მარცხენა პანელის ზედა ნაწილში, და ფანჯრის სათაურში.", + "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": "ფოტოს ეს პარამეტრები აქვს", + "The processor does the work.": "სამუშაოს პროცესორი ასრულებს.", + "The rule editor marks each rule that needs fixing right under it and keeps OK disabled until they are fixed. An": "წესების რედაქტორი თითოეულ გასასწორებელ წესს უშუალოდ მის ქვეშ აღნიშნავს და OK-ს გამორთულს ტოვებს, სანამ არ გასწორდება.", + "The rule editor's field menu is grouped: Rating, Pick Flag, Color Label and Any Searchable Text at the top, then": "წესების რედაქტორის ველების მენიუ დაჯგუფებულია: შეფასება, რჩეულის დროშა, ფერადი იარლიყი და ნებისმიერი საძიებო ტექსტი თავში, შემდეგ", + "The starting look fitted from a raw's camera JPEG now uses its darkest tones too (issue #1307).": "RAW-ის კამერის JPEG-იდან მორგებული საწყისი იერი ახლა მის ყველაზე მუქ ტონებსაც იყენებს (ხარვეზი #1307).", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "ესკიზების ქეში მხოლოდ საკუთარ ფაილებს ითვლის და შლის (ხარვეზი #98): საქაღალდეზე გახსნილი ბიბლიოთეკა, რომელიც", + "The top bar's appearance button cycles Auto, Light, Dark (a monitor, sun or moon), and View ▸": "ზედა ზოლის იერსახის ღილაკი მონაცვლეობს: ავტომატური, ღია, ბნელი (მონიტორი, მზე ან მთვარე), ხედი →", + "Their photos and the keywords below them come together under one keyword.": "მათი ფოტოები და მათ ქვეშ მყოფი საკვანძო სიტყვები ერთი საკვანძო სიტყვის ქვეშ ერთიანდება.", + "Theme": "თემა", + "There is no model file to install": "დასაინსტალირებელი მოდელის ფაილი არ არის", + "They can be restored from Recently Deleted until it is emptied.": "მათი აღდგენა შესაძლებელია „ახლახან წაშლილიდან“, სანამ ის არ დაცარიელდება.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "ისინი „ახლახან წაშლილში“ გადადის და შეიძლება აღდგეს; დისკზე არცერთ ფაილს არ ეხება.", + "Thirds": "მესამედები", + "This Computer": "ეს კომპიუტერი", + "This build can't download the model yet. To install it by hand, put model.safetensors, vocab.json and merges.txt from Meta's facebook/sam3 in the folder above: Object and Describe work as soon as they are there.": "ამ ვერსიას მოდელის ჩამოტვირთვა ჯერ არ შეუძლია. ხელით დასაინსტალირებლად ზემოთ მითითებულ საქაღალდეში მოათავსეთ model.safetensors, vocab.json და merges.txt Meta-ს facebook/sam3-დან: „ობიექტი“ და „აღწერა“ იქ მათი გაჩენისთანავე იმუშავებს.", + "This build cannot run denoise models.": "ამ ვერსიას ხმაურის მოცილების მოდელების გაშვება არ შეუძლია.", + "This build cannot run recognition models: they can be added and chosen, not used.": "ამ ვერსიას ამოცნობის მოდელების გაშვება არ შეუძლია: მათი დამატება და არჩევა შეიძლება, გამოყენება — არა.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "ამ ვერსიას ხმაურის მოცილების მოდელების შესანახი ადგილი არ აქვს, ამიტომ AI ხმაურის მოცილება მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "ამ ვერსიას სახეების მოდელების შესანახი ადგილი არ აქვს, ამიტომ მოდელების სია მიუწვდომელია. (სამაგიდო აპლიკაციას აქვს.)", + "This file cannot be used yet": "ამ ფაილის გამოყენება ჯერ შეუძლებელია", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "ეს საქაღალდე მიუწვდომელია (დისკი გამოერთებულია?). ჭკვიანი წინასწარი ხედები არ იქმნება და არ გამოიყენება, სანამ ის არ დაბრუნდება ან სხვა საქაღალდეს არ აირჩევთ.", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "ეს საქაღალდე აქ არ არის: ის გადაიტანეს ან გადაარქვეს, ან მისი დისკი არ არის მიერთებული.", + "This group is empty: add a rule or remove it.": "ეს ჯგუფი ცარიელია: დაამატეთ წესი ან წაშალეთ ის.", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "ეს Lightroom-ის კატალოგია და არა LightCraft-ის ბიბლიოთეკა. გსურთ მისი LightCraft-ის ბიბლიოთეკაში იმპორტი?", + "This is the album you're editing": "ეს ის ალბომია, რომელსაც არედაქტირებთ", + "This photo can't be opened": "ამ ფოტოს გახსნა შეუძლებელია", + "Threshold": "ზღვარი", + "Thumbnail cache": "ესკიზების ქეში", + "Thursday": "ხუთშაბათი", + "Tick the box to accept the model's terms first": "ჯერ მონიშნეთ ველი მოდელის პირობების მისაღებად", + "Time zone": "სასაათო სარტყელი", + "Tint": "ტონირება", + "Title": "დასახელება", + "Title metadata": "დასახელების მეტამონაცემები", + "To hand this editor the original file itself instead (the raw, without a copy: DxO PhotoLab, for example), right-click a photo → Edit In → Open Original in Other Editor (RAW, no copy).": "თუ ამ რედაქტორს ასლის ნაცვლად თავად ორიგინალი ფაილი უნდა გადაეცეს (RAW, ასლის გარეშე: მაგალითად, DxO PhotoLab), ფოტოზე მარჯვენა ღილაკით დაწკაპეთ → რედაქტირება სხვა პროგრამაში → ორიგინალის გახსნა სხვა რედაქტორში (RAW, ასლის გარეშე).", + "Toggle Keyword from Set": "ნაკრებიდან საკვანძო სიტყვის გადართვა", + "Toggle Mask": "ნიღბის გადართვა", + "Toggle Section": "სექციის გადართვა", + "Toggle Zoom": "მასშტაბის გადართვა", + "Told when there is no GPU": "შეტყობინება, როცა GPU არ არის", + "Tone Curve": "ტონალური მრუდი", + "Tools": "ხელსაწყოები", + "Top Level": "ზედა დონე", + "Trackpads: pinch to zoom around the pointer and scroll with two fingers to pan the photo; panning keeps the photo": "თაჩპედები: მასშტაბისთვის მაჩვენებლის გარშემო მოჭერით შეკუმშეთ, ფოტოს გადასაადგილებლად კი ორი თითით გადაახვიეთ; გადაადგილებისას ფოტო", + "Transfer": "გადაცემა", + "Treatment": "დამუშავება", + "Triangle": "სამკუთხედი", + "Try Again": "ხელახლა ცდა", + "Try Saving Again": "შენახვის ხელახლა ცდა", + "Try again": "ხელახლა ცდა", + "Try the graphics card again": "ვიდეობარათის ხელახლა ცდა", + "Tu": "სამ", + "Tuesday": "სამშაბათი", + "Tungsten": "ვარვარების ნათურა", + "Turn Off": "გამორთვა", + "Turn off": "გამორთვა", + "Turn on Include subfolders, or pick another folder under Local": "ჩართეთ „ქვესაქაღალდეების ჩათვლით“ ან აირჩიეთ სხვა საქაღალდე ლოკალურში", + "Type": "ტიპი", + "Type an exact value into any slider (issue #322): click the number next to its name, type (`1.5`, `-20`, `5600`)": "აკრიფეთ ზუსტი მნიშვნელობა ნებისმიერ სლაიდერში (ხარვეზი #322): დაწკაპეთ მისი სახელის გვერდით რიცხვზე, აკრიფეთ (`1.5`, `-20`, `5600`)", + "Uncheck All": "ყველას მონიშვნის მოხსნა", + "Uncheck the files of this type": "ამ ტიპის ფაილების მონიშვნის მოხსნა", + "Undo": "დაბრუნება", + "Unedited": "დაუმუშავებელი", + "Unedited raws": "დაუმუშავებელი RAW-ები", + "Unflag": "დროშის მოხსნა", + "Unflagged": "დროშის გარეშე", + "Ungroup Stack": "დასტის დაშლა", + "Unknown": "უცნობი", + "Unknown Date": "უცნობი თარიღი", + "Unknown licence": "უცნობი ლიცენზია", + "Unknown tags stay as typed — check the preview for a {typo}.": "უცნობი ტეგები რჩება ისე, როგორც აკრიფეთ — გადახედვაში შეამოწმეთ {typo}.", + "Unnamed Faces": "უსახელო სახეები", + "Unrated": "შეუფასებელი", + "Unreadable": "წაუკითხავი", + "Update": "განახლება", + "Update Mask Shape": "ნიღბის ფორმის განახლება", + "Update Preset with Current Settings": "პრესეტის განახლება მიმდინარე პარამეტრებით", + "Update Rules from Current Filter": "წესების განახლება მიმდინარე ფილტრიდან", + "Update Version": "ვერსიის განახლება", + "Update Version with Current Settings": "ვერსიის განახლება მიმდინარე პარამეტრებით", + "Update to Current Process": "მიმდინარე პროცესის ვერსიაზე განახლება", + "Update with Current Settings": "განახლება მიმდინარე პარამეტრებით", + "Updated the edits saved in the external editor": "გარე რედაქტორში შენახული ცვლილებები განახლდა", + "Upgrading from a Windows installer older than 0.6 (such as 0.5.0) keeps LightCraft in the folder it was installed": "0.6-ზე ძველი Windows-ის ინსტალერიდან (მაგ., 0.5.0) განახლებისას LightCraft რჩება იმ საქაღალდეში, სადაც", + "Upright": "Upright", + "Urban": "ქალაქური", + "Use": "გამოყენება", + "Use Keyword Set": "საკვანძო სიტყვების ნაკრების გამოყენება", + "Use Library Folder": "ბიბლიოთეკის საქაღალდის გამოყენება", + "Use Screen Profile": "ეკრანის პროფილის გამოყენება", + "Use camera-specific defaults": "კამერისთვის განკუთვნილი ნაგულისხმევი პარამეტრების გამოყენება", + "Use sRGB": "sRGB-ის გამოყენება", + "Use the GPU for rendering": "რენდერირებისთვის GPU-ის გამოყენება", + "User Presets": "მომხმარებლის პრესეტები", + "Username": "მომხმარებლის სახელი", + "Value": "მნიშვნელობა", + "Variance": "დისპერსია", + "Version": "ვერსია", + "Version name": "ვერსიის სახელი", + "Versions": "ვერსიები", + "Versions (Shift+V)": "ვერსიები (Shift+V)", + "Vertical": "ვერტიკალური", + "Vertical inset (% of short edge)": "ვერტიკალური დაშორება (მოკლე გვერდის %)", + "Vertical text": "ვერტიკალური ტექსტი", + "Vibrance": "სიხალასე", + "Video": "ვიდეო", + "Video Duration": "ვიდეოს ხანგრძლივობა", + "Videos": "ვიდეოები", + "View": "ხედი", + "Viewing and sharing": "ნახვა და გაზიარება", + "Vignette": "ვინიეტი", + "Vignetting": "ვინიეტირება", + "Vintage": "ვინტაჟი", + "Virtual Copy": "ვირტუალური ასლი", + "Visible": "ხილული", + "Visualize HDR": "HDR-ის ვიზუალიზაცია", + "Visualize Spots": "ლაქების ვიზუალიზაცია", + "Visualize Spots (A)": "ლაქების ვიზუალიზაცია (A)", + "Visualize range": "დიაპაზონის ვიზუალიზაცია", + "Vivid": "ცოცხალი", + "Vivid Pop": "ცოცხალი და მკვეთრი", + "Wait for the import to finish": "დაელოდეთ იმპორტის დასრულებას", + "Waiting to start…": "დაწყების მოლოდინში…", + "Warm Film": "თბილი ფირი", + "Warm Glow": "თბილი ნათება", + "Warm Print": "თბილი ანაბეჭდი", + "Warm Table": "თბილი მაგიდა", + "Watched folder": "თვალთვალის ქვეშ მყოფი საქაღალდე", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "ავტოიმპორტი თვალთვალის ქვეშ მყოფი საქაღალდიდან; DNG-ად გარდაქმნა; დუბლირება; სტანდარტული / 1:1 / ჭკვიანი წინასწარი ხედების შექმნა.", + "Watermark": "ჭვირნიშანი", + "We": "ოთხ", + "Wednesday": "ოთხშაბათი", + "What's New": "რა არის ახალი", + "When GPU rendering is on but no graphics processor can be found, LightCraft now says so once at start-up instead": "როცა GPU რენდერი ჩართულია, მაგრამ გრაფიკული პროცესორი ვერ მოიძებნა, LightCraft ახლა ამას გაშვებისას ერთხელ გეუბნებათ, ნაცვლად იმისა,", + "When a folder holds several file types, the Import Photos review has a toggle per type (`ARW · 120`, `JPG · 120`):": "როცა საქაღალდეში რამდენიმე ტიპის ფაილია, ფოტოების იმპორტის მიმოხილვას თითო ტიპზე გადამრთველი აქვს (`ARW · 120`, `JPG · 120`):", + "When rated or hovered": "შეფასებისას ან კურსორის მიტანისას", + "White Balance": "თეთრის ბალანსი", + "White Balance Selector": "თეთრის ბალანსის ამრჩევი", + "White Balance Selector (W)": "თეთრის ბალანსის ამრჩევი (W)", + "White balance on DNGs (and other raws with a colour matrix) re-develops the photo for the new white through the": "თეთრის ბალანსი DNG-ებზე (და ფერის მატრიცის მქონე სხვა RAW-ებზე) ფოტოს ახალი თეთრისთვის ხელახლა ამუშავებს", + "Whites": "თეთრები", + "Width": "სიგანე", + "Width (% of photo)": "სიგანე (ფოტოს %)", + "Width (px)": "სიგანე (პქს)", + "Width × Height": "სიგანე × სიმაღლე", + "Will Export": "ექსპორტირდება", + "Will be replaced": "ჩანაცვლდება", + "Will be restored": "აღდგება", + "Window": "ფანჯარა", + "Winter Blue": "ზამთრის ლურჯი", + "With **HDR output** on, a photo edited in HDR can now be exported as a **16-bit PNG** with the PQ curve in Rec. 2020,": "**HDR გამოტანის** ჩართვისას HDR-ში დამუშავებული ფოტოს ექსპორტი ახლა შესაძლებელია **16-ბიტიან PNG-ად** PQ მრუდით Rec. 2020-ში,", + "With a GPU, a slider drag's preview is drawn in the same frame as the slider moves instead of one frame later,": "GPU-ით სლაიდერის გადათრევის წინასწარი ხედი იმავე კადრში იხატება, რომელშიც სლაიდერი მოძრაობს, და არა ერთი კადრით გვიან,", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "შენახვის გარეშე გასვლა (ხარვეზი #100). ის აღარ იწყებს ჩუმად სადემონსტრაციო სესიას, რომელიც ჩამოყრილ ბიბლიოთეკას ჰგავდა და", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "მუშაობს სტანდარტული (Bayer) სენსორის მქონე კამერების RAW ფაილებზე. სხვა ფაილებს ხმაურის ჩვეულებრივი შემცირება რჩება.", + "XMP Sidecar Preferences": "XMP თანმხლები ფაილების პარამეტრები", + "XMP presets apply their red / green / blue curves only when they also have the master curve and all three channel": "XMP პრესეტები წითელ / მწვანე / ლურჯ მრუდებს მხოლოდ მაშინ იყენებს, როცა მათ მთავარი მრუდიც და სამივე არხის", + "XMP sidecars": "XMP თანმხლები ფაილები", + "Year": "წელი", + "Yellow": "ყვითელი", + "Yellow Hue": "ყვითლის ფერის ტონი", + "Yellow Luminance": "ყვითლის სიკაშკაშე", + "Yellow Saturation": "ყვითლის გაჯერება", + "Yellow label": "ყვითელი იარლიყი", + "Yes": "დიახ", + "Yes/no fields (Has Edits, Cropped, Has GPS…) read Yes / No instead of true / false. A rule sent as `\"false\"` now": "დიახ/არა ველები (აქვს ცვლილებები, ჩამოჭრილი, აქვს GPS…) აჩვენებს დიახ / არა-ს, ნაცვლად true / false-ისა. `\"false\"`-ად გაგზავნილი წესი ახლა", + "You can close this: the download continues, and resumes if interrupted.": "შეგიძლიათ დახუროთ: ჩამოტვირთვა გაგრძელდება და შეწყვეტის შემთხვევაში განახლდება.", + "Your Name": "თქვენი სახელი", + "Your library couldn't be opened": "თქვენი ბიბლიოთეკის გახსნა ვერ მოხერხდა", + "Zoom": "მასშტაბი", + "Zoom 100%": "მასშტაბი 100%", + "Zoom In": "გადიდება", + "Zoom Out": "დაპატარავება", + "Zoom to Fit": "ეკრანში ჩატევა", + "Zoom to page level": "გვერდის დონემდე მასშტაბირება", + "Zoomed 64 MP raws stay decoded": "გადიდებული 64 MP RAW ფაილები დეკოდირებული რჩება", + "Zooming in with Before/After side by side decodes the original once: the Before side used to decode its own copy": "გადიდება, როცა „მანამდე/შემდეგ“ გვერდიგვერდაა, ორიგინალს ერთხელ დეკოდირებს: „მანამდე“ მხარე ადრე საკუთარ ასლს დეკოდირებდა", + "[camera preview colour](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).": "[კამერის წინასწარი ხედის ფერი](camera-preview-colour.md#spectral-camera-matrices-and-the-order-of-precedence).", + "`*.jpg` case-sensitively, so `.JPG` and `.NEF` files were hidden — issues #342 and #705).": "`*.jpg`-ს რეგისტრის გათვალისწინებით ადარებდა, ამიტომ `.JPG` და `.NEF` ფაილები დამალული იყო — ხარვეზები #342 და #705).", + "`--import <folder>` takes HEIC": "`--import <folder>` HEIC-ს იღებს", + "`--memory` sessions keep their promise to save nothing (issues #164, #169): UI changes made in one no longer": "`--memory` სესიები პირობას ასრულებს, არაფერი შეინახონ (ხარვეზები #164, #169): მათში შეტანილი UI ცვლილებები აღარ", + "`.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The": "`.lrcat-data` საქაღალდეებიდან ერთ-ერთის, ან `.lrcat`-ის) მის შიგნით აღარ ქმნის ცარიელ LightCraft-ის ბიბლიოთეკას (ხარვეზი #891).", + "`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 თანმხლებ ფაილებთან ერთად; თითოეული ორიგინალი ბარათს მხოლოდ მაშინ ტოვებს, როცა მისი", + "`Shift+6–9` labels and advances. `Shift+P` picks and advances in Photo Grid and Square Grid; it opens Presets in other views. With Auto Advance on, Shift still moves only once.": "`Shift+6–9` იარლიყს ანიჭებს და გადადის შემდეგზე. `Shift+P` ფოტოების ბადესა და კვადრატულ ბადეში რჩეულად მონიშნავს და გადადის შემდეგზე; სხვა ხედებში პრესეტებს ხსნის. ავტომატური გადასვლის ჩართვისას Shift მაინც მხოლოდ ერთხელ გადადის.", + "`export.photosImports`. See [Add to Apple Photos](apple-photos.md).": "`export.photosImports`. იხ. [Apple Photos-ში დამატება](apple-photos.md).", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; რეგისტრის მიმართ არამგრძნობიარე ტომებზე რეგისტრის ცვლილება მაინც სრულდება.", + "`lightcraft-cli --library` given a library's own file (`catalog.snap`) opens its folder instead of failing with": "`lightcraft-cli --library`, თუ მას ბიბლიოთეკის საკუთარ ფაილს (`catalog.snap`) მისცემთ, მის საქაღალდეს ხსნის, ნაცვლად შეცდომისა", + "`lightcraft-cli run --import <folder>` (and the MCP tools that take folders) now picks up HEIC, HEIF and Nikon NRW": "`lightcraft-cli run --import <folder>` (და MCP ხელსაწყოები, რომლებიც საქაღალდეებს იღებს) ახლა HEIC, HEIF და Nikon NRW", + "`lightcraft-cli` logs warnings on stderr too (issue #168); `LIGHTCRAFT_LOG` or `RUST_LOG` picks another level.": "`lightcraft-cli` გაფრთხილებებს stderr-ზეც წერს (ხარვეზი #168); `LIGHTCRAFT_LOG` ან `RUST_LOG` სხვა დონეს ირჩევს.", + "`publicDomain` (rules and agents keep using the ids; `album.ruleFields` adds `choiceLabels`).": "`publicDomain` (წესები და აგენტები კვლავ იდენტიფიკატორებს იყენებს; `album.ruleFields` ამატებს `choiceLabels`-ს).", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "სავსე დისკი) შექმნილად აღარ ითვლება და ჭკვიანი წინასწარი ხედების შექმნა მას ჩაანაცვლებს.", + "a line, a tab per level, synonyms in braces, keywords left out of export in brackets). Capture One, darktable, Adobe": "თითო სტრიქონზე, თითო ტაბულაცია დონეზე, სინონიმები ფიგურულ ფრჩხილებში, ექსპორტიდან გამორიცხული საკვანძო სიტყვები კვადრატულ ფრჩხილებში). Capture One, darktable, Adobe", + "a literal %": "სიტყვასიტყვითი %", + "a removed photo": "ამოშლილი ფოტო", + "adjust or switch them off, and DNG export keeps them. Lateral chromatic aberration data is not applied: our": "მათ არეგულირებს ან გამორთავს, DNG ექსპორტი კი ინარჩუნებს. ლატერალური ქრომატული აბერაციის მონაცემები არ გამოიყენება: ჩვენი", + "after a rename, an `.XMP` sidecar. Adding photos or a folder, Browse Folder (Local), drag-and-drop, the CLI and the": "გადარქმევის შემდეგ, `.XMP` თანმხლები ფაილი. ფოტოების ან საქაღალდის დამატება, საქაღალდის დათვალიერება (ლოკალური), გადათრევა, CLI და", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "შემდეგ (ხარვეზი #101): ნაწილობრივი ჩანაწერი იკვეთება LightCraft-ის ხელახალ ცდამდე, ასე რომ შემდეგი გაშვება ხელახლა ასრულებს ყოველ", + "again.": "ხელახლა დეკოდირდეს.", + "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.": "ბიბლიოთეკის ორიგინალებთან შედარება აღარ ათვალიერებს მთელ ბიბლიოთეკას, როცა ამ მისამართზე არაფერია.", + "album that is gone, or an empty group is reported with its rule (\"rule 2.1: no rating 0–5 is 9\")": "გამქრალი ალბომი ან ცარიელი ჯგუფი მოხსენებულია თავისი წესით („წესი 2.1: შეფასება 0–5 არ არის 9“)", + "all": "ყველა", + "alone, so the photo's preview and thumbnail sources and its neighbours' were decoded from the raw again on every": "მარტო ავსებდა, ამიტომ ფოტოს წინასწარი ხედისა და ესკიზის წყაროები, ისევე როგორც მეზობლებისა, RAW-იდან ხელახლა დეკოდირდებოდა", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "უკვე აქვს სხვა სურათების `thumbs/` საქაღალდე, ქეშის შემცირებისას ან გასუფთავებისას მათ აღარ კარგავს.", + "and": "და", + "and CR3, instead of opening flat and desaturated (issue #310). Photos already imported pick it up when re-rendered.": "და CR3, ბრტყლად და გაუფერულებულად გახსნის ნაცვლად (ხარვეზი #310). უკვე იმპორტირებული ფოტოები მას ხელახალი რენდერისას იღებს.", + "and WB2000. The compressed ones (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) still open from their": "და WB2000. შეკუმშულები (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini) კვლავ თავისი", + "and a dark theme card, each previewing its theme (dark: Charcoal, the look so far, or Midnight;": "და ბნელი თემის ბარათების თავზე, თითოეული თავის თემას აჩვენებს (ბნელი: ნახშირი — აქამდელი იერი, ან შუაღამე;", + "and a section with nothing in it says so instead of disappearing.": "ცარიელი სექცია კი ამას ამბობს და არ ქრება.", + "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 exports don't change. Agents use `app.displayProfile`. See [display profiles](display-profiles.md).": "და ექსპორტი არ იცვლება. აგენტები იყენებენ `app.displayProfile`-ს. იხ. [ეკრანის პროფილები](display-profiles.md).", + "and its Cancel / OK buttons were off screen, so it couldn't be confirmed. Now its options scroll and the title and": "და მისი ღილაკები „გაუქმება“ / OK ეკრანს გარეთ იყო, ამიტომ დადასტურება შეუძლებელი იყო. ახლა პარამეტრები გადაიხვევა, სათაური და", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "და ფანჯარას არასოდეს ყინავს. ის არასავალდებულოა და LightCraft-ის ნაწილი არ არის (Meta-ს SAM ლიცენზია, დაახლოებით 3.4 GB):", + "and other files with embedded distortion / vignetting corrections), matching what the preset does elsewhere.": "და ჩაშენებული დეფორმაციის / ვინიეტირების კორექციების მქონე სხვა ფაილები), რაც პრესეტის სხვაგან ქცევას ემთხვევა.", + "and press Return; Esc keeps the old value.": "და დააჭირეთ Return-ს; Esc ძველ მნიშვნელობას ტოვებს.", + "and the fit to the photo's own JPEG still come first, so photos that had a fitted look keep it. See": "და ფოტოს საკუთარ JPEG-ზე მორგება კვლავ უპირატესია, ასე რომ მორგებული იერის მქონე ფოტოები მას ინარჩუნებს. იხ.", + "any": "ნებისმიერი", + "app, and with LightCraft fullscreen it was hidden altogether, so Import Photos… looked like a hang until the": "აპის გვერდით აყენებდნენ, სრულეკრანიან LightCraft-თან კი ის საერთოდ დამალული იყო, ასე რომ ფოტოების იმპორტი… გაჭედვას ჰგავდა, სანამ", + "are empty": "ცარიელია", + "are unchanged; photos imported before this change get it when imported again.": "უცვლელია; ამ ცვლილებამდე იმპორტირებული ფოტოები მას ხელახალი იმპორტისას იღებს.", + "aren't empty": "არ არის ცარიელი", + "as 1/250), File Extension, Video Duration, Copy Name, In a Stack, Alt Text, City, State / Province, Country, Long": "როგორც 1/250), ფაილის გაფართოება, ვიდეოს ხანგრძლივობა, ასლის სახელი, დასტაში, ალტერნატიული ტექსტი, ქალაქი, შტატი / პროვინცია, ქვეყანა, გრძელი", + "as Exposure would; the edges between bright and dark regions stay as sharp as before. This is rendering process 2:": "ისე, როგორც ექსპოზიცია; ნათელ და მუქ არეებს შორის კიდეები ისევე მკვეთრი რჩება. ეს რენდერის მე-2 პროცესია:", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "როგორც `<name>.corrupt-<time>` და გეცნობებათ; ფაილი, რომლის წაკითხვაც ვერ ხერხდება (მაგ., სხვა პროგრამის მიერ დაბლოკილი), ხელუხლებელი რჩება", + "as a raw and shows its embedded preview (preview only, with the reason), as the compressed MOS files do.": "როგორც RAW და აჩვენებს ჩაშენებულ წინასწარ ხედს (მხოლოდ წინასწარი ხედი, მიზეზით), როგორც შეკუმშული MOS ფაილები.", + "as it changes, without polling.": "მისი ცვლილებისას, გამოკითხვის გარეშე.", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "ამას არ მოითხოვთ. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (ან wgpu-ის `WGPU_BACKEND`, რომელსაც GPU რენდერირება", + "before, and becomes the Kelvin value with the same look the next time it is edited. A photo imported earlier shows": "ადრე, და შემდეგი რედაქტირებისას იმავე იერის მქონე კელვინის მნიშვნელობად იქცევა. ადრე იმპორტირებული ფოტო აჩვენებს", + "behaviour, `fresh` imports a clean copy and deletes the old record).": "ქცევას, `fresh` იმპორტს უკეთებს სუფთა ასლს და ძველ ჩანაწერს შლის).", + "belonged to another command moves to the new one. Menus show the new keys; agents use `app.setShortcut`.": "სხვა ბრძანებას ეკუთვნოდა, ახალზე გადადის. მენიუები ახალ კლავიშებს აჩვენებს; აგენტები იყენებენ `app.setShortcut`-ს.", + "beside the After's, and a window asked for again while the first was still decoding (a pan, the layout settling)": "„შემდეგის“ ასლის გვერდით, ხოლო ფანჯარა, რომელიც ხელახლა მოითხოვებოდა, სანამ პირველი ჯერ კიდევ დეკოდირდებოდა (გადაადგილება, განლაგების დასტაბილურება),", + "beside them is; they used to stay at regular weight.": "ლათინური ტექსტი; ადრე ისინი ჩვეულებრივი სისქით რჩებოდა.", + "blue": "ლურჯი", + "browsers, for example) show it in HDR; **viewers that don't read `cICP` show the PQ values as if they were sRGB**,": "ბრაუზერები) მას HDR-ში აჩვენებს; **მაყურებლები, რომლებიც `cICP`-ს არ კითხულობს, PQ მნიშვნელობებს ისე აჩვენებს, თითქოს sRGB იყოს**,", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "მათგან ნებისმიერის პაკეტები — მთელი საქაღალდეები ერთბაშად, პაკეტების მიხედვით დაჯგუფებული. პრესეტებში არსებული ნიღბებიც გადმოდის.", + "burst by file name, so the first file is the cover.": "სერიას ფაილის სახელით ალაგებს, ასე რომ პირველი ფაილი ყდაა.", + "buttons stay visible. Long lists in dialogs (What's New, All Metadata, Keyboard Shortcuts) get shorter to fit.": "ღილაკები კი ხილული რჩება. დიალოგებში გრძელი სიები (რა არის ახალი, ყველა მეტამონაცემი, კლავიატურის მალსახმობები) მოკლდება, რომ დაეტიოს.", + "by double-clicking it, delete one with − after a confirmation. Right-click for more: create inside, Put New Keywords": "ორმაგი დაწკაპუნებით, წაშლა −-ით დადასტურების შემდეგ. მარჯვენა დაწკაპუნებით მეტი: შიგნით შექმნა, ახალი საკვანძო სიტყვების", + "cached picture": "ქეშირებული სურათი", + "cached pictures": "ქეშირებული სურათი", + "camera JPEG can't be fitted no longer open with the neutral fallback: they start from colour matrices fitted to the": "კამერის JPEG-ზე მორგება შეუძლებელია, აღარ იხსნება ნეიტრალური სათადარიგო იერით: ისინი იწყება ფერის მატრიცებით, მორგებული", + "camera JPEG. Photos already imported pick the change up on Reload.": "კამერის JPEG-იდან იხსნება. უკვე იმპორტირებული ფოტოები ცვლილებას ხელახალი ჩატვირთვისას იღებს.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "კამერაში, აღარ ჩაიწვება შედეგში. „მხოლოდ წინასწარი ხედის“ სახით უკვე იმპორტირებული ფოტოები ხელახლა ჩატვირთვისას გადაერთვება. (ფაილები, რომელთა", + "camera's embedded JPEG while a raw decodes, the previous render while an edit renders, or the magnified frame": "კამერის ჩაშენებული JPEG, სანამ RAW დეკოდირდება, წინა რენდერი, სანამ რედაქტირება რენდერდება, ან გადიდებული კადრი,", + "camera's measured spectral sensitivities, from the Academy Software Foundation's rawtoaces-data. A camera profile": "კამერის გაზომილ სპექტრულ მგრძნობიარობაზე, Academy Software Foundation-ის rawtoaces-data-დან. კამერის პროფილი", + "camera's own colour matrices, as Lightroom does, instead of shifting the colours of the as-shot rendering: a grey": "კამერის საკუთარი ფერის მატრიცებით, როგორც Lightroom, გადაღებისას რენდერის ფერების წანაცვლების ნაცვლად: არჩეული", + "camera's own zoom to fill the frame) and vignetting, as with Panasonic and Sony files. The Optics profile controls": "კამერის საკუთარი მასშტაბირებით კადრის შესავსებად) და ვინიეტირება, როგორც Panasonic-ისა და Sony-ის ფაილებში. ოპტიკის პროფილის მართვის ელემენტები", + "can be attached to a report without hunting for the settings folder (issue #260).": "ანგარიშს შეიძლება დაერთოს პარამეტრების საქაღალდის ძებნის გარეშე (ხარვეზი #260).", + "can't be picked, such as one that would make an album include itself, are greyed with the reason. Esc with a dropdown or calendar open in a dialog": "არჩევა შეუძლებელია, მაგ., ის, რომელიც ალბომს საკუთარი თავის ჩართვას აიძულებდა, ნაცრისფრადაა მიზეზით. Esc დიალოგში გახსნილი ჩამოსაშლელი სიით ან კალენდრით", + "can't be tested that way": "ამ გზით შემოწმება შეუძლებელია", + "can't match any rating from 0 to 5": "ვერ დაემთხვევა ვერცერთ შეფასებას 0-დან 5-მდე", + "can't test a folder; choose an album in it": "საქაღალდეს ვერ ამოწმებს; აირჩიეთ მასში არსებული ალბომი", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "ცვლილებას. კატალოგები, რომლებიც უკვე შეიცავს ასეთ ფრაგმენტს, სრულად იტვირთება. გასვლა, როცა კატალოგის ჟურნალის ჩაწერა შეუძლებელია,", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "ცვლილებების შენახვა ვერ მოხერხდა, კიდევ ერთხელ ცდის, შემდეგ კი გეკითხებათ: ხელახლა შენახვის ცდა, მაინც გასვლა ან გაუქმება.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "არხიდან ან MCP-დან, გაერთიანების წინასწარი ხედის მისამართი ან `lightcraft-cli render IMG.jpg -o IMG.jpg` უარყოფილია მკაფიო", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "მოწმდება ბარათიდან წაკითხული შიგთავსის მიხედვით. ასლი, რომელიც ვერ შეიქმნა ან განსხვავდება, იშლება და აღირიცხება როგორც წარუმატებელი", + "choice next to that (Restore them / Import as new / Leave them) now starts on Restore. Files dropped on the window": "მის გვერდით არჩევანი (მათი აღდგენა / იმპორტი ახლის სახით / მათი დატოვება) ახლა აღდგენით იწყება. ფანჯარაზე ჩაგდებული ფაილები", + "choose what to do: import the new photos and relink the moved ones (the defaults), move the missing ones to Recently": "აირჩიეთ, რა უნდა მოხდეს: ახალი ფოტოების იმპორტი და გადატანილების ხელახლა დაკავშირება (ნაგულისხმევად), დაკარგულების გადატანა ახლახან", + "choosing a profile file.": "პროფილის ფაილის არჩევის შემდეგ.", + "chose) and ends on a page saying it was installed, with a \"Launch LightCraft\" box. It used to finish without a word,": "საქაღალდეს ინარჩუნებს) და სრულდება გვერდით, სადაც წერია, რომ დაყენდა, „Launch LightCraft“ ველით. ადრე ის უსიტყვოდ სრულდებოდა,", + "closes just that.": "მხოლოდ მას ხურავს.", + "codecs' `heif` feature — 8- and 10-bit, alpha, grid tiles, the container's rotation/mirror/crop, ICC, EXIF and XMP.": "codecs-ის `heif` ფუნქციის მიღმა — 8- და 10-ბიტიანი, ალფა, ბადის ფილები, კონტეინერის შემობრუნება/არეკვლა/ჩამოჭრა, ICC, EXIF და XMP.", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "ფერითა და ტონით, რომელიც კამერის საკუთარ JPEG-ს ერგება, როგორც Nikon-ისა და Sony-ის RAW ფაილებში, თანაფარდობით, რომელიც დაყენებულია", + "colours are right on wide-gamut and calibrated displays instead of oversaturated. The Detail view renders straight": "ფართო დიაპაზონის და კალიბრებულ ეკრანებზე ფერები სწორია და არა ზედმეტად გაჯერებული. დეტალური ხედი პირდაპირ ეკრანის", + "comma, separated": "მძიმით, გამოყოფილი", + "commercial use allowed": "კომერციული გამოყენება დაშვებულია", + "commercial use subject to GPL conditions": "კომერციული გამოყენება GPL-ის პირობებით", + "compiler built into Windows (FXC). `WGPU_DX12_COMPILER=dxc` uses a `dxcompiler.dll` instead.": "Windows-ში ჩაშენებული კომპილატორით (FXC). `WGPU_DX12_COMPILER=dxc` ამის ნაცვლად `dxcompiler.dll`-ს იყენებს.", + "contains": "შეიცავს", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "ასლი შემოწმებულია და ბიბლიოთეკაშია. დუბლიკატები და ფაილები, რომლებიც ვერ დამუშავდა, თავის ადგილზე რჩება.", + "copyrighted": "დაცულია საავტორო უფლებით", + "curves, as Lightroom does: a preset with just one channel curve (or no master curve) leaves the channels alone.": "მრუდიც აქვს, როგორც Lightroom-ში: პრესეტი მხოლოდ ერთი არხის მრუდით (ან მთავარი მრუდის გარეშე) არხებს არ ეხება.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "მონაცემები ჯერ არ იშიფრება, ამიტომ ისინი მხოლოდ წინასწარ ხედად რჩება. ადრე იმპორტირებული CR3-ებისთვის ფოტო → დისკიდან ხელახლა ჩატვირთვა (ახლა", + "day 01–31": "დღე 01–31", + "days": "დღე", + "default cache share (768 MB), so it was evicted as soon as it was stored. The original of the photo shown in Detail": "ქეშის ნაგულისხმევ წილს (768 MB), ამიტომ შენახვისთანავე იშლებოდა. დეტალურ ხედში ნაჩვენები ფოტოს ორიგინალი", + "deletes a keyword from the whole library.": "შლის საკვანძო სიტყვას მთელი ბიბლიოთეკიდან.", + "demosaic already lines the colour planes up. Details and measurements: `docs/fuji-lens-corrections.md`.": "დემოზაიკა ფერთა სიბრტყეებს ისედაც ასწორებს. დეტალები და გაზომვები: `docs/fuji-lens-corrections.md`.", + "denoiseModel.accept": "denoiseModel.accept", + "depending on the graphics card. On a Windows laptop with an AMD Radeon 780M, a full-size export of a 24 MP raw": "გრაფიკული ბარათის მიხედვით. Windows-ის ლეპტოპზე AMD Radeon 780M-ით 24 MP RAW-ის სრული ზომის ექსპორტი", + "dialog was found and closed.": "დიალოგს არ იპოვიდნენ და დახურავდნენ.", + "differ by about 7–10 % on the ILCE-7M3; which one is off is still open.": "ILCE-7M3-ზე დაახლოებით 7–10 %-ით განსხვავდება; რომელია მცდარი, ჯერ ღია საკითხია.", + "directives. See README → Quick start → Logs.": "დირექტივებს. იხ. README → Quick start → Logs.", + "discarding smart previews": "ჭკვიანი წინასწარი ხედების მოცილება", + "does: As Shot reads the file's white (an ILCE-7M3 sample: 4268 K / +7), the Temp slider runs 2000–50000 K and the": "„როგორც გადაღებულია“ კითხულობს ფაილის თეთრს (ILCE-7M3-ის ნიმუში: 4268 K / +7), ტემპერატურის სლაიდერი 2000–50000 K-ია, და", + "doesn't contain": "არ შეიცავს", + "dragging a handle into the image edge stops there instead of pushing the crop out of shape.": "სახელურის გამოსახულების კიდემდე გადათრევა იქვე ჩერდება, ჩამოჭრის ფორმის დარღვევის ნაცვლად.", + "e.g. sky · the red car · car, road": "მაგ., ცა · წითელი მანქანა · მანქანა, გზა", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "კიდეზე, იჭრება, ხოლო „12-ბიტიანი შეუკუმშავი“ ფაილები აღარ იკვეთება. RX100 სერია რენდერდება გაცილებით ახლოს", + "edit at 1:1, every zoom out and every step to the next photo.": "1:1-ზე ყოველ რედაქტირებაზე, ყოველ დაპატარავებასა და შემდეგ ფოტოზე ყოველ გადასვლაზე.", + "edited earlier may shift by a few pixels.": "ჩამოჭრა და ნიღბები შეიძლება რამდენიმე პიქსელით გადაინაცვლოს.", + "ellipses and radial components in compound masks. Edge and rotation handles keep their existing": "ელიფსები და რთული ნიღბების რადიალური კომპონენტები. კიდისა და შემობრუნების სახელურები თავის არსებულ", + "embedded JPEG.": "ჩაშენებული JPEG.", + "ends with": "მთავრდება", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "ყველა ფორმატში, რომელსაც კამერები წერს. ადრე კორპუსების უმეტესობა მხოლოდ წინასწარი ხედის სახით იხსნებოდა: GH1–GH5, G, GX, GF, GM, FZ, LX და", + "every selected photo; a chip's menu acts on the selection (add to all, remove, show photos, edit) and no longer": "ყველა მონიშნული ფოტოდან; ჩიპის მენიუ მონიშნულზე მოქმედებს (ყველასთვის დამატება, მოშორება, ფოტოების ჩვენება, რედაქტირება) და აღარ", + "existing LightCraft edits are preserved by default. Source settings/history are archived, unsupported": "LightCraft-ის არსებული ცვლილებები ნაგულისხმევად შენარჩუნდება. წყაროს პარამეტრები/ისტორია არქივდება, მხარდაუჭერელი", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "ექსპორტი. კატალოგი, XMP თანმხლები ფაილები, პარამეტრები, DNG-ები, გაერთიანებები, ჭკვიანი წინასწარი ხედები და იმპორტის მიერ შექმნილი ასლები კვლავ", + "exported files will carry for the selection.": "შეიცავს ექსპორტირებული ფაილები მონიშნულისთვის.", + "exported or converted to DNG. It is not applied by default: Lightroom Classic renders ProRAW without it. To see a": "ექსპორტირდება ან DNG-ად გარდაიქმნება. ნაგულისხმევად არ გამოიყენება: Lightroom Classic ProRAW-ს მის გარეშე რენდერავს.", + "false": "მცდარი", + "favourites across restarts (issue #328).": "გადატვირთვის შემდეგაც რჩეულებად რჩება (ხარვეზი #328).", + "fields are reported, and the original Lightroom database stays read-only. Rendering is approximate.": "ველები მოხსენებაში ჩანს, ხოლო Lightroom-ის ორიგინალი მონაცემთა ბაზა მხოლოდ წაკითხვადი რჩება. რენდერი მიახლოებითია.", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "ველებს და დანარჩენს ინარჩუნებს — მაგ., იმ აპლიკაციის დამუშავების პარამეტრებსა და რედაქტირების ისტორიას — ბაიტ-ბაიტ. თანმხლები", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "ფაილიდან (Sony ამ კორპუსებზე მათ მხოლოდ მწარმოებლის შიფრირებულ შენიშვნებში ინახავს), მარჯვენა კიდეზე შევსების რამდენიმე სვეტი", + "file is gone, photos whose file was renamed or moved within the folder, and XMP sidecars changed by other apps; then": "ფაილი გაქრა, საქაღალდის შიგნით გადარქმეული ან გადატანილი ფაილების და სხვა აპების მიერ შეცვლილი XMP თანმხლები ფაილების საპოვნელად; შემდეგ", + "file name, then id, instead of import order. Recently Added keeps a batch in file name order, and Auto-Stack orders a": "ფაილის სახელით, შემდეგ id-ით, იმპორტის რიგის ნაცვლად. „ბოლოს დამატებული“ პარტიას ფაილის სახელის რიგით ინახავს, ხოლო ავტომატური დასტებად დაჯგუფება", + "files also Adobe RGB and ProPhoto), metadata as for JPEG. With HDR output, photos edited in HDR are written as": "ფაილებისთვის ასევე Adobe RGB და ProPhoto), მეტამონაცემები — როგორც JPEG-ისთვის. HDR გამოტანისას HDR-ში დამუშავებული ფოტოები იწერება როგორც", + "files, the same files as `library.import` (issue #1080). They used to be skipped without a word.": "ფაილებსაც იღებს, იგივე ფაილებს, რასაც `library.import` (ხარვეზი #1080). ადრე ისინი უსიტყვოდ გამოიტოვებოდა.", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "AI ნიღბის პირველად გამოყენებისას LightCraft ჩამოტვირთვამდე გეკითხებათ, აჩვენებს პროგრესს, შეუძლია გაუქმება და განახლება,", + "folder scans, exports (and the contact sheet PDF), Synchronize Folder, Build / Smart Previews, Lightroom catalog import, Photo Merge, model downloads, the face scan,": "საქაღალდეების სკანირება, ექსპორტი (და საკონტაქტო ფურცლის PDF), საქაღალდის სინქრონიზაცია, წინასწარი / ჭკვიანი წინასწარი ხედების შექმნა, Lightroom-ის კატალოგის იმპორტი, ფოტოების გაერთიანება, მოდელების ჩამოტვირთვა, სახეების სკანირება,", + "folder, fold that folder first (the bottom of an open folder means \"inside\"). Agents use `album.reorder`": "საქაღალდის შემდეგ მოსათავსებლად ჯერ ის საქაღალდე ჩაკეცეთ (ღია საქაღალდის ქვედა ნაწილი „შიგნით“ ნიშნავს). აგენტები იყენებენ `album.reorder`-ს", + "follow.": "მოგვიანებით დაემატება.", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "სესიის განმავლობაში, ნაგულისხმევით გადაწერის ნაცვლად. აპლიკაციის პარამეტრები (`ui.json`, რომელიც იმახსოვრებს თქვენს", + "for\").": "რამე“).", + "formats. On an E-1 photo the starting render moved from ΔE00 11.3 to 8.3 against Lightroom's. Photos already": "ფორმატებისთვის. E-1-ის ფოტოზე საწყისი რენდერი Lightroom-ის მიმართ ΔE00 11.3-დან 8.3-მდე გაუმჯობესდა. უკვე", + "from the grid onto a keyword get it.": "საკვანძო სიტყვაზე გადათრეულ ფოტოებს ის მიენიჭება.", + "full-range/matrix signalling is honoured (iPhone photos are full range), chroma is upsampled like libheif, grids": "სრული დიაპაზონის/მატრიცის სიგნალიზაცია გათვალისწინებულია (iPhone-ის ფოტოები სრული დიაპაზონისაა), ქრომა libheif-ის მსგავსად ზრდის გარჩევადობას, ბადეები", + "functions, and one Undo restores the entire drag.": "ფუნქციებს ინარჩუნებს, და ერთი დაბრუნება მთელ გადათრევას აუქმებს.", + "gets -1, -2… instead of being replaced.": "იღებს -1, -2… ჩანაცვლების ნაცვლად.", + "green": "მწვანე", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "მომწვანო ნეიტრალური რენდერის ნაცვლად (ხარვეზი #150); თეთრის ბალანსი გადაღებისდროინდელ იერთან მიმართებით რეგულირდება. 12-ბიტიანი NEF-ები", + "handle, and a program being started by another thread at that moment held a copy of the handle until it was fully": "დესკრიპტორთან ერთად ქრებოდა, და სხვა ნაკადის მიერ იმ მომენტში გაშვებული პროგრამა დესკრიპტორის ასლს ინახავდა, სანამ ბოლომდე", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "აქვს გახსნილი — ამ კომპიუტერზე ან სხვაზე, რომელიც საქაღალდეს იზიარებს — გეუბნებათ, ვისთანაა („უკვე გახსნილია LightCraft-ში (პროცესი", + "has the first date after the second": "პირველი თარიღი მეორეზე გვიანია", + "high-resolution cameras such as the Sony A7R VI (64 MP). Their decoded original (~800 MB) is bigger than the": "მაღალი გარჩევადობის კამერებზე, როგორიცაა Sony A7R VI (64 MP). მათი დეკოდირებული ორიგინალი (~800 MB) აღემატება", + "highlight recovery missed them and, darkened, they took a colour cast instead of going neutral grey.": "ღია ტონების აღდგენა მათ ვერ ხედავდა და, დამუქებისას, ნეიტრალური ნაცრისფერის ნაცვლად ფერად ელფერს იღებდა.", + "hold (such as a name in brackets), which it leaves out so the file always reads back.": "დაიტევს (მაგ., სახელი ფრჩხილებში) და რომელსაც გამოტოვებს, რათა ფაილი ყოველთვის უკან წაიკითხებოდეს.", + "hour 00–23": "საათი 00–23", + "hours": "საათი", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "როგორ აღადგინოთ ან სამუდამოდ წაშალოთ და ხელახლა მოახდინოთ იმპორტი. (ფოტოს თავიდან დასაწყებად — რედაქტირების ჩამოყრა,", + "iPhone ProRAW and other DNGs that carry their own segmentation mattes (DNG semantic masks) use them for the Select": "iPhone ProRAW და სხვა DNG-ები, რომლებსაც საკუთარი სეგმენტაციის მატები აქვს (DNG სემანტიკური ნიღბები), მათ იყენებს", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "ადრე იგნორირებული) ირჩევს გრაფიკულ ბექენდს; `off` რენდერს CPU-ზე ასრულებს. GPU ახლა ირთვება მას შემდეგ, რაც ფანჯარა", + "image": "გამოსახულება", + "immutable, and HOSTING.md describes the actual build.": "უცვლელად, ხოლო HOSTING.md რეალურ აწყობას აღწერს.", + "import only the raws and leave the JPEGs beside them (issue #344).": "შემოიტანეთ მხოლოდ RAW-ები და JPEG-ები მათ გვერდით დატოვეთ (ხარვეზი #344).", + "import review checks such files, marks them \"Will be restored\" and counts them as \"in Recently Deleted\"; the": "იმპორტის მიმოხილვა ასეთ ფაილებს ამოწმებს, აღნიშნავს „აღდგება“ და ითვლის როგორც „ახლახან წაშლილში“;", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "იმპორტი — ასე რომ „იმპორტი დასრულდა“ ნიშნავს, რომ ასლები ვარგისია ბარათის დაფორმატებამდე — და დაკავებული სახელი", + "imported as \"preview only\" switch over on Reload.": "„მხოლოდ წინასწარი ხედის“ სახით იმპორტირებული, ხელახლა ჩატვირთვისას გადაერთვება.", + "imported earlier switch over with Photo ▸ Reload from Disk.": "ადრე იმპორტირებული ფოტოები გადაერთვება ბრძანებით ფოტო → დისკიდან ხელახლა ჩატვირთვა.", + "imported from files (or read from sidecars) keep the hierarchy `lr:hierarchicalSubject` gives them.": "ფაილებიდან იმპორტირებული (ან თანმხლები ფაილებიდან წაკითხული) საკვანძო სიტყვები ინარჩუნებს `lr:hierarchicalSubject`-ის იერარქიას.", + "imported pick it up when re-rendered.": "იმპორტირებული ფოტოები მას ხელახალი რენდერისას იღებს.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "მცირე ნაწილებად, რათა ნელი ინტეგრირებული GPU-ები მათმა watchdog-მა არ გადატვირთოს. `ui.inspect` → `perf`", + "in use": "გამოიყენება", + "in, instead of moving it back to Program Files (issue #399). If that folder has been deleted since, the upgrade": "დაყენდა, და აღარ ბრუნდება Program Files-ში (ხარვეზი #399). თუ ის საქაღალდე მას შემდეგ წაიშალა, განახლება", + "includes the folders inside it": "მოიცავს შიდა საქაღალდეებს", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "არასრული, ხელახლა სრულდება CPU-ზე — ფაილი ორივე შემთხვევაში ერთი და იგივე გამოსახულებაა. სამუშაო GPU-ს ეგზავნება", + "inference on a Mac through SSH. Saved masks still render and export offline.": "ინფერენსი კი SSH-ით Mac-ზე გაუშვით. შენახული ნიღბები ოფლაინაც რენდერდება და ექსპორტირდება.", + "inside it; undo and redo carry them back and forth.": "იარლიყებთან ერთად; დაბრუნება და ხელახლა შესრულება მათ წინ და უკან გადაატარებს.", + "inside the view. A plain mouse wheel over a zoomed photo pans it too.": "ხედის შიგნით რჩება. ჩვეულებრივი მაუსის ბორბალი გადიდებულ ფოტოზე მასაც გადააადგილებს.", + "installed": "დაინსტალირებულია", + "installs to Program Files.": "Program Files-ში დაყენდება.", + "instead of opening as their embedded preview. Photos imported earlier switch over with Photo ▸ Reload from Disk.": "ჩაშენებული წინასწარი ხედის სახით გახსნის ნაცვლად. ადრე იმპორტირებული ფოტოები გადაირთვება: ფოტო → დისკიდან ხელახლა ჩატვირთვა.", + "instead of quietly matching nothing or everything. New date rules start at the current year.": "და აღარ ემთხვევა ჩუმად არაფერს ან ყველაფერს. ახალი თარიღის წესები მიმდინარე წლით იწყება.", + "into the display's own gamut; thumbnails and other previews are converted to it. Histograms, the preview caches": "საკუთარ ფერთა დიაპაზონში რენდერდება; ესკიზები და სხვა წინასწარი ხედები მასში გარდაიქმნება. ჰისტოგრამები, წინასწარი ხედების ქეში", + "is": "არის", + "is <": "<", + "is >": ">", + "is after": "გვიანაა, ვიდრე", + "is before": "ადრეა, ვიდრე", + "is between": "შუალედშია", + "is empty": "ცარიელია", + "is in the last": "ბოლო", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "შენარჩუნებულია). წარუმატებელი შენახვა (საცავი სავსეა) ახლა აჩვენებს შეუნახავობის გაფრთხილებას და მეორდება, ფოტო, რომლის შენახვაც ვერ ხერხდება,", + "is refused and nothing is overwritten.": "უარყოფილია და არაფერი გადაიწერება.", + "is touched. Agents use `folder.scanChanges` and `folder.synchronize`.": "არ ეხება. აგენტები იყენებენ `folder.scanChanges`-სა და `folder.synchronize`-ს.", + "is unknown": "უცნობია", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "ჩაირთვება და მხოლოდ მაშინ, როცა პარამეტრები → წარმადობა → რენდერირებისთვის GPU-ის გამოყენება ჩართულია; თუ LightCraft ოდესმე ავარიულად დაიხურება", + "is yes or no": "უნდა იყოს დიახ ან არა", + "is ≤": "≤", + "is ≥": "≥", + "isn't": "არ არის", + "isn't a field rules know": "წესებისთვის ცნობილი ველი არ არის", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "არ ემატება, მეორე ჩანართი პირველის გადაწერის ნაცვლად შეტყობინებას აჩვენებს, `?reset` ჯერ გეკითხებათ, ხოლო გვერდი გეუბნებათ,", + "isn't empty": "არ არის ცარიელი", + "isn't in the last": "არ არის ბოლო", + "isn't one of the choices": "არჩევანთაგანი არ არის", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "მას მეორისგან (ხარვეზი #92). დაკარგული ფოტოების ძებნა გადარქმეულ ფაილებსაც პოულობს შიგთავსით, უპირატესობას ანიჭებს შიგთავსის დამთხვევას", + "it. The list is there with no photo selected too, to set up keywords first.": "მის შიგნით მოთავსდება. სია ფოტოს მონიშვნის გარეშეც ჩანს, რომ საკვანძო სიტყვები წინასწარ მოამზადოთ.", + "its embedded JPEG or a thumbnail to show, the loupe now says so at the bottom of the photo (\"Original offline · no": "საჩვენებლად მისი ჩაშენებული JPEG ან ესკიზი, ლუპა ახლა ამას ფოტოს ქვედა ნაწილში წერს („ორიგინალი მიუწვდომელია · ჭკვიანი", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "თავის ფოტოებსა და XMP თანმხლებ ფაილებს, და ფოტოები ისევ მასზე მიუთითებს. თუ ძველ ადგილს ახლა რამე იკავებს, დაბრუნება", + "its real As Shot temperature once the file is opened (or Reload reads it).": "ნამდვილ „როგორც გადაღებულია“ ტემპერატურას, როგორც კი ფაილი გაიხსნება (ან ხელახალი ჩატვირთვა წაიკითხავს).", + "keep names — or e.g. {date}_{seq:3}": "სახელების შენარჩუნება — ან მაგ., {date}_{seq:3}", + "keyword only some of them have is marked with an asterisk (hover: how many). Only the × removes a keyword, from": "საკვანძო სიტყვა, რომელიც მხოლოდ ზოგიერთს აქვს, ვარსკვლავითაა აღნიშნული (მიტანისას: რამდენს). მხოლოდ × შლის საკვანძო სიტყვას", + "labels 6–9, P / U and the rest did nothing, because macOS only passes ⌘ / ⌃ combinations and function keys": "იარლიყები 6–9, P / U და დანარჩენი არაფერს აკეთებდა, რადგან macOS მენიუს ზოლს მხოლოდ ⌘ / ⌃ კომბინაციებს და ფუნქციურ კლავიშებს", + "land in `ui.json` (where they replaced the saved settings), and the GPU crash sentinel no longer creates the": "ხვდება `ui.json`-ში (სადაც შენახულ პარამეტრებს ცვლიდა), ხოლო GPU-ს ავარიის მცველი იქ აღარ ქმნის", + "latitude, longitude": "განედი, გრძედი", + "launched, so reopening the library straight away could be refused with \"This library is already open in another": "არ გაეშვებოდა, ამიტომ ბიბლიოთეკის მაშინვე ხელახლა გახსნაზე შეიძლება უარი ეთქვა შეტყობინებით „ეს ბიბლიოთეკა უკვე გახსნილია სხვა", + "layout is read from each file instead of assumed.": "განლაგება თითოეული ფაილიდან იკითხება და არა ივარაუდება.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "DNG-ს არ ტოვებს და RAW-ს ინარჩუნებს. ჭკვიანი წინასწარი ხედებიც ასევე იწერება; დაზიანებული (ავარიით ან", + "leaves out whatever Excluded Photos matches, as its rules change. The album picker shows your albums as in the sidebar": "გამოტოვებს ყველაფერს, რასაც Excluded Photos ემთხვევა, მისი წესების ცვლილებასთან ერთად. ალბომის ამრჩევი აჩვენებს თქვენს ალბომებს, როგორც გვერდით ზოლში", + "left out, so these photos come out a little smaller, closer to the camera's own JPEG. Crops and masks on photos": "გამოტოვებულია, ამიტომ ეს ფოტოები ოდნავ პატარა გამოდის, კამერის საკუთარ JPEG-თან უფრო ახლოს. ადრე რედაქტირებულ ფოტოებზე", + "let go. On a 64 MP raw the first of those renders decoded the whole file again, which held up the loupe's": "სლაიდერს გაუშვებთ. 64 MP RAW-ზე ამ რენდერებიდან პირველი მთელ ფაილს ხელახლა დეკოდირებდა, რაც ლუპის", + "library locked: the lock is the operating system's and goes away with the program.": "ბიბლიოთეკას დაბლოკილს: ბლოკი ოპერაციულ სისტემას ეკუთვნის და პროგრამასთან ერთად ქრება.", + "library open now stays, and LightCraft says \"This is a Lightroom catalog, not a LightCraft library\" and offers": "გახსნილი ბიბლიოთეკა რჩება, LightCraft კი ამბობს: „ეს Lightroom-ის კატალოგია და არა LightCraft-ის ბიბლიოთეკა“ და გთავაზობთ", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "ბიბლიოთეკა) ატომურად იწერება და ინახება, როგორც კი სხვა ბიბლიოთეკას გახსნით, და არა მხოლოდ გასვლისას. გასვლა, როცა", + "light: Silver or Paper). New and existing installs stay dark; following the system is opt-in.": "ღია: ვერცხლისფერი ან ქაღალდი). ახალი და არსებული ინსტალაციები ბნელი რჩება; სისტემის მიყოლა არჩევითია.", + "like a photo that can't be opened, with the reason, instead of rendering forever; its thumbnail stops waiting too.": "როგორც ფოტო, რომლის გახსნაც შეუძლებელია, მიზეზით, უსასრულო რენდერის ნაცვლად; მისი ესკიზიც წყვეტს ლოდინს.", + "like the HDR AVIF: the same brightness, with SDR white at 203 cd/m², and content light levels recorded.": "როგორც HDR AVIF: იგივე სიკაშკაშე, SDR თეთრით 203 cd/m²-ზე, და ჩაწერილი შიგთავსის სინათლის დონეებით.", + "lit by the chosen white comes out grey and saturated colours move as the camera records them. The eyedropper and": "თეთრით განათებული ნაცრისფერი ნაცრისფერი გამოდის, გაჯერებული ფერები კი ისე იცვლება, როგორც კამერა ჩაწერს. პიპეტი და", + "longer frees it, which cost a re-upload of about a second on the next pan (800 MB for a 64 MP raw). It is freed": "მას აღარ ათავისუფლებს, რაც შემდეგ გადაადგილებაზე დაახლოებით წამიან ხელახლა ატვირთვას იწვევდა (800 MB 64 MP RAW-ისთვის). ის თავისუფლდება,", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "აღარ ამბობს მხოლოდ „დუბლიკატი გამოტოვებულია“: ის გვერდით პანელს ხსნის ახლახან წაშლილზე, ფოტო მონიშნულია, და გეუბნებათ,", + "looks right everywhere, export a JPEG with HDR output on (an SDR image plus a gain map).": "ყველგან სწორად ჩანს, ექსპორტი გააკეთეთ JPEG-ად ჩართული HDR გამოტანით (SDR გამოსახულება პლუს gain map).", + "lossy tiles decode too, keeping the raw values above 1.0 instead of clipping them (checked on one real file). A tile that cannot be decoded makes the file open from its embedded": "დანაკარგიანი ფილებიც დეკოდირდება, RAW მნიშვნელობებს 1.0-ზე ზემოთ ინარჩუნებს მოკვეთის ნაცვლად (შემოწმებულია ერთ რეალურ ფაილზე). ფილა, რომლის დეკოდირება შეუძლებელია, ფაილს მისი ჩაშენებული", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "გასვლისას ყველაფერს კარგავდა; დროებითი სესია მთელი დროის განმავლობაში აჩვენებს ბანერს და თქვენს ბიბლიოთეკაში არასოდეს წერს.", + "macOS, Linux packages, AppImage, Flatpak), and says so if it isn't installed, before making a copy.": "macOS, Linux-ის პაკეტები, AppImage, Flatpak), და თუ დაყენებული არ არის, ასლის შექმნამდე გეტყვით.", + "made a lock test flaky, and the fix closes the window for good.": "ეს ბლოკის ტესტს არასტაბილურს ხდიდა, გასწორება კი ამ ფანჯარას სამუდამოდ ხურავს.", + "magnified preview isn't taken for the photo when checking focus at 1:1.": "გადიდებული წინასწარი ხედი ფოტოდ არ მიიჩნევა, როცა ფოკუსს 1:1-ზე ამოწმებთ.", + "makes AI Denoise available": "AI ხმაურის მოცილებას ხელმისაწვდომს ხდის", + "makes it inside that folder (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` take `parent`).": "მას ამ საქაღალდის შიგნით ქმნის (`dialog.newAlbum`, `dialog.newFolder`, `dialog.smartAlbum`, `dialog.newSmartAlbum` იღებს `parent`-ს).", + "many Photos added, or why it added none. Export with Previous and export presets repeat it.": "დაამატა Photos-მა, ან რატომ ვერ დაამატა ვერცერთი. ექსპორტი წინა პარამეტრებით და ექსპორტის პრესეტები ამას იმეორებს.", + "map); the option is per photo, so presets and Copy Settings carry it.": "რუკა აქვს); პარამეტრი ფოტოს დონისაა, ასე რომ პრესეტები და პარამეტრების კოპირება მას გადაიტანს.", + "means no (it used to count as yes), and a value that is neither yes nor no is refused.": "ნიშნავს „არას“ (ადრე „დიახად“ ითვლებოდა), ხოლო მნიშვნელობა, რომელიც არც დიახია და არც არა, უარყოფილია.", + "memory only": "მხოლოდ მეხსიერებაში", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "მეხსიერება, აჭარბებს GPU-ის ბუფერის ლიმიტებს, დრაივერის შეცდომას ან გადატვირთვას აწყდება, ან აბრუნებს", + "menu, and Return in the painter's box starts painting.": "მენიუ, ხოლო Return ფუნჯის ველში მინიჭებას იწყებს.", + "message box that names the log file, instead of quitting without a trace (issue #260).": "შეტყობინების ფანჯარაში ამბობს, რომელიც ჟურნალის ფაილს ასახელებს, უკვალოდ გასვლის ნაცვლად (ხარვეზი #260).", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "შეტყობინებით, ხოლო ორიგინალი ბაიტ-ბაიტ ხელუხლებელი რჩება. ჩვეულებრივი წინა ექსპორტები მოთხოვნისას კვლავ გადაიწერება.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "სერიის შუაში: შეცდომა მათ ჩამოთვლის (ძველი → ახალი), ბიბლიოთეკა კი მათ ახალ სახელებზე მიუთითებს (ნაწილობრივი გადარქმევა, რომლის დაბრუნებაც შეიძლება),", + "minute": "წუთი", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "მოდელებიდან, რომელთა სენსორი მწვანე-ლურჯი რიგით იწყება) აღარ გამოდის მეწამული (ხარვეზი #85): ფერის ფილტრის", + "month 01–12": "თვე 01–12", + "months": "თვე", + "moved from ΔE00 7.6 to 5.2 against Lightroom's.": "Lightroom-ის მიმართ ΔE00 7.6-დან 5.2-მდე გაუმჯობესდა.", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "სახელით (ფაილის ჩანაცვლების გარეშე), და მხოლოდ ამის შემდეგ ხდება ფოტოს ხელახლა მიბმა ან RAW ასლის მოცილება — წარუმატებელი ჩაწერა", + "names an album that no longer exists": "მიუთითებს ალბომზე, რომელიც აღარ არსებობს", + "needs a date like 2026, 2026-08 or 2026-08-14": "საჭიროა თარიღი, მაგ., 2026, 2026-08 ან 2026-08-14", + "needs a number": "საჭიროა რიცხვი", + "needs a number more than 0": "საჭიროა 0-ზე მეტი რიცხვი", + "needs a time like 1/250 or 2": "საჭიროა დრო, მაგ., 1/250 ან 2", + "needs an album": "საჭიროა ალბომი", + "needs hours, days, weeks, months or years": "საჭიროა საათები, დღეები, კვირები, თვეები ან წლები", + "needs something to look for (or use “are empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „ცარიელია“)", + "needs something to look for (or use “aren't empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „არ არის ცარიელი“)", + "needs something to look for (or use “is empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „ცარიელია“)", + "needs something to look for (or use “isn't empty”)": "საჭიროა საძიებო ტექსტი (ან გამოიყენეთ „არ არის ცარიელი“)", + "needs text": "საჭიროა ტექსტი", + "needs two values": "საჭიროა ორი მნიშვნელობა", + "neutral default.": "ნეიტრალური ნაგულისხმევით იწყებოდა.", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "არასოდეს ტოვებს შეკვეცილ ფაილს; „ორიგინალის“ ექსპორტის XMP თანმხლები ფაილიც „თუ ფაილი არსებობს“ არჩევანს მიჰყვება.", + "never restore anything. Agents: `library.import` → `onDeleted` defaults to `restore` (`skip` keeps the old": "არაფერს აღადგენს. აგენტები: `library.import` → `onDeleted` ნაგულისხმევად `restore`-ია (`skip` ინარჩუნებს ძველ", + "new photos (and Reset) get it, photos you already edited keep their look until you choose Photo ▸ Update to Current": "ახალი ფოტოები (და ჩამოყრა) მას იღებს, უკვე დამუშავებული ფოტოები კი თავის იერს ინარჩუნებს, სანამ არ აირჩევთ ფოტო → მიმდინარე", + "next to it while you rotate (issue #534). For an exact angle, type it in the new Angle field under Straighten and press Return": "შემობრუნებისას მის გვერდით ჩანს (ხარვეზი #534). ზუსტი კუთხისთვის აკრიფეთ ის გასწორების ქვეშ ახალ კუთხის ველში და დააჭირეთ Return-ს", + "no longer saves it). Return in those dialogs now confirms them, and their name opens selected. More fields will": "აღარ ინახავს მას). Return ამ დიალოგებში ახლა ადასტურებს მათ, ხოლო სახელი მონიშნული იხსნება. სხვა ველებიც", + "non-commercial use only": "მხოლოდ არაკომერციული გამოყენება", + "none": "არცერთი", + "none (CPU rendering)": "არცერთი (CPU რენდერირება)", + "none of": "არცერთი", + "not added to Apple Photos (cancelled)": "Apple Photos-ში არ დაემატა (გაუქმდა)", + "not available here": "აქ ხელმისაწვდომი არ არის", + "noticeably duller.": "შესამჩნევად მკრთალი ჩანდა.", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "ახლა მუშავდება RAW მონაცემებიდან კამერის ჩაშენებული JPEG-ის ნაცვლად, ასე რომ შავ-თეთრი ან სხვა გამოსახულების სტილი, რომელიც დაყენებულია", + "now import as \"preview only\" with their embedded JPEG instead of failing. A raw whose data is damaged but whose": "ახლა „მხოლოდ წინასწარი ხედის“ სახით იმპორტირდება ჩაშენებული JPEG-ით, შეცდომის ნაცვლად. RAW, რომლის მონაცემები დაზიანებულია, მაგრამ", + "now measured on the masked columns alone (8 corpus bodies, EOS 40D to 5DS R). On a 6D photo the starting render": "ახლა მხოლოდ დაფარულ სვეტებზე იზომება (კორპუსის 8 კამერა, EOS 40D-დან 5DS R-მდე). 6D-ის ფოტოზე საწყისი რენდერი", + "now stays, like its preview, until another photo or view is shown.": "ახლა რჩება, მისი წინასწარი ხედის მსგავსად, სანამ სხვა ფოტო ან ხედი არ გამოჩნდება.", + "of quietly rendering everything on the CPU, much more slowly. Help ▸ System Info says why. On NixOS this happens": "რომ ყველაფერი ჩუმად, გაცილებით ნელა, CPU-ზე დაარენდეროს. დახმარება → სისტემის ინფორმაცია მიზეზს გეტყვით. NixOS-ზე ეს ხდება,", + "of the following rules:": "შემდეგი წესიდან:", + "older versions refuse it, as with every format change.": "ძველი ვერსიები მას აღარ გახსნიან, როგორც ფორმატის ყოველი ცვლილებისას.", + "on the CPU, are not waited for).": "CPU-ზე, არ ელოდება).", + "on the DLL search path without its `dxil.dll` (issue #471): DirectX 12 shaders now always compile with the": "DLL-ების საძიებო გზაზეა მისი `dxil.dll`-ის გარეშე (ხარვეზი #471): DirectX 12 შეიდერები ახლა ყოველთვის კომპილირდება", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "ბარათზე იმპორტის გადახედვის შემდეგ, აღირიცხება იმის ნაცვლად, რომ მოძველებული მონაცემებით იმპორტირდეს. ექსპორტის მისამართის შემოწმება", + "on; ✕ stops the tasks that can stop. Three rows show, then \"+N more\". The separate export panel and the progress": "მუშაობს; × აჩერებს შეჩერებად ამოცანებს. ჩანს სამი სტრიქონი, შემდეგ „+N კიდევ“. ცალკე ექსპორტის პანელი, პროგრესის", + "once you zoom out or move on.": "როცა დააპატარავებთ ან სხვაზე გადახვალთ.", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "სათითაოდ — მათი ხელახლა ექსპორტი ყოველთვის შეიძლება, ხოლო USB დისკზე ან NAS-ზე თითოეული ფაილის სინქრონიზაცია დიდ", + "only convert between the camera's multipliers and Kelvin; the colour look is what it was.": "მხოლოდ კამერის მამრავლებსა და კელვინებს შორის გარდაქმნის; ფერის იერი ისეთივეა, როგორიც იყო.", + "opened a library, older versions refuse it, as with every format change.": "ბიბლიოთეკას გახსნის, ძველი ვერსიები მას აღარ გახსნიან, როგორც ფორმატის ყოველი ცვლილებისას.", + "or": "ან", + "or the edge of another folder, to place it before or after it: that folder is then ordered by hand. Right-click ▸": "ან სხვა საქაღალდის კიდეზე, რომ მის წინ ან შემდეგ მოთავსდეს: იმ საქაღალდის რიგი მაშინ ხელითაა დადგენილი. მარჯვენა დაწკაპუნება →", + "or unchecks a range of photos (issue #338).": "ფოტოების დიაპაზონს მონიშნავს ან მონიშვნას მოხსნის (ხარვეზი #338).", + "out green and dark.": "მწვანე და მუქი გამოდიოდა.", + "out. Clear Recent Libraries empties the list.": "ნაცრისფრადაა. ბოლოს გახსნილი ბიბლიოთეკების სიის გასუფთავება სიას ცარიელს ხდის.", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "იმავე სახელისა და ზომის მსგავს ფაილთან შედარებით, და გამოტოვებს (და აღრიცხავს) ფოტოებს, რომელთა გარჩევაც ვერ ხერხდება, გამოცნობის ნაცვლად.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "ნაწილს გამორიცხავს), ან აკრიფეთ, რა მოინიშნოს („ცა“, „წითელი მანქანა“, „მანქანა, გზა“); ორივე სხვა ნიღბებთან ერთიანდება, აქვს", + "photo has its denoised picture.": "ფოტოს აქვს ხმაურმოცილებული სურათი.", + "photo has yet too. Filter it, tick a keyword to give it to the selected photos (a dash: only some have it), or click": "რომლებიც ჯერ არცერთ ფოტოს არ აქვს. გაფილტრეთ, მონიშნეთ საკვანძო სიტყვა, რომ მონიშნულ ფოტოებს მიენიჭოს (ტირე: მხოლოდ ზოგიერთს აქვს), ან დაწკაპეთ", + "photo waiting": "ფოტო რიგში", + "photos have their denoised picture.": "ფოტოს აქვს ხმაურმოცილებული სურათი.", + "photos waiting": "ფოტო რიგში", + "pick": "რჩეული", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "RAW რედაქტირებისა და სრული გარჩევადობის ექსპორტის შენარჩუნებით, ჩაშენებული JPEG წინასწარი ხედების ნაცვლად.", + "preset pickers in Import and Settings ▸ Import scroll the same way.": "პრესეტების ამრჩევები იმპორტსა და პარამეტრები → იმპორტში ასევე გადაიხვევა.", + "presets.json) stays responsive. Every command used to re-serialize the whole list to see whether it had changed": "presets.json) სწრაფად რეაგირებს. ადრე ყოველი ბრძანება მთელ სიას ხელახლა სერიალიზებდა, რომ გაეგო, შეიცვალა თუ არა", + "press the new keys (Esc cancels), × removes it, ↺ restores the original, Reset All undoes every change. A key that": "დააჭირეთ ახალ კლავიშებს (Esc აუქმებს), × შლის მას, ↺ თავდაპირველს აღადგენს, ყველაფრის ჩამოყრა ყველა ცვლილებას აუქმებს. კლავიში, რომელიც", + "preview is intact does the same.": "წინასწარი ხედი მთელია, იგივენაირად იქცევა.", + "preview, with the reason, instead of showing a black tile. A JPEG XL preview stored in the DNG is used like an": "წინასწარი ხედიდან ხსნის, მიზეზის მითითებით, შავი ფილის ჩვენების ნაცვლად. DNG-ში შენახული JPEG XL წინასწარი ხედი ისე გამოიყენება, როგორც", + "previews": "წინასწარი ხედი", + "previews for up to a second.": "წინასწარ ხედებს წამამდე აყოვნებდა.", + "promise, instead of filtering whatever album or folder was open, which often showed nothing (issue #341).": "გვპირდება, ღია ალბომის ან საქაღალდის გაფილტვრის ნაცვლად, რაც ხშირად არაფერს აჩვენებდა (ხარვეზი #341).", + "publicDomain": "საზოგადოებრივი საკუთრება", + "purple": "იისფერი", + "raw": "RAW", + "raws of other cameras keep the as-shot shift of issue #510. The two Kelvin scales come from different matrices and": "სხვა კამერების RAW-ები ინარჩუნებს ხარვეზი #510-ის „როგორც გადაღებულია“ წანაცვლებას. ორი კელვინის შკალა სხვადასხვა მატრიციდან მოდის და", + "read from the white-balance presets the camera stores in every ARW: a photo shot at 3400 K on the camera reads": "წაკითხულს თეთრის ბალანსის პრესეტებიდან, რომლებსაც კამერა ყველა ARW-ში ინახავს: კამერაზე 3400 K-ზე გადაღებული ფოტო აჩვენებს", + "read in the wrong units.": "არასწორ ერთეულებში იკითხებოდა.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ბარათის მესამედ წაკითხვის ნაცვლად (გადატანა წყაროს წაშლამდე კვლავ ბაიტ-ბაიტ ადარებს); ფაილი, რომელიც შეიცვალა", + "red": "წითელი", + "reject": "უარყოფილი", + "relinked to the file it is imported from. Browsing a folder in Local, Auto Import and a Lightroom catalog import": "ხელახლა უკავშირდება იმ ფაილს, საიდანაც იმპორტირდება. საქაღალდის დათვალიერება ლოკალურში, ავტოიმპორტი და Lightroom-ის კატალოგის იმპორტი", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "რენდერები და ეკრანის ანაბეჭდები კვლავ დროებით ფაილში იწერება და ადგილზე გადაირქმევა, მაგრამ დისკზე იძულებით აღარ ჩაიწერება", + "reports each row's `label`.": "თითოეული სტრიქონის `label`-ს აბრუნებს.", + "restore the photo too and say \"Restored 1 photo from Recently Deleted\". A deleted photo whose file was moved is": "ფოტოს ასევე აღადგენს და ამბობს „ახლახან წაშლილიდან აღდგა 1 ფოტო“. წაშლილი ფოტო, რომლის ფაილიც გადაიტანეს,", + "runs in the background with a row in the activity stack (✕ stops it); everything it does is one undo step, and no file on disk": "ფონურად მიმდინარეობს აქტივობების დასტაში საკუთარი სტრიქონით (× აჩერებს); ყველაფერი, რასაც აკეთებს, ერთი დაბრუნების ნაბიჯია, და დისკზე არცერთ ფაილს", + "save goes through.": "შენახვა შესრულდება.", + "saved in": "შენახულია:", + "saved in an earlier version now shifts the other way; set it again (or re-run Auto / the white-balance picker).": "შენახული მორგებული ტონირება ახლა საპირისპიროდ იხრება; კვლავ დააყენეთ (ან ხელახლა გაუშვით ავტომატური / თეთრის ბალანსის პიპეტი).", + "says what to fix, and `albums.list` reports `problems`.": "ამბობს, რა უნდა გასწორდეს, `albums.list` კი აბრუნებს `problems`-ს.", + "scale keep their look, and As Shot shows the real temperature once the file is opened.": "სკალაზე შესრულებული რედაქტირება იერს ინარჩუნებს, ხოლო „როგორც გადაღებულია“ ფაილის გახსნისთანავე რეალურ ტემპერატურას აჩვენებს.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "სცენის) ახლა სენსორის მონაცემებიდან მუშავდება ყველა რედაქტირების ხელსაწყოთი, კამერის სხვა RAW ფაილების მსგავსად. ფოტოები", + "second": "წამი", + "set). An empty field leaves its key empty instead of moving the keywords after it; editing Recent Keywords and naming": "ნაკრებად ინახავს). ცარიელი ველი თავის კლავიშს ცარიელს ტოვებს და მომდევნო საკვანძო სიტყვებს არ ანაცვლებს; ბოლოდროინდელი საკვანძო სიტყვების რედაქტირება და მათთვის სახელის", + "settings folder there. The same goes for the temporary session offered when the library can't be opened.": "პარამეტრების საქაღალდეს. იგივე ეხება დროებით სესიას, რომელიც ბიბლიოთეკის გახსნის შეუძლებლობისას გთავაზობთ.", + "show a black (or, on the 645Z, white) band along one or more edges (issue #1271). The sensor's masked border is": "აჩვენებს შავ (ან, 645Z-ზე, თეთრ) ზოლს ერთ ან რამდენიმე კიდეზე (ხარვეზი #1271). სენსორის დაფარული კიდე", + "show as a partial import.": "ნაწილობრივ იმპორტად ჩანს.", + "show no dialogs and take `INSTALLFOLDER=...`.": "დიალოგებს არ აჩვენებს და იღებს `INSTALLFOLDER=...`-ს.", + "shows and as in Lightroom, and Tint values in Lightroom XMP sidecars now render as they do there. A custom Tint": "აჩვენებს და როგორც Lightroom-ში, ხოლო Lightroom-ის XMP თანმხლებ ფაილებში ტონირების მნიშვნელობები ახლა ისე რენდერდება, როგორც იქ. ადრინდელ ვერსიაში", + "shows the label's dot before its photo count. Agents use `folder.label` (`path`, `label`); `library.folders`": "ფოტოების რაოდენობის წინ იარლიყის წერტილს აჩვენებს. აგენტები იყენებენ `folder.label`-ს (`path`, `label`); `library.folders`", + "shows where it will go), or right-click a header for Move Up / Move Down.": "აჩვენებს, სად მოხვდება), ან სათაურზე მარჯვენა დაწკაპუნებით აირჩიეთ აწევა / ჩამოწევა.", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "მონათესავე მოდელები) აღარ იხსნება კაშკაშა მწვანედ (ხარვეზი #148): მათი გადაღებისდროინდელი თეთრის ბალანსი და შავის დონე იკითხება", + "sidecar lookup always did; the Add Photos dialog on Linux did not until 0.5.0 (GTK and the desktop portal match": "თანმხლები ფაილის ძებნა ყოველთვის ასე იქცეოდა; Linux-ზე ფოტოების დამატების დიალოგი — 0.5.0-მდე არა (GTK და დესკტოპის პორტალი", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "თანმხლები ფაილი, რომელიც ვალიდური XMP არ არის, ჯერ კოპირდება `<name>.xmp.bak-<time>`-ში. ნაგულისხმევი ფუძით დასახელებისას RAW და", + "sky no longer flattens the twigs, lines and texture in it, and lifting Shadows keeps the texture of dark areas, much": "შემცირება აღარ აბრტყელებს მასში ტოტებს, ხაზებსა და ტექსტურას, ჩრდილების აწევა კი მუქი უბნების ტექსტურას ინარჩუნებს, დაახლოებით", + "smaller file; Progressive files show a coarse version first. 8- or 16-bit, sRGB, Display P3 or Rec. 2020 (lossless": "უფრო მცირე ფაილზე; პროგრესული ფაილები ჯერ უხეშ ვერსიას აჩვენებს. 8 ან 16 ბიტი, sRGB, Display P3 ან Rec. 2020 (უდანაკარგო", + "smart album value|No": "არა", + "smart album value|Yes": "დიახ", + "smart preview\" or \"This photo can't be opened\", the reason on hover) instead of showing that preview as if it were": "წინასწარი ხედი არ არის“ ან „ამ ფოტოს გახსნა შეუძლებელია“, მიზეზი — მიტანისას), ნაცვლად იმისა, რომ ეს წინასწარი ხედი ისე აჩვენოს, თითქოს", + "smart previews": "ჭკვიანი წინასწარი ხედი", + "so a successful install looked like nothing had happened (issues #18, #399). Silent installs (`/qn`, `/passive`)": "ასე რომ წარმატებული დაყენება ისე გამოიყურებოდა, თითქოს არაფერი მომხდარა (ხარვეზები #18, #399). ჩუმი დაყენებები (`/qn`, `/passive`)", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "ასე რომ არცერთი არ ჩანს დაკარგულად. RAW + JPEG წყვილიდან ერთის გადარქმევა კოპირებს მათ საერთო `IMG_0001.xmp`-ს, ნაცვლად იმისა, რომ", + "so the picture looks flat and washed out there. That's how PQ PNG works, not a broken file: for a picture that": "ამიტომ სურათი იქ ბრტყელი და გაცრეცილი ჩანს. ასე მუშაობს PQ PNG და ფაილი დაზიანებული არ არის: სურათისთვის, რომელიც", + "started yet another decode.": "კიდევ ერთ დეკოდირებას იწყებდა.", + "starting look is fitted to the camera JPEG away from edges when the misaligned edges spoil the fit on all pixels.": "საწყისი იერი კამერის JPEG-ს კიდეებისგან მოშორებით ერგება, როცა არათანხვედრი კიდეები ყველა პიქსელზე მორგებას აფუჭებს.", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "GPU-ის გაშვებისას, შემდეგი გაშვება GPU რენდერირების გამორთვით იწყება და გეუბნებათ, როგორ ჩართოთ ის ხელახლა.", + "starts using it for AI Denoise": "იწყებს მის გამოყენებას AI ხმაურის მოცილებისთვის", + "starts with": "იწყება", + "still saves your queued changes in the closing snapshot.": "რიგში მყოფ ცვლილებებს მაინც ინახავს დახურვისას შექმნილ მომენტალურ ასლში.", + "submenus for Source, File, Date, Keywords & People, Description, Camera Info, Location, Size, Develop and Assisted": "ქვემენიუები: წყარო, ფაილი, თარიღი, საკვანძო სიტყვები და ადამიანები, აღწერა, კამერის ინფორმაცია, მდებარეობა, ზომა, დამუშავება და დამხმარე", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "სინქრონიზდება. იმპორტი → კოპირება ახლა ყოველ ასლს ამოწმებს ბარათის სკანირებისას აღებული შიგთავსის ჰეშით, ნაცვლად", + "take their tiles' ICC profile, and `imir` mirrors the way libheif writes it. Importing a HEIC reads its size from": "თავისი ფილების ICC პროფილს იღებს, ხოლო `imir` ისე არეკლავს, როგორც libheif წერს. HEIC-ის იმპორტი ზომას კონტეინერიდან კითხულობს", + "terms unclear": "პირობები გაურკვეველია", + "that light already reaches (left of the image area Canon records), 4 % of the range too high on the EOS 6D. It is": "რომლებსაც სინათლე უკვე აღწევს (Canon-ის ჩაწერილი გამოსახულების არის მარცხნივ), EOS 6D-ზე დიაპაზონის 4 %-ით მეტი. ის", + "the JPEG uses `IMG_0001.JPG.xmp`.": "JPEG იყენებს `IMG_0001.JPG.xmp`-ს.", + "the LightCraft window. The xdg-desktop-portal picker used to open as a separate top-level window on Wayland": "LightCraft-ის ფანჯრის შვილობილია. xdg-desktop-portal-ის ამრჩევი ადრე Wayland-ზე ცალკე ზედა დონის ფანჯრად იხსნებოდა", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "ლოკალური საქაღალდეების ხე და ავტოიმპორტიც დისკს ფონურ ნაკადებში კითხულობს. იმპორტის პროგრესის ფანჯარას აქვს გაუქმება.", + "the My Photos title to bring one back, or choose Reset Sidebar Sections. The arrangement is remembered.": "„ჩემი ფოტოების“ სათაურზე მას დააბრუნებთ, ან აირჩიეთ გვერდითი ზოლის სექციების ჩამოყრა. განლაგება დაიმახსოვრება.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "„ფოტოს“ მენიუსა და ფოტოს კონტექსტურ მენიუში) იღებს სრული ზომის წინასწარ ხედს და ავსებს კამერის მეტამონაცემებს, რომლებიც", + "the arrow to see its photos.": "ისარზე მისი ფოტოების სანახავად.", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "კამერაში; სენსორის ცნობილი დეფექტები ივსება. `.rwl` და `.raw` ფაილებიც იმპორტირდება. ფოტოები, რომლებიც უკვე", + "the container instead of decoding it; small previews use the file's embedded thumbnail. The release packages": "დეკოდირების ნაცვლად; პატარა წინასწარი ხედები ფაილის ჩაშენებულ ესკიზს იყენებს. გამოშვების პაკეტები", + "the graphics card": "ვიდეობარათი", + "the graphics card is not used": "ვიდეობარათი არ გამოიყენება", + "the other editor (Edit in Other Editor, now ⌥⌘E), and where PhotoCraft is if you use a portable copy.": "სხვა რედაქტორისთვის (რედაქტირება სხვა რედაქტორში, ახლა ⌥⌘E), და PhotoCraft-ის მდებარეობა, თუ მის პორტატულ ასლს იყენებთ.", + "the photo.": "ის თავად ფოტო იყოს.", + "the photos in view": "ხილული ფოტოები", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "იმავე სახელით და „გადაწერა“ (ან წინა პარამეტრებით ექსპორტი, რომელიც მას იმეორებს), ზუსტი გამოტანის მისამართი მართვის", + "the selected photos": "მონიშნული ფოტოები", + "the shadows: the black level recorded in the file is now subtracted.": "ნაცრისფერი ბურუსის გარეშე იხსნება: ფაილში ჩაწერილი შავის დონე ახლა აკლდება.", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "მათი საქაღალდე 30 დღეა არ დაგითვალიერებიათ — თქვენი ფაილები და თანმხლები ფაილები რჩება, საქაღალდის დათვალიერება კი მათ ისევ აჩვენებს", + "them saves a new set.": "დარქმევა ახალ ნაკრებს ინახავს.", + "this photo": "ეს ფოტო", + "this screen's profile": "ამ ეკრანის პროფილი", + "time is typed, and picking a day replaces one). Typing still works, and \"2026-10-01 10:00\" (a space for the": "დრო იკრიფება, დღის არჩევა კი მას ჩაანაცვლებს). აკრეფა კვლავ მუშაობს, და „2026-10-01 10:00“ (ჰარი", + "to save it as UTF-8. Exporting warns about keywords Capture One won't import, and names any keyword the format can't": "სთხოვს მის UTF-8-ად შენახვას. ექსპორტი აფრთხილებს საკვანძო სიტყვებზე, რომლებსაც Capture One ვერ შემოიტანს, და ასახელებს ყველა საკვანძო სიტყვას, რომელსაც ფორმატი ვერ", + "to the camera's own JPEG.": "კამერის საკუთარ JPEG-თან.", + "to the menu bar and the app ignored those keys, assuming the menu bar would handle them. Keys outside the menus": "გადასცემს, აპი კი ამ კლავიშებს უგულებელყოფდა იმ ვარაუდით, რომ მენიუს ზოლი დაამუშავებდა. მენიუების გარეთ არსებულ კლავიშებზე", + "triangles, a highlight under the pointer. A folded section shows how many rows it holds, counts of 0 are fainter,": "სამკუთხედებთან ერთ სვეტშია, მაჩვენებლის ქვეშ — მონიშვნის ფონი. ჩაკეცილი სექცია აჩვენებს სტრიქონების რაოდენობას, 0 უფრო მკრთალია,", + "true": "ჭეშმარიტი", + "two dates of a \"between\" can't be picked out of order; Esc closes just the calendar. It picks dates, not times (a": "„შორის“-ის ორი თარიღის არეულად არჩევა შეუძლებელია; Esc მხოლოდ კალენდარს ხურავს. ის თარიღებს ირჩევს და არა დროს (", + "unknown": "უცნობი", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "ხელუხლებლად — ძველი ვერსიები აღარ კითხულობს მის ნაწილს დაზიანებულ ჟურნალად. მას შემდეგ, რაც ეს ვერსია ბიბლიოთეკას გახსნის,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "გამოიყენება (მაგ., გამოტოვებული პროგრამული ადაპტერი, როგორიცაა llvmpipe) და რატომ გადაერთო ბოლო რენდერი CPU-ზე.", + "usual and then added to Photos, into a top-level album of that name (made when it's missing). The toast says how": "და შემდეგ ემატება Photos-ს, ამ სახელის მქონე ზედა დონის ალბომში (იქმნება, თუ არ არსებობს). შეტყობინება ამბობს, რამდენი", + "video": "ვიდეო", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "რეგისტრის მიმართ მგრძნობიარე ტომებზე (Linux, APFS) `img_1.JPG` `IMG_1.JPG`-ის გვერდით სხვა ფოტოა და გადარქმეული იღებს", + "way round, \"in the last 0 days\", a colour label that doesn't exist, \"contains\" with nothing to look for, an": "მიმდევრობითაა, „ბოლო 0 დღე“, არარსებული ფერადი იარლიყი, „შეიცავს“ საძიებოს გარეშე,", + "weeks": "კვირა", + "were missing, without touching anything already set.": "აკლდა, უკვე დაყენებულის შეუცვლელად.", + "when a `cargo` build runs outside `nix develop` (no Vulkan loader); start it from there.": "როცა `cargo`-ს ანაწყობი `nix develop`-ის გარეთ ეშვება (Vulkan-ის ჩამტვირთავის გარეშე); გაუშვით იქიდან.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "როცა ბრაუზერმა შეიძლება ბიბლიოთეკა წაშალოს. ჰოსტინგი: ქეშის სანიმუშო სათაურები აღარ აღნიშნავს (ჰეშის გარეშე) ფაილებს", + "when the preview renders in a few milliseconds (it waits at most 40 % of a frame for it; slower renders, such as": "როცა წინასწარი ხედი რამდენიმე მილიწამში რენდერდება (მას კადრის მაქსიმუმ 40 %-ს ელოდება; უფრო ნელ რენდერებს, მაგალითად", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "არის თუ არა ორიგინალები ადგილზე, ფონურ ნაკადში მოწმდება (ბადის ესკიზები, ინფორმაციის პანელი, ფოტოს მენიუ, დაკარგული", + "which section you are in and can fold it from there.": "რომელ სექციაში ხართ და იქიდანვე შეგიძლიათ მისი ჩაკეცვა.", + "while the 1:1 detail is on its way. It appears once that has lasted half a second, so quick renders never show it.": "სანამ 1:1 დეტალი მზადდება. ის ჩნდება, როცა ეს ნახევარ წამს გაგრძელდება, ამიტომ სწრაფი რენდერები მას არასოდეს აჩვენებს.", + "white, empty Point Color slots…) as settings that couldn't be carried over.": "თეთრი, წერტილის ფერის ცარიელი სლოტები…) როგორც პარამეტრებს, რომელთა გადატანა ვერ მოხერხდა.", + "windows and toasts are gone.": "ფანჯრები და შეტყობინებები აღარ არის.", + "with a counterpart here come along, the rest is listed.": "რომლებსაც აქ შესატყვისი აქვს, გადმოდის, დანარჩენი ჩამოითვლება.", + "with the logs of the two previous runs beside it, so warnings and crashes of a run started from a desktop menu or the": "წინა ორი გაშვების ჟურნალებით მის გვერდით, ასე რომ სამუშაო მაგიდის მენიუდან ან Dock-იდან გაშვებული სესიის გაფრთხილებები და ავარიები", + "with the original and opens it in PhotoCraft. LightCraft finds PhotoCraft where its installer put it (Windows,": "ორიგინალთან ერთ დასტაში და ხსნის PhotoCraft-ში. LightCraft პოულობს PhotoCraft-ს იქ, სადაც მისმა ინსტალერმა დადო (Windows,", + "with those edits went from about 3.7 s to 1.9 s. Photos look the same as before.": "ამ რედაქტირებით დაახლოებით 3.7 წმ-დან 1.9 წმ-მდე შემცირდა. ფოტოები ისევე გამოიყურება, როგორც ადრე.", + "would make this album include itself through other smart albums": "ამ ალბომს სხვა სმარტ-ალბომების გავლით საკუთარი თავის ჩართვას აიძულებდა", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "რომელიც LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა, უარყოფილია შეტყობინებით „ეს ბიბლიოთეკა LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა“ და რჩება", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "რომელიც LightCraft-ის უფრო ახალმა ვერსიამ ჩაწერა), LightCraft ამას და მიზეზს გეუბნებათ და გთავაზობთ: ხელახლა ცდა, სხვა ბიბლიოთეკის არჩევა…, გაგრძელება", + "year, 2 digits": "წელი, 2 ციფრი", + "year, 4 digits": "წელი, 4 ციფრი", + "years": "წელი", + "yellow": "ყვითელი", + "yet and import as \"preview only\", with the reason.": "ჯერ არ შეუძლია, და „მხოლოდ წინასწარი ხედის“ სახით იმპორტირდება, მიზეზის მითითებით.", + "{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": "≥ არანაკლებ", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘-გადათრევა გასასწორებლად, ⇧G მართვადი Upright, ბადე გარდაქმნისას.", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)" +} diff --git a/crates/ui-egui/locales/pl-formats.json b/crates/ui-egui/locales/pl-formats.json new file mode 100644 index 000000000..1886098df --- /dev/null +++ b/crates/ui-egui/locales/pl-formats.json @@ -0,0 +1,299 @@ +{ + " ({unsure} with several look-alike files: use Locate)": " ({unsure} wśród kilku podobnych plików: użyj polecenia Zlokalizuj)", + " ({} in Recently Deleted)": " ({} w Ostatnio usuniętych)", + " · {:.0}% clipped": " · {:.0}% prześwietlonych obszarów", + " · {failed} couldn't be rendered": " · nie udało się wyświetlić: {failed}", + " · {skipped} skipped (file exists)": " · pominięto {skipped} (plik istnieje)", + " · {} duplicate{} skipped": " · pominięte duplikaty: {}{:.0}", + " · {} failed: {}": " · {} nieudanych: {}", + " · {} moved": " · {} przeniesionych", + " · {} not readable": " · {} nie można odczytać", + " · {} original{} left at the source": " · oryginały pozostawione w źródle: {}{:.0}", + " · {} restored from Recently Deleted": " · {} przywróconych z Ostatnio usuniętych", + " — not carried over: {}{}": " — nie przeniesiono: {}{}", + "+{} more": "+{} więcej", + ", {failed} file{} not readable": ", nieczytelne pliki: {failed}{:.0}", + "; {kept} already had a location": "; {kept} miało już przypisaną lokalizację", + "; {outside} outside its time range": "; {outside} poza zakresem dat", + "@{}: {} PRs, {} commits, +{} / −{} lines (Δ {}), +{} / −{} binary assets, {} – {}": "@{}: {} PR, {} zatwierdzeń, +{} / −{} wierszy (Δ {}), +{} / −{} zasobów binarnych, {} – {}", + "Add {local_n} to My Photos": "Dodaj {local_n} do Moich zdjęć", + "Add {} Photo{}": "Dodaj zdjęcia: {}{:.0}", + "Add {} Photo{}, restore {}": "Dodaj zdjęcia: {}{:.0}, przywróć: {}", + "Add “{}” to Favorites": "Dodaj „{}” do ulubionych", + "Added {n} photo{} to My Photos": "Dodano do Moich zdjęć: {n}{:.0}", + "Added {n} photo{} to “{}”": "Dodano do „{1}”: {n}{0:.0}", + "Added {what} to {name}": "Dodano {what} do {name}", + "Adding photos… {} of {}": "Dodawanie zdjęć… {} z {}", + "All {n} selected photos move by the same amount.": "Wszystkie {n} zaznaczone zdjęcia zostaną przesunięte o tę samą wartość.", + "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Już w bibliotece, w Ostatnio usuniętych — zaznaczone zdjęcia: {}{:.0} · kliknij prawym przyciskiem → Przywróć albo Usuń trwale, aby zaimportować ponownie", + "Already in the library: {} photo{} selected in All Photos": "Już w bibliotece — zaznaczone zdjęcia we Wszystkich zdjęciach: {}{:.0}", + "Auto ({})": "Automatycznie ({})", + "Auto Import: added {} photo{}": "Automatyczny import — dodane zdjęcia: {}{:.0}", + "Auto Sync: edits apply to {n} photos": "Automatyczna synchronizacja: zmiany dotyczą {n} zdjęć", + "Automatic ({} MB)": "Automatycznie ({} MB)", + "Build 1:1 Previews ({scope})": "Utwórz podglądy 1:1 ({scope})", + "Build Smart Previews ({scope})": "Utwórz podglądy inteligentne ({scope})", + "Build Standard-Sized Previews ({scope})": "Utwórz podglądy o standardowym rozmiarze ({scope})", + "Building previews for {} photos…": "Tworzenie podglądów dla {} zdjęć…", + "Click {}:1": "Kliknij {}:1", + "Couldn't open the editor: {e}": "Nie można otworzyć edytora: {e}", + "Create {n} Virtual Copies": "Utwórz {n} kopii wirtualnych", + "Creates {} stacks from {} of {scope}": "Tworzy {} stosów z {} w zakresie: {scope}", + "Decoder: {reason}": "Dekoder: {reason}", + "Delete \"{label}\"": "Usuń „{label}”", + "Delete \"{label}\" (and the mask)": "Usuń „{label}” (wraz z maską)", + "Delete {n} Photos": "Usuń {n} zdjęć", + "Delete “{current}”": "Usuń „{current}”", + "Discard Smart Previews ({scope})": "Odrzuć podglądy inteligentne ({scope})", + "Done ({what}): {done} photo{plural}": "Gotowe ({what}) — zdjęcia: {done}{plural:.0}", + "Duplicate \"{label}\"": "Duplikuj „{label}”", + "Example: {example}": "Przykład: {example}", + "Exported {n} photo{}": "Wyeksportowane zdjęcia: {n}{:.0}", + "Exported {ok} of {total} photo{}": "Wyeksportowane zdjęcia: {ok} z {total}{:.0}", + "Exported {} point curve preset{}": "Wyeksportowane ustawienia krzywej punktowej: {}{:.0}", + "Exported {} preset{}": "Wyeksportowane ustawienia wstępne: {}{:.0}", + "Exporting {} of {total}": "Eksportowanie {} z {total}", + "Filter bar — {active} active filter{}": "Pasek filtrów — aktywne filtry: {active}{:.0}", + "Focus {:.0}": "Ostrość {:.0}", + "Folder “{}” (and its subfolders)": "Folder „{}” (wraz z podfolderami)", + "Found {n} missing photo{}; {left} still missing{unsure}": "Odnalezione brakujące zdjęcia: {n}{:.0}; nadal brakuje: {left}{unsure}", + "Healed {n} dust spot{}": "Usunięte plamki kurzu: {n}{:.0}", + "Import cancelled · {} photo{} added": "Import anulowany · dodane zdjęcia: {}{:.0}", + "Import failed: {e}": "Import nieudany: {e}", + "Imported {n} point curve preset{}": "Zaimportowane ustawienia krzywej punktowej: {n}{:.0}", + "Imported {n} preset{}": "Zaimportowane ustawienia wstępne: {n}{:.0}", + "Imported {n} preset{}, {f} file{} not readable": "Zaimportowane ustawienia wstępne: {n}{:.0}, nieczytelne pliki: {f}{:.0}", + "Imported {profiles} profile{} (Profile browser ▸ their groups)": "Zaimportowane profile: {profiles}{:.0} (Przeglądarka profili → odpowiednie grupy)", + "Imported {} photo{}": "Zaimportowane zdjęcia: {}{:.0}", + "Insert at the cursor (also written {})": "Wstaw w miejscu kursora (zostanie też zapisane {})", + "Label Set: {name}": "Zestaw etykiet: {name}", + "LightCraft could not open its window: {e}": "LightCraft nie mógł otworzyć okna: {e}", + "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft nie mógł otworzyć okna: {e}\n\nSzczegóły znajdują się w pliku dziennika: {path}", + "Lightroom catalog inspected: {photos} photos, {collections} collections, {missing} missing": "Sprawdzono katalog Lightroom: {photos} zdjęć, {collections} kolekcji, {missing} brakujących", + "Lightroom import complete: {imported} photos added": "Import z Lightroom zakończony: dodano {imported} zdjęć", + "Lightroom import complete: {imported} photos added, {failed} failed": "Import z Lightroom zakończony: dodano {imported} zdjęć, błędów: {failed}", + "Lightroom import complete: {imported} photos added; index warning: {warning}": "Import z Lightroom zakończony: dodano {imported} zdjęć; ostrzeżenie indeksu: {warning}", + "Lightroom import failed: {e}{warning}": "Import z Lightroom nieudany: {e}{warning}", + "Lightroom inspection failed: {e}{warning}": "Sprawdzanie katalogu Lightroom nieudane: {e}{warning}", + "MIT OR Apache-2.0. Fonts: {} (OFL). Icons: original.": "MIT lub Apache-2.0. Czcionki: {} (OFL). Ikony: oryginalne.", + "Matches: {}": "Dopasowania: {}", + "Merging… {stage} {:.0}%": "Scalanie… {stage} {:.0}%", + "Move {what} to Recently Deleted?": "Przenieść {what} do Ostatnio usuniętych?", + "Named ({named_n})": "Nazwane ({named_n})", + "Painting “{k}”: click photos to add or remove it · Esc stops": "Przypisywanie „{k}”: klikaj zdjęcia, aby dodać lub usunąć słowo kluczowe · Esc kończy", + "Paste into {n} photo{} — only settings that were copied are pasted": "Wklej do zdjęć ({n}){:.0} — wklejone zostaną tylko skopiowane ustawienia", + "Preset: {name}": "Ustawienie wstępne: {name}", + "Preview only — editing the camera's embedded JPEG ({})": "Tylko podgląd — edycja pliku JPEG osadzonego przez aparat ({})", + "Preview only — {}": "Tylko podgląd — {}", + "Previews ready for {done} photo{plural}": "Podglądy gotowe — zdjęcia: {done}{plural:.0}", + "Profile: {}": "Profil: {}", + "Proof Copy ({})": "Kopia próbna ({})", + "Proof Preview · {}": "Podgląd próbny · {}", + "Quick Develop · {n} photos": "Szybka obróbka · {n} zdjęć", + "Quick Develop: {name}": "Szybka obróbka: {name}", + "Rated {}": "Ocena: {}", + "Reading Lightroom catalog… {done} of {total}": "Odczytywanie katalogu Lightroom… {done} z {total}", + "Reading photos… {done} of {total}": "Odczytywanie zdjęć… {done} z {total}", + "Reading photos… {} of {}": "Odczytywanie zdjęć… {} z {}", + "Redo {}": "Ponów {}", + "Remove “{name}” and its {count} photo{} from the library?": "Usunąć „{name}” i jego zdjęcia ({count}){:.0} z biblioteki?", + "Remove “{}” from Favorites": "Usuń „{}” z ulubionych", + "Removed from {}": "Usunięto z {}", + "Removed {what} from {name}": "Usunięto {what} z {name}", + "Rename {n} Photos…": "Zmień nazwy {n} zdjęć…", + "Rename “{name}”": "Zmień nazwę „{name}”", + "Renames “{from}” on {n} photo{} (keywords below it too). Use | for levels, e.g. Travel|Italy. An existing name merges the two.": "Zmienia nazwę „{from}” na zdjęciach ({n}){:.0} (także podrzędne słowa kluczowe). Rozdzielaj poziomy znakiem |, np. Podróże|Włochy. Istniejąca nazwa spowoduje scalenie.", + "Rendering on the CPU: {why}": "Renderowanie na procesorze: {why}", + "Replace {} with:": "Zamień {} na:", + "Reset {name}": "Wyzeruj {name}", + "Reset {} Channel": "Wyzeruj kanał {}", + "Restore {}": "Przywróć {}", + "Restore {} Photo{}": "Przywróć zdjęcia: {}{:.0}", + "Saved export preset “{}”": "Zapisano ustawienie eksportu „{}”", + "Scores {scope} for focus and exposure and finds similar shots taken within seconds of each other (bursts).": "Ocenia ostrość i ekspozycję zdjęć z zakresu {scope} oraz znajduje podobne ujęcia wykonane w odstępie kilku sekund (serie).", + "Show {hidden} hidden location{}": "Pokaż ukryte lokalizacje: {hidden}{:.0}", + "Showing {n} of {}": "Wyświetlono {n} z {}", + "Smart album: {}": "Album inteligentny: {}", + "Tagged {n} photo{} from the tracklog": "Geotagi ze śladu GPS dodane do zdjęć: {n}{:.0}", + "The SAM 3 download failed: {e}": "Nie udało się pobrać SAM 3: {e}", + "Undo {}": "Cofnij {}", + "Unknown tag{s} {} stay{} as typed (see Tags)": "Nieznane tagi{s:.0} pozostaną bez zmian: {}{:.0} (zobacz Tagi)", + "Updated “{name}”": "Zaktualizowano „{name}”", + "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Wersja {} — niezależnie opracowana biblioteka zdjęć i wywoływarka RAW napisana wyłącznie w Rust.", + "Version: {}": "Wersja: {}", + "Virtual copy “{name}” of {of}": "Kopia wirtualna „{name}” zdjęcia {of}", + "Working on {what} for {} photos…": "Przetwarzanie {what} dla {} zdjęć…", + "none (CPU rendering): {why}": "brak (renderowanie na CPU): {why}", + "the {n} selected photos": "{n} zaznaczonych zdjęć", + "the {} photos in view": "{} zdjęć w bieżącym widoku", + "these {count} photos": "te {count} zdjęcia", + "{count} smart preview{} · {:.1} MB": "Inteligentne podglądy: {count}{:.0} · {:.1} MB", + "{f} folders (and their subfolders)": "{f} folderów (wraz z podfolderami)", + "{label} Grading": "Gradacja: {label}", + "{label} {d:+} on every selected photo": "{label}: {d:+} na każdym zaznaczonym zdjęciu", + "{label} {index} of {n}": "{label} {index} z {n}", + "{label}, {n} photos": "{label}, {n} zdjęć", + "{matching} of {t} photos": "{matching} z {t} zdjęć", + "{matching} photos": "{matching} pasujących zdjęć", + "{mode} spot {} of {}": "{mode}: plamka {} z {}", + "{month}": "{month}", + "{month} {year}": "{month} {year}", + "{msg} ({failed} failed)": "{msg} (nieudanych: {failed})", + "{name} Label": "Etykieta {name}", + "{name} Off": "{name}: wyłączone", + "{name} On": "{name}: włączone", + "{name} opened for editing; it is stacked with the original": "{name} otwarto do edycji; kopia znajduje się w stosie z oryginałem", + "{name} — double-click to reset it": "{name} — kliknij dwukrotnie, aby wyzerować", + "{n} change{} couldn't be written to disk: {e}\nIf you quit now, {} lost.": "Nie udało się zapisać zmian na dysku ({n}){0:.0}: {e}\nJeśli teraz zamkniesz program, zostaną utracone.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e}\nLightCraft retries automatically; quitting now would lose {}.": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){0:.0}: {e}\nLightCraft ponawia zapis automatycznie; zamknięcie programu teraz oznacza ich utratę.{1:.0}", + "{n} change{} saved in memory but not written to disk: {e} — LightCraft will retry": "Zmiany zapisane w pamięci, ale nie na dysku ({n}){:.0}: {e} — LightCraft ponowi próbę", + "{n} found · {sel} selected": "Znaleziono {n} · zaznaczono {sel}", + "{n} photos": "{n} zdjęć", + "{n} photos in {} are hidden by the filters above": "{n} zdjęć w {} jest ukrytych przez powyższe filtry", + "{n} photo{}": "Zdjęcia: {n}{:.0}", + "{n} photo{} match · updates automatically as photos change": "Pasujące zdjęcia: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", + "{n} photo{} now · updates automatically as photos change": "Zdjęcia teraz: {n}{:.0} · aktualizacja automatyczna po zmianach zdjęć", + "{pid} (missing)": "{pid} (brak)", + "{sel_n} selected · {counted}": "Zaznaczono {sel_n} · {counted}", + "{stage}… {:.0}%": "{stage}… {:.0}%", + "{verb} {n} Photo{}": "{verb} (zdjęcia: {n}){:.0}", + "{weekday}, {day}": "{weekday}, {day}", + "{weekday}, {day} {month} {year}": "{weekday}, {day} {month} {year}", + "{what} merge added": "Dodano po scaleniu: {what}", + "{what} merge failed: {e}": "Scalanie nieudane: {what}: {e}", + "{year}": "{year}", + "{{date:…}} directives: {}": "Dyrektywy {{date:…}}: {}", + "{} and higher": "{} i nowsze", + "{} contributors · {} commits": "{} współtwórców · {} zatwierdzeń", + "{} failed": "{} nieudanych", + "{} files": "{} plików", + "{} files and {f} folder{}": "Pliki: {}, foldery: {f}{:.0}", + "{} item{}": "Elementy: {}{:.0}", + "{} of 4 guides — drag along lines that should be vertical or horizontal.": "{} z 4 prowadnic — przeciągnij wzdłuż linii, które powinny być pionowe lub poziome.", + "{} photos": "{} zdjęć", + "{} photos · exposures {} EV": "{} zdjęć · ekspozycje {} EV", + "{} photos · {} · {:.0}° × {:.0}°": "{} zdjęć · {} · {:.0}° × {:.0}°", + "{} photo{} not readable": "Nieczytelne zdjęcia: {}{:.0}", + "{} sample{}": "Próbki: {}{:.0}", + "{} sec": "{} s", + "{} spot(s) on this photo": "{} plamek na tym zdjęciu", + "{} — show their photos": "{} — pokaż ich zdjęcia", + "· {dups} already in the library": "· {dups} już w bibliotece", + "· {n} photo{}": "· zdjęcia: {n}{:.0}", + "… and {more} more": "… i jeszcze {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 nie obsługuje jeszcze tego wariantu RAW ({}). Edytujesz podgląd JPEG osadzony przez aparat, zawierający już styl obrazu aparatu (np. monochromatyczny) i balans bieli.", + "Filtered: {label}": "Filtrowanie: {label}", + "{n} similar photo{} · View ▸ Clear Filters to see all": "Podobne zdjęcia: {n}{:.0} · Widok → Wyczyść filtry, aby zobaczyć wszystkie", + "Moved; {} photo(s) relinked": "Przeniesiono; ponownie powiązano {} zdjęć", + "{} label (click more labels to show any of them)": "{} etykieta (kliknij kolejne etykiety, aby wyświetlić dowolną z nich)", + ", {} star{}": ", gwiazdki: {}{:.0}", + ", {} label": ", {} etykieta", + "Downloads from {host}. Once it has arrived and checked out, LightCraft installs it, {after}.": "Pobieranie z {host}. Po pobraniu i weryfikacji LightCraft zainstaluje plik, {after}.", + "{:.1} MB": "{:.1} MB", + "Running on the processor: {}.": "Działa na procesorze: {}.", + "{} · {}. Kept in the library's “denoise” folder; the oldest ones that no photo uses go first when the limit is reached.": "{} · {}. Przechowywane w folderze „denoise” biblioteki; po osiągnięciu limitu najpierw usuwane są najstarsze pliki nieużywane przez żadne zdjęcie.", + "Denoising {name}: {done} of {total} tiles{}": "Odszumianie {name}: {done} z {total} kafelków{}", + "{size} GB": "{size} GB", + "{} of {} from {host}": "{} z {} z serwera {host}", + "{} KB": "{} KB", + "{} MB": "{} MB", + "Making the denoised picture… {}%": "Tworzenie odszumionego obrazu… {}%", + "{name} · {terms}": "{name} · {terms}", + "{n} {noun}": "{n} {noun}", + "Trained on: {provenance}": "Wytrenowano na: {provenance}", + " · {queued} waiting": " · {queued} oczekuje", + "Shows the model's terms, then downloads {} from {host}, installs it and starts using it. Nothing else is sent.": "Wyświetla warunki modelu, następnie pobiera {} z {host}, instaluje go i zaczyna używać. Żadne inne dane nie są wysyłane.", + "In line for AI Denoise ({ahead} ahead)…": "W kolejce do odszumiania AI ({ahead} przed tym zadaniem)…", + "Running on {}.": "Działa na {}.", + "{file} · {size}": "{file} · {size}", + "• {assumption}": "• {assumption}", + "Put inside “{parent}”": "Umieść wewnątrz „{parent}”", + "Delete the keyword “{name}”?": "Usunąć słowo kluczowe „{name}”?", + "It is taken off {count} photo{}, with the keywords below it. Undo brings it back.": "Zostanie usunięte ze zdjęć ({count}){:.0} razem z podrzędnymi słowami kluczowymi. Możesz to cofnąć.", + "Create Keyword Tag Inside “{name}”…": "Utwórz podrzędne słowo kluczowe w „{name}”…", + "“{parent}” has a “{name}” already. Merge the two?": "„{parent}” zawiera już „{name}”. Scalić oba?", + "There is a “{name}” at the top level already. Merge the two?": "Słowo kluczowe „{name}” istnieje już na najwyższym poziomie. Scalić oba?", + "Added “{keyword}” to {n} photo{}": "Dodano „{keyword}” do zdjęć: {n}{:.0}", + "On {have} of {of} selected photos": "Na {have} z {of} zaznaczonych zdjęć", + "Remove “{name}”": "Usuń „{name}”", + "There is a keyword set “{name}” already": "Istnieje już zestaw słów kluczowych „{name}”", + "Added {n} keyword{}": "Dodane słowa kluczowe: {n}{:.0}", + "; {n} gained synonyms": "; {n} otrzymało synonimy", + "Exported {n} keyword{}": "Wyeksportowane słowa kluczowe: {n}{:.0}", + " — Capture One won't import a list with ; , < or > in a keyword: {names}": " — Capture One nie zaimportuje listy zawierającej ; , < lub > w słowie kluczowym: {names}", + " — left out, as a keyword list can't hold them: {names}": " — pominięto, ponieważ lista słów kluczowych ich nie obsługuje: {names}", + "{done} of {total}": "{done} z {total}", + "{done} of {total} MB": "{done} z {total} MB", + "{pct} %": "{pct} %", + "+{n} more": "+{n} więcej", + "1 task is still running: {names}. Quitting stops it.": "Wciąż trwa 1 zadanie: {names}. Zamknięcie programu je zatrzyma.", + "{n} tasks are still running: {names}. Quitting stops them.": "Wciąż trwają {n} zadania: {names}. Zamknięcie programu je zatrzyma.", + "{what} merge cancelled": "Scalanie anulowane: {what}", + "Import warnings ({n}):\n{warnings}": "Ostrzeżenia importu ({n}):\n{warnings}", + "Lightroom geometry for {path} was not preserved; Guided Upright saved transform is unsupported.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie Guided Upright nie jest obsługiwane.", + "Lightroom geometry for {path} was not preserved; saved Upright transform is unsupported.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie Upright nie jest obsługiwane.", + "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Geometria z Lightrooma dla {path} nie została zachowana; zapisane przekształcenie jest pomijane, ponieważ Upright jest wyłączony.", + "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Geometria z Lightrooma dla {path} nie została zachowana; tryb {mode} zostanie obliczony ponownie.", + "Moved {} photo(s)": "Przeniesione zdjęcia: {}", + "New Folder in “{name}”": "Nowy folder w „{name}”", + "Restored {} photo{} from Recently Deleted": "Przywrócone z Ostatnio usuniętych zdjęcia: {}{:.0}", + "XMP adjustments were not imported for {path}: {fields}.": "Korekty XMP dla {path} nie zostały zaimportowane: {fields}.", + "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Profil XMP „{profile}” nie został zastosowany do {path}; kolory mogą się różnić od Lightrooma.", + "not added to Apple Photos: {e}": "nie dodano do aplikacji Zdjęcia: {e}", + "{name} opened in the other editor": "{name} otwarto w innym edytorze", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "Pominięte zdjęcia lokalne: {n}{0:.0} — albumy zawierają tylko zdjęcia z biblioteki (najpierw dodaj je do Moich zdjęć)", + "{n} added to Apple Photos": "Dodane do aplikacji Zdjęcia: {n}", + "{n} added to Apple Photos album “{album}”": "Dodane do albumu „{album}” w aplikacji Zdjęcia: {n}", + "{n} originals opened in the other editor": "Oryginały otwarte w innym edytorze: {n}", + "{n} photos opened for editing; each is stacked with its original": "Zdjęcia otwarte do edycji: {n}; każde połączono w stos z oryginałem", + "· {in_source} identical copies": "· identyczne kopie: {in_source}", + "· {trashed} in Recently Deleted": "· w Ostatnio usuniętych: {trashed}", + " in {g} group{}": " in {g} group{}", + " · kept, not applied: {}": " · kept, not applied: {}", + " — kept but not applied: {}": " — kept but not applied: {}", + "Apply to “{mask}” on Selected Photos": "Apply to “{mask}” on Selected Photos", + "Delete the effect preset “{name}”?": "Delete the effect preset “{name}”?", + "Delete these {n} effect presets?": "Delete these {n} effect presets?", + "Delete {n} effect preset{}? This can't be undone.": "Delete {n} effect preset{}? This can't be undone.", + "Effect: {name}": "Effect: {name}", + "Exported {} effect preset{}": "Exported {} effect preset{}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Imported {e} mask effect{} (Masking → Effect)", + "Update “{name}” with Current Settings": "Update “{name}” with Current Settings", + "{n} preset{}": "{n} preset{}", + "{count} more warnings.": "{count} more warnings.", + "{photos} catalog records processed; {collections} collections.": "{photos} catalog records processed; {collections} collections.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + "Applied {name}": "Applied {name}", + "Delete “{name}”?": "Delete “{name}”?", + "Updated {name}": "Updated {name}", + "Exported to {path}": "Exported to {path}", + "Saved template {name}": "Saved template {name}", + "Imported {name}": "Imported {name}", + "Skipped {problem}": "Skipped {problem}", + "…and {more} more": "…and {more} more", + "Template preview: {name}": "Template preview: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} photos in view · {slides} slides · {time}", + "{n} pages": "{n} pages", + "{n} JPEG files": "{n} JPEG files", + "{s} s of AV1 video": "{s} s of AV1 video", + "Slideshow exported: {what}": "Slideshow exported: {what}", + " · {n} skipped (could not be rendered)": " · {n} skipped (could not be rendered)", + "{n} raws can now be developed from their raw data": "{n} raws can now be developed from their raw data", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + "Create the keyword “{name}”": "Create the keyword “{name}”", + "new, inside {parent}": "new, inside {parent}", + "Keywords of {n} selected photos": "Keywords of {n} selected photos", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "Removed “{name}” from {n} photos. Edit → Undo puts it back.", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft stopped unexpectedly: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renames {n} face{} across the library. The files are not changed.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” is already a person. Combining them makes one person; Undo separates them again.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Color space: Rec. 2020 {curve} (HDR AVIF)", + "{n} selected photos · {pages} pages": "{n} selected photos · {pages} pages", + "Already in “{}”": "Już w „{}”", + "Move {n} photo{} to “{}”": "Przenieś do „{1}” — zdjęcia: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Przenieść do „{1}” — zdjęcia: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "Przeniesiono do „{1}” — zdjęcia: {n}{0:.0}. Edytuj → Cofnij przywróci je." +} diff --git a/crates/ui-egui/locales/pl.json b/crates/ui-egui/locales/pl.json new file mode 100644 index 000000000..7416ce241 --- /dev/null +++ b/crates/ui-egui/locales/pl.json @@ -0,0 +1,2161 @@ +{ + " (library default)": " (domyślne ustawienie biblioteki)", + " (inverted)": " (odwrócona)", + " · best of its burst": " · najlepsze zdjęcie z serii", + " · in a burst": " · w serii zdjęć", + "% of all commits": "% wszystkich zatwierdzonych zmian", + "+ Group": "+ Grupa", + "+ Rule": "+ Reguła", + "0: nothing is rejected.": "0: żadne zdjęcie nie jest odrzucane.", + "1 day": "1 dzień", + "= exactly": "= dokładnie", + "A blank template keeps the original names.": "Pusty szablon zachowuje oryginalne nazwy.", + "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Filtr ukrywa zdjęcia w tym widoku: zmień go lub wyczyść (Widok → Wyczyść filtry)", + "A one-time download of about": "Jednorazowe pobranie około", + "AI Mask Model Status": "Stan modelu masek AI", + "About": "O programie", + "About LightCraft": "O LightCraft", + "Active": "Aktywny", + "Activity": "Aktywność", + "Actual size": "Rzeczywisty rozmiar", + "Add": "Dodaj", + "Add Catchlight": "Dodaj błysk w oku", + "Add Folder to Local": "Dodaj folder do lokalnych", + "Add Mask": "Dodaj maskę", + "Add Object Click": "Dodaj zaznaczenie obiektu kliknięciem", + "Add Point Color Sample": "Dodaj próbkę koloru punktowego", + "Add Red Eye Correction": "Dodaj korektę czerwonych oczu", + "Add Remove Spot": "Dodaj usuwanie plamki", + "Add Selected Photos": "Dodaj zaznaczone zdjęcia", + "Add a rule (⌥-click: a group)": "Dodaj regułę (⌥ + kliknięcie: grupa)", + "Add in place": "Dodaj bez kopiowania plików", + "Add in place: the library references the files where they are; nothing is copied or moved.": "Dodaj w miejscu: biblioteka odwołuje się do plików w ich obecnej lokalizacji; nic nie jest kopiowane ani przenoszone.", + "Add keyword": "Dodaj słowo kluczowe", + "Add number": "Dodaj numer", + "Add to": "Dodaj do", + "Add to Album": "Dodaj do albumu", + "Add to Favorites": "Dodaj do ulubionych", + "Add to Local": "Dodaj do lokalnych", + "Add to My Photos": "Dodaj do Moich zdjęć", + "Add to Selected Photos": "Dodaj do zaznaczonych zdjęć", + "Add to Target Album": "Dodaj do albumu docelowego", + "Add to this mask": "Dodaj do tej maski", + "Add/Subtract/Intersect Mask": "Dodaj/Odejmij/Przetnij maskę", + "Added to photos you import that don't already have it.": "Dodawane do importowanych zdjęć, które jeszcze tego nie mają.", + "Adding": "Dodawanie", + "Adding Lightroom photos…": "Dodawanie zdjęć z Lightrooma…", + "Advanced search…": "Wyszukiwanie zaawansowane…", + "After": "Po", + "After 30 days": "Po 30 dniach", + "After 7 days": "Po 7 dniach", + "After 90 days": "Po 90 dniach", + "After a year": "Po roku", + "Album": "Album", + "Albums": "Albumy", + "All": "Wszystkie", + "All Metadata": "Wszystkie metadane", + "All Metadata…": "Wszystkie metadane…", + "All Photos": "Wszystkie zdjęcia", + "All rights reserved": "Wszelkie prawa zastrzeżone", + "All the photos imported from this disk are included.": "Uwzględniono wszystkie zdjęcia zaimportowane z tego dysku.", + "All types": "Wszystkie typy", + "Alt Text": "Tekst alternatywny", + "Always": "Zawsze", + "Amount": "Ilość", + "Analyzing the photo for AI masks…": "Analizowanie zdjęcia pod kątem masek AI…", + "Any Searchable Text": "Dowolny tekst do wyszukania", + "Aperture": "Przysłona", + "Application": "Aplikacja", + "Apply": "Zastosuj", + "Apply Color Label Set": "Zastosuj zestaw etykiet kolorów", + "Apply Filter Preset": "Zastosuj ustawienia wstępne filtra", + "Apply Metadata Preset": "Zastosuj ustawienia wstępne metadanych", + "Apply Point Curve Preset": "Zastosuj ustawienia wstępne krzywej punktowej", + "Apply Preset": "Zastosuj ustawienie wstępne", + "Apply Settings JSON": "Zastosuj ustawienia JSON", + "Approved": "Zatwierdzone", + "Aqua": "Turkusowy", + "Aqua Hue": "Odcień turkusowego", + "Aqua Luminance": "Jasność turkusowego", + "Aqua Saturation": "Nasycenie turkusowego", + "Arrange Panel": "Rozmieść panel", + "ArtCraft Website": "Witryna ArtCraft", + "ArtCraft — more creative apps": "ArtCraft — więcej aplikacji kreatywnych", + "As Shot": "Jak na zdjęciu", + "As shot": "Jak zrobiono zdjęcie", + "Ascending": "Rosnąco", + "Aspect": "Proporcje", + "Aspect Ratio": "Proporcje obrazu", + "Assisted Culling": "Wspomagana selekcja", + "Assisted Culling…": "Wspomagana selekcja…", + "Auto": "Automatycznie", + "Auto Advance": "Automatyczne przechodzenie dalej", + "Auto Advance Off": "Automatyczne przechodzenie dalej wyłączone", + "Auto Advance On": "Automatyczne przechodzenie dalej włączone", + "Auto Advance after rating or flagging": "Przechodź dalej po ocenie lub oznaczeniu", + "Auto Advance: move to the next photo after rating or flagging": "Automatyczne przejście: przejdź do następnego zdjęcia po nadaniu oceny lub oznaczeniu flagą", + "Auto Align": "Automatyczne wyrównanie", + "Auto B&W Mix": "Automatyczne mieszanie czerni i bieli", + "Auto Crop": "Automatyczne kadrowanie", + "Auto Import": "Automatyczny import", + "Auto Import Now": "Importuj automatycznie teraz", + "Auto Import Settings": "Automatyczne ustawienia importu", + "Auto Mask": "Automatyczna maska", + "Auto Select": "Automatyczny wybór", + "Auto Settings": "Ustawienia automatyczne", + "Auto Straighten": "Automatyczne prostowanie", + "Auto Sync": "Automatyczna synchronizacja", + "Auto Sync: edits apply to every selected photo. Auto B&W mix. Automatic versions.": "Automatyczna synchronizacja: zmiany obejmują wszystkie zaznaczone zdjęcia. Automatyczne mieszanie czerni i bieli. Automatyczne wersje.", + "Auto settings applied": "Zastosowano ustawienia automatyczne", + "Auto-Stack by Capture Time": "Automatycznie grupuj według czasu wykonania", + "Auto-Stack by Capture Time…": "Automatycznie grupuj według czasu wykonania…", + "Auto-Tag Photos from Tracklog": "Automatycznie oznacz zdjęcia na podstawie śladu GPS", + "Auto-Tag from Tracklog": "Automatycznie oznacz na podstawie śladu GPS", + "Auto-Tag from Tracklog: GPS locations for your photos from a GPX track log, matched by capture time.": "Automatyczne tagowanie ze śladu: przypisuje zdjęciom pozycje GPS ze śladu GPX na podstawie czasu wykonania.", + "Auto-Tag from Tracklog…": "Automatycznie oznacz na podstawie śladu GPS…", + "Automatic": "Automatycznie", + "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.": "Tryb automatyczny renderuje widok szczegółów w wyświetlanym rozmiarze, nie przekraczając rozdzielczości zdjęcia. Wybierz mniejszy limit, aby przyspieszyć działanie.", + "Automatically Write Changes into XMP": "Automatycznie zapisuj zmiany w XMP", + "Automatically write changes into XMP sidecars": "Automatycznie zapisuj zmiany w plikach pomocniczych XMP", + "Autumn Gold": "Jesienne złoto", + "B&W": "Czarno-białe", + "B&W Mix": "Mieszanie czerni i bieli", + "B&W Mixer": "Mikser czerni i bieli", + "Back": "Wstecz", + "Back Up Library…": "Utwórz kopię zapasową biblioteki…", + "Back to Edit": "Wróć do edycji", + "Back to Edit (E)": "Wróć do edycji (E)", + "Back to Grid": "Wróć do siatki", + "Background": "Tło", + "Balance": "Balans", + "Basic": "Podstawowe", + "Before": "Przed", + "Before/After (Y)": "Przed/Po (Y)", + "Before/After Settings": "Ustawienia porównania przed/po", + "Before/After Split": "Podział przed/po", + "Before/After Split Top/Bottom": "Podział przed/po góra/dół", + "Before/After Top/Bottom": "Przed/Po góra/dół", + "Begin Interaction": "Rozpocznij interakcję", + "Best of Similar Shots": "Najlepsze spośród podobnych ujęć", + "Binary assets added": "Dodano zasoby binarne", + "Binary assets removed": "Usunięto zasoby binarne", + "Bit depth": "Głębia bitowa", + "Black & White": "Czarno-białe", + "Blacks": "Czernie", + "Bleach Bypass": "Pominięcie wybielania", + "Bleached": "Wyblakły", + "Blending": "Mieszanie", + "Blue": "Niebieski", + "Blue Hour Boost": "Wzmocnienie błękitnej godziny", + "Blue Hue": "Odcień niebieskiego", + "Blue Luminance": "Luminancja niebieskiego", + "Blue Primary": "Podstawowy niebieski", + "Blue Saturation": "Nasycenie niebieskiego", + "Blue channel": "Kanał niebieski", + "Blue label": "Niebieska etykieta", + "Blue/Yellow Fringe": "Niebiesko-żółte obwódki", + "Blurry photos below the score are flagged as rejects.": "Nieostre zdjęcia z oceną poniżej progu są oznaczane jako odrzucone.", + "Boundary Warp": "Dopasowanie granic", + "Bright & Airy": "Jasne i lekkie", + "Bring All to Front": "Przenieś wszystko na wierzch", + "Browse Folder": "Przeglądaj folder", + "Browse Folder…": "Przeglądaj folder…", + "Browse Profiles": "Przeglądaj profile", + "Browse…": "Przeglądaj…", + "Brush": "Pędzel", + "Brush Settings": "Ustawienia pędzla", + "Brush Stroke": "Pociągnięcie pędzla", + "Build Previews": "Utwórz podglądy", + "Build Smart Previews": "Utwórz podglądy inteligentne", + "By Date": "Według daty", + "By day (YYYY/YYYY-MM-DD)": "Według dnia (RRRR/RRRR-MM-DD)", + "By month (YYYY/YYYY-MM)": "Według miesiąca (RRRR/RRRR-MM)", + "CPU threads": "Wątki procesora", + "Calibration": "Kalibracja", + "Camera": "Aparat", + "Camera make and model": "Marka i model aparatu", + "Cameras and Cards": "Aparaty i karty pamięci", + "Cancel": "Anuluj", + "Cancel AI Mask Model Download": "Anuluj pobieranie modelu masek AI", + "Cancel Download": "Anuluj pobieranie", + "Cancel Interaction": "Anuluj interakcję", + "Cancel Preview Build": "Anuluj tworzenie podglądu", + "Candidate": "Kandydat", + "Caption": "Podpis", + "Capture Date": "Data wykonania zdjęcia", + "Capture date and time in your own format (directives below)": "Data i godzina wykonania zdjęcia we własnym formacie (dyrektywy poniżej)", + "Capture date as YYYYMMDD": "Data wykonania jako RRRRMMDD", + "Captured": "Wykonano", + "Catalog Statistics": "Statystyki katalogu", + "Catchlight": "Błysk w oku", + "Change": "Zmień", + "Change Component Mode": "Zmień tryb składnika", + "Characters not allowed in file names (/ \\ : * ? \" < > |) become -; an existing name gets -1, -2….": "Znaki niedozwolone w nazwach plików (/ \\ : * ? \" < > |) są zastępowane myślnikiem; jeśli nazwa już istnieje, dodawane są przyrostki -1, -2….", + "Check All": "Zaznacz wszystko", + "Check Output Path": "Sprawdź ścieżkę wyjściową", + "Check the files of this type": "Sprawdź pliki tego typu", + "Checking names…": "Sprawdzanie nazw…", + "Choose Another Library…": "Wybierz inną bibliotekę…", + "Choose Edit Settings to Copy": "Wybierz ustawienia edycji do skopiowania", + "Choose Edit Settings to Copy…": "Wybierz ustawienia edycji do skopiowania…", + "Choose Folder…": "Wybierz folder…", + "Choose a photo in the grid or filmstrip": "Wybierz zdjęcie w siatce lub na pasku zdjęć", + "Choose…": "Wybierz…", + "Cinematic": "Filmowy", + "City": "Miasto", + "Clarity": "Przejrzystość", + "Clean Studio": "Czyste studio", + "Clear": "Wyczyść", + "Clear Cache": "Wyczyść pamięć podręczną", + "Clear Filters": "Wyczyść filtry", + "Clear Guides": "Wyczyść prowadnice", + "Clear History": "Wyczyść historię", + "Clear Preview Cache": "Wyczyść pamięć podręczną podglądów", + "Clear Quick Collection": "Wyczyść szybką kolekcję", + "Clear Reference": "Wyczyść zdjęcie referencyjne", + "Clear all": "Wyczyść wszystko", + "Clear form": "Wyczyść formularz", + "Clear rating": "Usuń ocenę", + "Clear rating and advance": "Usuń ocenę i przejdź dalej", + "Cleared History": "Wyczyszczono historię", + "Click Zoom Ratio": "Współczynnik powiększenia po kliknięciu", + "Click a shortcut, then press the new keys (Esc cancels).": "Kliknij skrót, a następnie naciśnij nowe klawisze (Esc anuluje).", + "Click a tag to insert it. Examples are for IMG_0042.CR3 taken 14 Jan 2026, 05:58:48.": "Kliknij znacznik, aby go wstawić. Przykłady dotyczą pliku IMG_0042.CR3 z 14 stycznia 2026 r., godz. 05:58:48.", + "Click photos in the grid to toggle the keyword; Esc stops": "Klikaj zdjęcia w siatce, aby przełączać słowo kluczowe; Esc kończy", + "Click the object to select it · ⌥-click leaves a part out": "Kliknij obiekt, aby go zaznaczyć · ⌥-kliknięcie wyklucza fragment", + "Click the photo to pick a colour · ⇧-click adds more": "Kliknij zdjęcie, aby pobrać kolor · ⇧-kliknięcie dodaje kolejne", + "Click the photo to pick a colour; ⇧-click adds more (up to 5)": "Kliknij zdjęcie, aby pobrać kolor; ⇧-kliknięcie dodaje kolejne (maks. 5)", + "Click to clear these filters": "Kliknij, aby wyczyścić te filtry", + "Click to restore · double-click to rename": "Kliknij, aby przywrócić · kliknij dwukrotnie, aby zmienić nazwę", + "Clone": "Klonuj", + "Close": "Zamknij", + "Close Panel": "Zamknij panel", + "Close Window": "Zamknij okno", + "Close all": "Zamknij wszystko", + "Close file": "Zamknij plik", + "Close panel": "Zamknij panel", + "Cloudy": "Pochmurno", + "Collapse": "Zwiń", + "Collapse All Stacks": "Zwiń wszystkie stosy", + "Collapse Stack": "Zwiń stos", + "Color": "Kolor", + "Color Grading": "Gradacja kolorów", + "Color Label": "Etykieta kolorystyczna", + "Color Label Names": "Nazwy etykiet kolorów", + "Color Label Sets": "Zestawy etykiet kolorów", + "Color Mixer": "Mikser kolorów", + "Color Noise Reduction": "Redukcja szumu koloru", + "Color Priority": "Priorytet kolorów", + "Color Range": "Zakres kolorów", + "Color label cleared": "Usunięto etykietę koloru", + "Color space": "Przestrzeń barw", + "Color space: sRGB (AVIF)": "Przestrzeń kolorów: sRGB (AVIF)", + "Colors": "Kolory", + "Colour-label filter with several labels at once; expandable folder tree in Local.": "Filtrowanie według kilku etykiet kolorystycznych jednocześnie; rozwijane drzewo folderów w widoku lokalnym.", + "Colour-range masks: click the photo to sample. Luminance ranges: range bar, smoothness, luminance map.": "Maski zakresu barw: kliknij zdjęcie, aby pobrać próbkę. Zakresy luminancji: pasek zakresu, płynność przejścia, mapa luminancji.", + "Combine files…": "Połącz pliki…", + "Command Journal": "Dziennik poleceń", + "Comments panel": "Panel komentarzy", + "Commits": "Zatwierdzone zmiany", + "Company": "Firma", + "Compare": "Porównaj", + "Compare (Shift+C)": "Porównaj (Shift+C)", + "Compare Before and After": "Porównaj przed i po", + "Compare needs at least two photos": "Porównanie wymaga co najmniej dwóch zdjęć", + "Component options": "Opcje składnika", + "Compression": "Kompresja", + "Concrete Cool": "Chłodny beton", + "Confirm before deleting": "Potwierdzaj przed usunięciem", + "Confirm before moving photos to Recently Deleted": "Potwierdzaj przeniesienie zdjęć do ostatnio usuniętych", + "Constrain Crop": "Ogranicz kadrowanie", + "Continue": "Kontynuuj", + "Continue Without Saving": "Kontynuuj bez zapisywania", + "Contrast": "Kontrast", + "Contributors": "Współtwórcy", + "Convert to DNG": "Konwertuj do DNG", + "Cool Fade": "Chłodne wyblaknięcie", + "Cool Morning": "Chłodny poranek", + "Copy": "Kopiuj", + "Copy After's Settings to Before": "Skopiuj ustawienia „Po” do „Przed”", + "Copy Before to After": "Skopiuj „Przed” do „Po”", + "Copy Before's Settings to After": "Skopiuj ustawienia „Przed” do „Po”", + "Copy Edit Settings": "Kopiuj ustawienia edycji", + "Copy History Step Settings to Before": "Skopiuj ustawienia kroku historii do „Przed”", + "Copy Metadata": "Kopiuj metadane", + "Copy as DNG": "Kopiuj jako DNG", + "Copy into the library (else use the files where they are)": "Kopiuj do biblioteki (w przeciwnym razie używaj plików w ich lokalizacji)", + "Copy pages": "Kopiuj strony", + "Copy the files (into the library's Originals/, or a folder you choose)": "Kopiuj pliki (do folderu Originals/ biblioteki lub wybranego folderu)", + "Copy to": "Kopiuj do", + "Copy to Clipboard": "Kopiuj do schowka", + "Copy: the files are copied to the folder below and the library uses the copies; the originals are left as they are.": "Kopiuj: pliki zostaną skopiowane do poniższego folderu, a biblioteka będzie używać kopii; oryginały pozostaną bez zmian.", + "Copyright": "Prawa autorskie", + "Copyright Info URL": "Adres URL informacji o prawach autorskich", + "Copyright Status": "Status praw autorskich", + "Copyright status, rights usage terms and copyright info URL in Info, metadata presets and exports.": "Stan praw autorskich, warunki wykorzystania i adres URL informacji o prawach autorskich w informacjach, ustawieniach wstępnych metadanych i eksporcie.", + "Country": "Kraj", + "Create Album": "Utwórz album", + "Create Album…": "Utwórz album…", + "Create Folder": "Utwórz folder", + "Create Folder…": "Utwórz folder…", + "Create New Mask": "Utwórz nową maskę", + "Create PDF from clipboard": "Utwórz PDF ze schowka", + "Create PDF from file…": "Utwórz PDF z pliku…", + "Create PDF from images…": "Utwórz PDF z obrazów…", + "Create Preset": "Utwórz ustawienie wstępne", + "Create Preset…": "Utwórz ustawienie wstępne…", + "Create Proof Copy": "Utwórz kopię próbną", + "Create Smart Album": "Utwórz inteligentny album", + "Create Smart Album from Filter…": "Utwórz album inteligentny z filtra…", + "Create Smart Album…": "Utwórz album inteligentny…", + "Create Stack": "Utwórz stos", + "Create Version": "Utwórz wersję", + "Create Version from Step": "Utwórz wersję z kroku", + "Create Virtual Copy": "Utwórz kopię wirtualną", + "Creator": "Twórca", + "Creator metadata": "Metadane twórcy", + "Crisp Landscape": "Wyrazisty krajobraz", + "Crisp Vista": "Wyrazisty widok", + "Crop": "Kadruj", + "Crop & Rotate": "Kadrowanie i obrót", + "Crop & Rotate (C)": "Kadruj i obracaj (C)", + "Crop Aspect": "Proporcje kadrowania", + "Crop, Rotate, Geometry": "Kadrowanie, obrót, geometria", + "Cross Process": "Obróbka krzyżowa", + "Culling": "Selekcja zdjęć", + "Curve": "Krzywa", + "Custom": "Niestandardowe", + "Custom template…": "Szablon niestandardowy…", + "Custom…": "Niestandardowe…", + "Cut": "Wytnij", + "Cut pages": "Wytnij strony", + "Cycle Crop Overlay": "Przełącz nakładkę kadrowania", + "Cycle Crop Overlay Orientation": "Przełącz orientację nakładki kadrowania", + "Cycle Info Overlay": "Przełącz nakładkę informacji", + "Cycle Mask Overlay Color": "Przełącz kolor nakładki maski", + "Cycle Mask Overlay Mode": "Przełącz tryb nakładki maski", + "Cylindrical": "Cylindryczny", + "Dark & Moody": "Ciemny i nastrojowy", + "Darken": "Przyciemnij", + "Darks": "Ciemne tony", + "Date": "Data", + "Date Groups": "Grupy dat", + "Day": "Dzień", + "Daylight": "Światło dzienne", + "Decrease Brush Feather": "Zmniejsz miękkość pędzla", + "Decrease Brush Size": "Zmniejsz rozmiar pędzla", + "Deep Sky": "Głębokie niebo", + "Defringe": "Usuń obwódki barwne", + "Deghost Amount": "Intensywność usuwania duchów", + "Dehaze": "Usuń zamglenie", + "Delete": "Usuń", + "Delete (⌫)": "Usuń (⌫)", + "Delete Album": "Usuń album", + "Delete All Masks": "Usuń wszystkie maski", + "Delete Color Label Set": "Usuń zestaw etykiet kolorów", + "Delete Component": "Usuń składnik", + "Delete Export Preset": "Usuń ustawienie wstępne eksportu", + "Delete Filter Preset": "Usuń ustawienie wstępne filtra", + "Delete Imported Profile": "Usuń zaimportowany profil", + "Delete Keyword": "Usuń słowo kluczowe", + "Delete Keyword Set": "Usuń zestaw słów kluczowych", + "Delete Mask": "Usuń maskę", + "Delete Metadata Preset": "Usuń ustawienie wstępne metadanych", + "Delete Permanently": "Usuń trwale", + "Delete Photo": "Usuń zdjęcie", + "Delete Photos": "Usuń zdjęcia", + "Delete Point Color": "Usuń kolor punktowy", + "Delete Point Color Sample": "Usuń próbkę koloru punktowego", + "Delete Point Curve Preset": "Usuń ustawienie wstępne krzywej punktowej", + "Delete Preset": "Usuń ustawienie wstępne", + "Delete Red Eye": "Usuń czerwone oczy", + "Delete Red Eye Correction": "Usuń korektę czerwonych oczu", + "Delete Spot": "Usuń plamkę", + "Delete Version": "Usuń wersję", + "Delete all": "Usuń wszystko", + "Delete all spots": "Usuń wszystkie plamki", + "Delete mask": "Usuń maskę", + "Delete them": "Usuń je", + "Density": "Gęstość", + "Depth Range": "Zakres głębi", + "Descending": "Malejąco", + "Describe": "Opisz", + "Describe what to keep": "Opisz, co zachować", + "Describe what to leave out": "Opisz, co wykluczyć", + "Describe what to select": "Opisz, co zaznaczyć", + "Describe…": "Opisz…", + "Deselect": "Odznacz", + "Deselect All": "Odznacz wszystko", + "Desert Heat": "Pustynny żar", + "Desert Warmth": "Pustynne ciepło", + "Desktop": "Pulpit", + "Detail": "Szczegóły", + "Detail (D)": "Szczegóły (D)", + "Details": "Szczegóły", + "Diagonal": "Przekątna", + "Discard Preview Cache": "Odrzuć pamięć podręczną podglądów", + "Display P3": "Display P3", + "Display name": "Nazwa wyświetlana", + "Display theme": "Motyw wyświetlania", + "Distortion": "Zniekształcenie", + "Document properties…": "Właściwości dokumentu…", + "Don't enlarge": "Nie powiększaj", + "Done": "Gotowe", + "Download": "Pobierz", + "Download AI Mask Model": "Pobierz model masek AI", + "Download the SAM 3 Model?": "Pobrać model SAM 3?", + "Downloads": "Pobrane", + "Drag along the horizon; double-click for Auto": "Przeciągnij wzdłuż horyzontu; kliknij dwukrotnie, aby użyć trybu automatycznego", + "Drag over an eye on the photo; the pupil inside is found automatically.": "Przeciągnij po oku na zdjęciu; źrenica zostanie wykryta automatycznie.", + "Draw Guides": "Rysuj prowadnice", + "Duplicate": "Duplikuj", + "Duplicate Component": "Duplikuj składnik", + "Duplicate Mask": "Duplikuj maskę", + "Duplicate and Invert Mask": "Duplikuj i odwróć maskę", + "Each / starts a folder level; tags are filled in per photo (a level with missing metadata is \"unknown\").": "Każdy znak / rozpoczyna kolejny poziom folderu; znaczniki są uzupełniane osobno dla każdego zdjęcia (brakujące metadane oznaczają „nieznane”).", + "Edge": "Krawędź", + "Edit": "Edytuj", + "Edit (E)": "Edytuj (E)", + "Edit Capture Time": "Edytuj czas wykonania", + "Edit Capture Time…": "Edytuj czas wykonania…", + "Edit Color Label Names": "Edytuj nazwy etykiet kolorów", + "Edit Color Label Names…": "Edytuj nazwy etykiet kolorów…", + "Edit Copy for External Editor": "Edytuj kopię w edytorze zewnętrznym", + "Edit Date": "Edytuj datę", + "Edit Info": "Edytuj informacje", + "Edit Label Names": "Edytuj nazwy etykiet", + "Edit Label Names…": "Edytuj nazwy etykiet…", + "Edit Mask": "Edytuj maskę", + "Edit Mask Component": "Edytuj składnik maski", + "Edit Sections": "Edytuj sekcje", + "Edit Smart Album": "Edytuj album inteligentny", + "Edit Smart Album…": "Edytuj album inteligentny…", + "Edit Spot": "Edytuj plamkę", + "Edit in External Editor": "Edytuj w edytorze zewnętrznym", + "Edit in External Editor (⇧⌘E): a 16-bit TIFF copy, stacked, refreshed when you come back.": "Edytuj w zewnętrznym edytorze (⇧⌘E): powstaje 16-bitowa kopia TIFF, dodana do stosu i odświeżana po powrocie.", + "Edit settings copied": "Skopiowano ustawienia edycji", + "Edited": "Edytowane", + "Edited or not": "Edytowane lub nie", + "Editing": "Edycja", + "Effect": "Efekt", + "Effects": "Efekty", + "Empty Recently Deleted": "Opróżnij ostatnio usunięte", + "Enable Lens Corrections": "Włącz korekcję obiektywu", + "Enable Profile Corrections": "Włącz korekcje profilu", + "End Interaction": "Zakończ interakcję", + "English": "Angielski", + "Enter Full Screen": "Przejdź na pełny ekran", + "Enter a date as YYYY-MM-DD HH:MM:SS": "Wprowadź datę jako RRRR-MM-DD GG:MM:SS", + "Erase": "Wymaż", + "Every EXIF, GPS and XMP field in the file": "Każde pole EXIF, GPS i XMP w pliku", + "Existing previews": "Istniejące podglądy", + "Exit": "Zakończ", + "Expand": "Rozwiń", + "Expand All Stacks": "Rozwiń wszystkie stosy", + "Expand Stack": "Rozwiń stos", + "Expand/Collapse Stack": "Rozwiń/Zwiń stos", + "Export": "Eksportuj", + "Export Album with Preset": "Eksportuj album z ustawieniem wstępnym", + "Export Album…": "Eksportuj album…", + "Export As…": "Eksportuj jako…", + "Export Group…": "Eksportuj grupę…", + "Export Now": "Eksportuj teraz", + "Export Point Curve Presets": "Eksportuj ustawienia wstępne krzywej punktowej", + "Export Point Curve Presets…": "Eksportuj ustawienia wstępne krzywej punktowej…", + "Export Presets": "Eksportuj ustawienia wstępne", + "Export Presets…": "Eksportuj ustawienia wstępne…", + "Export User Presets…": "Eksportuj ustawienia wstępne użytkownika…", + "Export file names use the Rename Photos tokens ({title}, {seq:2}, {date:%Y-%m-%d}…), plus new {num}, {folder}, {lens}, {iso}, {rating}, {creator}.": "Nazwy plików przy eksporcie używają znaczników Zmień nazwy zdjęć ({title}, {seq:2}, {date:%Y-%m-%d}…) oraz nowych: {num}, {folder}, {lens}, {iso}, {rating}, {creator}.", + "Export stopped unexpectedly": "Eksport został nieoczekiwanie przerwany", + "Export with Preset": "Eksportuj z ustawieniem wstępnym", + "Export with Previous": "Eksportuj z poprzednimi ustawieniami", + "Exporting": "Eksportowanie", + "Export…": "Eksportuj…", + "Exposure": "Ekspozycja", + "Exposure (shutter · aperture · ISO)": "Ekspozycja (migawka · przysłona · ISO)", + "Extended Description": "Opis rozszerzony", + "External Editor": "Edytor zewnętrzny", + "Face Boxes": "Ramki twarzy", + "Face boxes": "Ramki twarzy", + "Face names written to XMP by Lightroom and other apps show up here.": "Imiona osób zapisane w XMP przez Lightrooma i inne aplikacje pojawiają się tutaj.", + "Faded Matte": "Wyblakły mat", + "Faded Slide": "Wyblakły slajd", + "Faded Urban": "Wyblakłe miasto", + "Favorite Preset": "Ulubione ustawienie wstępne", + "Favorite Profile": "Ulubiony profil", + "Favorites": "Ulubione", + "Feather": "Miękkość krawędzi", + "File": "Plik", + "File & date": "Plik i data", + "File Format": "Format pliku", + "File Name": "Nazwa pliku", + "File Path": "Ścieżka pliku", + "File Size": "Rozmiar pliku", + "File Type": "Typ pliku", + "File name": "Nazwa pliku", + "File types:": "Typy plików:", + "Filename": "Nazwa pliku", + "Fill": "Wypełnij", + "Fill Edges": "Wypełnij krawędzie", + "Film": "Film", + "Filmstrip": "Pasek zdjęć", + "Filmstrip (/)": "Pasek zdjęć (/)", + "Filter": "Filtr", + "Filter Bar": "Pasek filtrów", + "Filter Presets": "Ustawienia wstępne filtrów", + "Filter bar": "Pasek filtrów", + "Filter fields": "Pola filtrów", + "Find Dust Spots": "Znajdź drobiny kurzu", + "Find Missing Photos": "Znajdź brakujące zdjęcia", + "Find Missing Photos…": "Znajdź brakujące zdjęcia…", + "Find Similar Photos": "Znajdź podobne zdjęcia", + "Find tools and commands…": "Znajdź narzędzia i polecenia…", + "Find…": "Znajdź…", + "First": "Pierwszy", + "First commit": "Pierwszy commit", + "Fit": "Dopasuj", + "Fit (F)": "Dopasuj (F)", + "Fit on Screen": "Dopasuj do ekranu", + "Fit to width": "Dopasuj do szerokości", + "Fit visible": "Dopasuj widoczne", + "Five stars": "Pięć gwiazdek", + "Fixed keys": "Stałe klawisze", + "Flag": "Oznacz", + "Flag as Pick": "Oznacz jako wybrane", + "Flag as Reject": "Oznacz jako odrzucone", + "Flagged as Pick": "Oznaczone jako wybrane", + "Flagged as Reject": "Oznaczone jako odrzucone", + "Flash": "Lampa błyskowa", + "Flip H": "Odbij w poziomie", + "Flip Horizontal": "Odbij poziomo", + "Flip Overlay (⇧O)": "Odwróć nakładkę (⇧O)", + "Flip V": "Odbij w pionie", + "Flip Vertical": "Odbij pionowo", + "Float panel": "Odłącz panel", + "Flow": "Przepływ", + "Fluorescent": "Światło fluorescencyjne", + "Focal Length": "Ogniskowa", + "Focal length": "Ogniskowa", + "Focus (assisted culling)": "Ostrość (wspomagana selekcja)", + "Folder": "Folder", + "Folder name (renamed on disk; its photos follow)": "Nazwa folderu (zmieniana na dysku wraz ze zdjęciami)", + "Folders": "Foldery", + "Folders and {date} use the capture time; a photo without one uses today's date.": "Foldery i {date} używają daty wykonania; gdy zdjęcie jej nie ma, używana jest dzisiejsza data.", + "Food": "Jedzenie", + "Forget Unchanged Local Photos": "Zapomnij o niezmienionych zdjęciach lokalnych", + "Forget unchanged photos": "Zapomnij o niezmienionych zdjęciach", + "Form fields panel": "Panel pól formularza", + "Format": "Format", + "Forward": "Dalej", + "Frame time": "Czas klatki", + "Frame update": "Aktualizacja klatki", + "Free": "Wolne", + "Fresh & Bright": "Świeżo i jasno", + "Friday": "Piątek", + "Full": "Pełny", + "Full Screen Preview": "Podgląd pełnoekranowy", + "Full screen mode": "Tryb pełnoekranowy", + "Full size": "Pełny rozmiar", + "GPS Track Log": "Ślad GPS", + "GPU Rendering": "Renderowanie GPU", + "GPU rendering": "Renderowanie na GPU", + "General": "Ogólne", + "Generated demo photo": "Wygenerowane zdjęcie demonstracyjne", + "Geometry": "Geometria", + "Get Develop Settings": "Pobierz ustawienia wywoływania", + "Global Hue": "Globalny odcień", + "Global Luminance": "Globalna luminancja", + "Global Saturation": "Globalne nasycenie", + "Glossy": "Błyszczący", + "Go to History Step": "Przejdź do kroku historii", + "Golden": "Złocisty", + "Golden Glow": "Złota poświata", + "Golden Hour": "Złota godzina", + "Grab bag": "Zbiór różnych elementów", + "Grain": "Ziarno", + "Graphic": "Graficzny", + "Green": "Zielony", + "Green Amount": "Ilość zieleni", + "Green Hue": "Odcień zieleni", + "Green Hue High": "Górny zakres odcienia zieleni", + "Green Hue Low": "Dolny zakres odcienia zieleni", + "Green Luminance": "Luminancja zieleni", + "Green Primary": "Zieleń podstawowa", + "Green Saturation": "Nasycenie zieleni", + "Green channel": "Kanał zielony", + "Green label": "Zielona etykieta", + "Grid": "Siatka", + "Grid Info": "Informacje w siatce", + "Gritty Street": "Surowa ulica", + "Group": "Grupa", + "Group by Date: Automatic": "Grupowanie według daty: automatycznie", + "Group by Day": "Grupuj według dnia", + "Group by Month": "Grupuj według miesiąca", + "Group by Year": "Grupuj według roku", + "Group by date": "Grupuj według daty", + "Group into Stack": "Zgrupuj w stos", + "Group name": "Nazwa grupy", + "Guided": "Z prowadnicami", + "Guided Upright": "Korekcja perspektywy z prowadnicami", + "Guided Upright Guides": "Prowadnice korekcji perspektywy", + "HDR Merge": "Scalanie HDR", + "HDR Merge Preview": "Podgląd scalania HDR", + "HDR Panorama Merge": "Scal panoramę HDR", + "HDR Panorama Merge Preview": "Podgląd scalania panoramy HDR", + "HDR Panorama with Last Settings": "Panorama HDR z ostatnimi ustawieniami", + "HDR Panoramas": "Panoramy HDR", + "HDR Panorama…": "Panorama HDR…", + "HDR panorama": "Panorama HDR", + "HDR panoramas": "Panoramy HDR", + "HDR with Last Settings": "HDR z ostatnimi ustawieniami", + "HDR…": "HDR…", + "Has Edits": "Ma edycje", + "Has GPS": "Ma dane GPS", + "Heal": "Korekta", + "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Usuń retuszem niewielkie, miękkie ciemne plamki pozostawione przez kurz na matrycy", + "Height": "Wysokość", + "Height (px)": "Wysokość (px)", + "Help": "Pomoc", + "Hide LightCraft": "Ukryj LightCraft", + "Hide Mask": "Ukryj maskę", + "Hide Others": "Ukryj pozostałe", + "Hides this shortcut only; the folder and its photos stay as they are": "Ukrywa tylko ten skrót; folder i zdjęcia pozostają bez zmian", + "High": "Wysoki", + "High Contrast B&W": "Czarno-biały o wysokim kontraście", + "Highlight": "Światła", + "Highlights": "Światła", + "Highlights Hue": "Odcień świateł", + "Highlights Luminance": "Luminancja świateł", + "Highlights Saturation": "Nasycenie świateł", + "Highlights split": "Podział świateł", + "Histogram": "Histogram", + "History": "Historia", + "History & Activity (Y)": "Historia i aktywność (Y)", + "Home": "Strona główna", + "Horizontal": "Poziomo", + "Horizontal text": "Tekst poziomy", + "Hours": "Godziny", + "Hue": "Odcień", + "Hue Range": "Zakres odcieni", + "ISO Speed": "Czułość ISO", + "ISO speed": "Czułość ISO", + "If file exists": "Jeśli plik istnieje", + "If your library can't be opened at launch (open in another program, unreadable, on a drive that isn't connected,": "Jeśli nie można otworzyć biblioteki przy uruchamianiu (jest używana w innym programie, nieczytelna lub znajduje się na odłączonym dysku,", + "Image": "Obraz", + "Import": "Importuj", + "Import Date": "Data importu", + "Import Lightroom Catalog": "Importuj katalog Lightrooma", + "Import Lightroom Catalog…": "Importuj katalog Lightrooma…", + "Import Photos": "Importuj zdjęcia", + "Import Photos…": "Importuj zdjęcia…", + "Import Point Curve Presets": "Importuj ustawienia krzywej punktowej", + "Import Point Curve Presets…": "Importuj ustawienia krzywej punktowej…", + "Import Presets": "Importuj ustawienia wstępne", + "Import Presets…": "Importuj ustawienia wstępne…", + "Import Profiles": "Importuj profile", + "Import Profiles & Presets…": "Importuj profile i ustawienia wstępne…", + "Import as new": "Importuj jako nowe", + "Import from Device": "Importuj z urządzenia", + "Import from Folder…": "Importuj z folderu…", + "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here": "Importuj zdjęcia przez Plik → Importuj zdjęcia… (Cmd+Shift+I) lub przeciągnij je tutaj", + "Imported": "Zaimportowane", + "Imported Photos": "Zaimportowane zdjęcia", + "Importing": "Importowanie", + "Importing Lightroom Catalog": "Importowanie katalogu Lightrooma", + "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).": "Importowanie wyszukuje zdjęcia w źródle; nie dodaje folderu do sekcji Lokalne (użyj Lokalne → Przeglądaj folder…, aby pracować w folderze bez importowania).", + "In Photo Grid and Square Grid: Flag as Pick and advance": "W siatce zdjęć i siatce kwadratowej: oznacz jako wybrane i przejdź dalej", + "In Photo Grid and Square Grid: expand or collapse the stack": "W siatce zdjęć i siatce kwadratowej: rozwiń lub zwiń stos", + "In Recently Deleted": "W ostatnio usuniętych", + "In library": "W bibliotece", + "In memory — nothing is saved": "W pamięci — nic nie zapisano", + "In use": "W użyciu", + "In-memory session: these settings last until LightCraft quits.": "Sesja w pamięci: te ustawienia obowiązują do zamknięcia LightCraft.", + "Include subfolders": "Uwzględnij podfoldery", + "Increase Brush Feather": "Zwiększ miękkość pędzla", + "Increase Brush Size": "Zwiększ rozmiar pędzla", + "Info": "Informacje", + "Info (I)": "Informacje (I)", + "Info Overlay Off": "Wyłącz nakładkę informacyjną", + "Info Overlay: Exposure": "Nakładka informacyjna: ekspozycja", + "Info Overlay: File": "Nakładka informacyjna: plik", + "Info overlay": "Nakładka informacyjna", + "Insert at the cursor": "Wstaw w miejscu kursora", + "Inspect Lightroom Catalog": "Sprawdź katalog Lightrooma", + "Inspect Photo": "Sprawdź zdjęcie", + "Inspecting Lightroom Catalog": "Sprawdzanie katalogu Lightrooma", + "Instant ’70s": "Lata 70. od ręki", + "Interface": "Interfejs", + "Interface language": "Język interfejsu", + "Intersect": "Część wspólna", + "Intersect with": "Przetnij z", + "Into one folder": "Do jednego folderu", + "Invert": "Odwróć", + "Invert Component": "Odwróć składnik", + "Invert Mask": "Odwróć maskę", + "JPEG (Large)": "JPEG (duży)", + "JPEG (Small)": "JPEG (mały)", + "Join the ArtCraft Discord": "Dołącz do Discorda ArtCraft", + "Join the ArtCraft Discord…": "Dołącz do Discorda ArtCraft…", + "Join the ArtCraft community on Discord": "Dołącz do społeczności ArtCraft na Discordzie", + "Keep in Local": "Zachowaj w lokalnych", + "Keep it on a drive with room: smart previews are about 1 MB per photo. The thumbnail cache stays in the library.": "Przechowuj ją na dysku z wolnym miejscem: podglądy inteligentne zajmują około 1 MB na zdjęcie. Pamięć podręczna miniatur pozostaje w bibliotece.", + "Keyboard Shortcuts": "Skróty klawiaturowe", + "Keyboard shortcuts": "Skróty klawiaturowe", + "Keyword": "Słowo kluczowe", + "Keyword Painter": "Pędzel słów kluczowych", + "Keyword Set": "Zestaw słów kluczowych", + "Keyword Sets": "Zestawy słów kluczowych", + "Keyword Suggestions": "Sugestie słów kluczowych", + "Keyword to paint": "Słowo kluczowe do nanoszenia", + "Keywords": "Słowa kluczowe", + "Keywords (K)": "Słowa kluczowe (K)", + "Keywords you add appear here; ⌥1–⌥9 apply them.": "Dodane słowa kluczowe pojawiają się tutaj; skróty ⌥1–⌥9 przypisują je do zdjęć.", + "Label": "Etykieta", + "Landscape": "Krajobraz", + "Language": "Język", + "Last": "Ostatni", + "Last GPU fallback": "Ostatnie przełączenie awaryjne GPU", + "Last commit": "Ostatni zapis zmian", + "Last loupe render": "Ostatnie renderowanie lupy", + "Last view": "Ostatni widok", + "Lateral Chromatic Aberration": "Poprzeczna aberracja chromatyczna", + "Layer": "Warstwa", + "Layers": "Warstwy", + "Leave them": "Pozostaw je", + "Lens": "Obiektyw", + "Lens Blur": "Rozmycie obiektywu", + "Lens Corrections": "Korekcje obiektywu", + "Level": "Poziom", + "Library": "Biblioteka", + "Library Info": "Informacje o bibliotece", + "Library Originals": "Oryginały biblioteki", + "Library Preferences": "Preferencje biblioteki", + "Library State": "Stan biblioteki", + "Library saved": "Biblioteka zapisana", + "Licence:": "Licencja:", + "Light": "Światło", + "LightCraft 0.2.0 and older refuse it (\"unsupported format … v2\").": "LightCraft 0.2.0 i starsze wersje odrzucają ten plik („nieobsługiwany format … v2”).", + "LightCraft Default": "Domyślne ustawienia LightCraft", + "LightCraft Help": "Pomoc LightCraft", + "LightCraft Preset": "Ustawienie wstępne LightCraft", + "LightCraft Presets": "Ustawienia wstępne LightCraft", + "LightCraft Website": "Strona LightCraft", + "LightCraft could not start": "Nie można uruchomić LightCraft", + "LightCraft couldn't find where to keep your library.": "LightCraft nie znalazł miejsca na przechowywanie biblioteki.", + "LightCraft couldn't open the library at": "LightCraft nie mógł otworzyć biblioteki w lokalizacji", + "LightCraft on GitHub": "LightCraft w serwisie GitHub", + "LightCraft website": "Strona internetowa LightCraft", + "LightCraft — Second Window": "LightCraft — drugie okno", + "Lightroom Classic Catalog": "Katalog Lightroom Classic", + "Lightroom import cancelled": "Anulowano import z Lightrooma", + "Lightroom inspection cancelled": "Anulowano sprawdzanie Lightrooma", + "Lights": "Światła", + "Limit file size (KB, 0 = off)": "Limit rozmiaru pliku (KB, 0 = wyłączone)", + "Line delta": "Zmiana liczby wierszy", + "Linear": "Liniowy", + "Linear Gradient": "Gradient liniowy", + "Lines +/−": "Wiersze +/−", + "Lines added": "Dodane wiersze", + "Lines deleted": "Usunięte wiersze", + "List Albums": "Wyświetl albumy", + "List Develop Controls": "Wyświetl ustawienia wywoływania", + "List History": "Wyświetl historię", + "List Presets": "Wyświetl ustawienia predefiniowane", + "List Profiles": "Wyświetl profile", + "List this folder in Local from now on": "Od teraz pokazuj ten folder w widoku lokalnym", + "Local": "Lokalne", + "Local folders": "Foldery lokalne", + "Local library — no cloud account needed": "Biblioteka lokalna — konto w chmurze nie jest potrzebne", + "Locate Missing File…": "Znajdź brakujący plik…", + "Locate Photo": "Znajdź zdjęcie", + "Location": "Lokalizacja", + "Lock": "Zablokuj", + "Lock Crop Aspect": "Zablokuj proporcje kadrowania", + "Locked": "Zablokowane", + "Long Edge": "Dłuższy bok", + "Looking for photos…": "Wyszukiwanie zdjęć…", + "Lossless": "Bezstratny", + "Lossy DNG files and Smart Previews open as raw photos.": "Stratne pliki DNG i podglądy inteligentne otwierają się jako zdjęcia RAW.", + "Low": "Niski", + "Luminance": "Luminancja", + "Luminance Range": "Zakres luminancji", + "Lush Greens": "Soczysta zieleń", + "Magenta": "Purpurowy", + "Magenta Hue": "Odcień purpury", + "Magenta Luminance": "Luminancja purpury", + "Magenta Saturation": "Nasycenie purpury", + "Make Candidate the Select": "Ustaw zdjęcie kandydujące jako wybrane", + "Manual": "Ręcznie", + "Marquee zoom": "Powiększanie zaznaczonego obszaru", + "Mask Adjustment": "Korekta maski", + "Masking": "Maskowanie", + "Masking (M)": "Maskowanie (M)", + "Masks": "Maski", + "Match": "Dopasuj", + "Match Total Exposures": "Wyrównaj całkowitą ekspozycję", + "Matte": "Matowy", + "Matte Soft": "Delikatny mat", + "Med": "Śr.", + "Medium Contrast": "Średni kontrast", + "Megapixels": "Megapiksele", + "Memory Budget": "Limit pamięci", + "Memory Usage": "Wykorzystanie pamięci", + "Memory budget": "Limit pamięci", + "Memory for caches": "Pamięć na pamięć podręczną", + "Menu": "Menu", + "Merge": "Scal", + "Merge Keywords": "Połącz słowa kluczowe", + "Merge into…": "Scal z…", + "Merged": "Scalone", + "Merged PRs": "Scalone zgłoszenia PR", + "Merging…": "Scalanie…", + "Metadata": "Metadane", + "Metadata Preset": "Zestaw metadanych", + "Metadata Presets": "Zestawy metadanych", + "Metadata preset": "Zestaw metadanych", + "Midpoint": "Punkt środkowy", + "Midtones": "Tony średnie", + "Midtones Hue": "Odcień tonów średnich", + "Midtones Luminance": "Luminancja tonów średnich", + "Midtones Saturation": "Nasycenie tonów średnich", + "Midtones split": "Podział tonów średnich", + "Minimize": "Minimalizuj", + "Missing Photos": "Brakujące zdjęcia", + "Missing metadata ({camera}, {title}…) leaves an empty gap; a name that comes out empty keeps the original name.": "Brakujące metadane ({camera}, {title}…) pozostawiają puste miejsce; jeśli wynikowa nazwa jest pusta, zachowywana jest nazwa oryginalna.", + "Misty Morning": "Mglisty poranek", + "Mode": "Tryb", + "Model": "Model", + "Models": "Modele", + "Modified Date": "Data modyfikacji", + "Monday": "Poniedziałek", + "Mono Red Filter": "Monochromatyczny filtr czerwony", + "Mono Rich": "Bogata monochromia", + "Mono Soft": "Delikatna monochromia", + "Monochrome": "Monochromatyczny", + "Month": "Miesiąc", + "Moody": "Nastrojowy", + "Moody Portrait": "Nastrojowy portret", + "More": "Więcej", + "More preset options": "Więcej opcji ustawienia predefiniowanego", + "Move": "Przenieś", + "Move Album": "Przenieś album", + "Move Down": "Przenieś w dół", + "Move Down in Stack": "Przenieś niżej w stosie", + "Move Folder": "Przenieś folder", + "Move Folder To…": "Przenieś folder do…", + "Move Mask": "Przenieś maskę", + "Move Preset to Group": "Przenieś ustawienie predefiniowane do grupy", + "Move Preset to New Group": "Przenieś ustawienie predefiniowane do nowej grupy", + "Move Up": "Przenieś w górę", + "Move Up in Stack": "Przenieś wyżej w stosie", + "Move in Stack": "Przenieś w stosie", + "Move the files (into the library's Originals/, or a folder you choose), removing them from the source": "Przenieś pliki (do folderu Originals/ w bibliotece lub do wybranego folderu), usuwając je z lokalizacji źródłowej", + "Move them": "Przenieś je", + "Move to": "Przenieś do", + "Move to Group": "Przenieś do grupy", + "Move to group": "Przenieś do grupy", + "Moves every photo imported from this disk to Recently Deleted; no file is touched": "Przenosi wszystkie zdjęcia zaimportowane z tego dysku do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Moves the photos imported from this folder to Recently Deleted; no file is touched": "Przenosi zdjęcia zaimportowane z tego folderu do Ostatnio usuniętych; żaden plik nie zostanie zmieniony", + "Muted": "Stonowany", + "Muted Film": "Stonowany film", + "My Photos": "Moje zdjęcia", + "My Photos Panel": "Panel Moje zdjęcia", + "Name": "Nazwa", + "Name (A–Z)": "Nazwa (A–Z)", + "Name of the folder the original is in": "Nazwa folderu z oryginałem", + "Named": "Nazwane", + "Named People": "Osoby z przypisanymi imionami", + "Names appear in the label menu, the filter bar and the Info panel, and are written to XMP. Empty = the colour's name.": "Nazwy pojawiają się w menu etykiet, pasku filtrów i panelu informacji oraz są zapisywane w XMP. Puste pole oznacza nazwę koloru.", + "Navigator": "Nawigator", + "Needs Work": "Wymaga dopracowania", + "Neon Dusk": "Neonowy zmierzch", + "Neon Night": "Neonowa noc", + "Nested group": "Grupa zagnieżdżona", + "Neutral": "Neutralny", + "Never": "Nigdy", + "New": "Nowy", + "New Album": "Nowy album", + "New Album…": "Nowy album…", + "New Folder…": "Nowy folder…", + "New Group…": "Nowa grupa…", + "New Smart Album": "Nowy album inteligentny", + "New Smart Album from Filter…": "Nowy album inteligentny z filtra…", + "New Smart Album…": "Nowy album inteligentny…", + "New album": "Nowy album", + "New album…": "Nowy album…", + "New blank PDF": "Nowy pusty PDF", + "New name": "Nowa nazwa", + "New time": "Nowy czas", + "New…": "Nowy…", + "Next Photo": "Następne zdjęcie", + "Night Blue": "Nocny błękit", + "No": "Nie", + "No Camera or Card Found": "Nie znaleziono aparatu ani karty pamięci", + "No Date Groups": "Brak grup według dat", + "No Stars": "Brak gwiazdek", + "No Tool": "Brak narzędzia", + "No automatic versions.": "Brak automatycznych wersji.", + "No camera": "Brak aparatu", + "No contributor data was built into this copy.": "W tej wersji nie zapisano danych współtwórców.", + "No date": "Brak daty", + "No dust spots found": "Nie znaleziono plamek kurzu", + "No edits yet.": "Nie wprowadzono jeszcze zmian.", + "No exposure information": "Brak informacji o ekspozycji", + "No filter presets yet": "Brak zapisanych zestawów filtrów", + "No lens data embedded in this file (DNG lens opcodes only).": "Ten plik nie zawiera danych obiektywu (dostępne są jedynie instrukcje obiektywu DNG).", + "No masks yet. Choose a mask type above.": "Nie ma jeszcze masek. Wybierz typ maski powyżej.", + "No matching commands": "Brak pasujących poleceń", + "No matching photos": "Brak pasujących zdjęć", + "No metadata found": "Nie znaleziono metadanych", + "No model credits were built into this copy.": "W tej wersji nie zapisano informacji o autorach modeli.", + "No named people in these photos": "Na tych zdjęciach nie ma osób z przypisanymi imionami", + "No named people yet": "Nie nazwano jeszcze żadnych osób", + "No new presets": "Brak nowych ustawień predefiniowanych", + "No photo selected": "Nie wybrano zdjęcia", + "No photos": "Brak zdjęć", + "No photos found": "Nie znaleziono zdjęć", + "No photos in this folder": "W tym folderze nie ma zdjęć", + "No photos match the active filters": "Żadne zdjęcia nie spełniają aktywnych filtrów", + "No photos match the filters (View → Clear Filters)": "Żadne zdjęcia nie spełniają filtrów (Widok → Wyczyść filtry)", + "No raw photos yet: cameras appear here once their photos are in the library.": "Brak zdjęć RAW. Aparaty pojawią się tutaj po dodaniu ich zdjęć do biblioteki.", + "No reference photo": "Brak zdjęcia referencyjnego", + "No usable GPU found: rendering on the CPU": "Nie znaleziono obsługiwanej karty graficznej: renderowanie na procesorze", + "No versions yet. Create one to keep this look.": "Nie ma jeszcze wersji. Utwórz wersję, aby zachować ten wygląd.", + "Noise": "Szum", + "Noise Reduction": "Redukcja szumu", + "Non-raw photos": "Zdjęcia inne niż RAW", + "None": "Brak", + "Not Now": "Nie teraz", + "Nothing to compare": "Nie ma czego porównywać", + "Nothing to survey": "Nie ma czego przeglądać", + "Nudge Develop Value": "Zmień nieznacznie wartość wywoływania", + "OK": "OK", + "Object": "Obiekt", + "Object Mask": "Maska obiektu", + "October 2026": "Październik 2026", + "Off": "Wyłączone", + "Offset X": "Przesunięcie X", + "Offset Y": "Przesunięcie Y", + "Opacity": "Krycie", + "Opacity (%)": "Krycie (%)", + "Open Library": "Otwórz bibliotekę", + "Open Library…": "Otwórz bibliotekę…", + "Open in": "Otwórz w", + "Open in Detail": "Otwórz w widoku szczegółowym", + "Open the place in OpenStreetMap": "Otwórz lokalizację w OpenStreetMap", + "Open…": "Otwórz…", + "Optics": "Optyka", + "Optimize Library": "Optymalizuj bibliotekę", + "Optional name": "Nazwa (opcjonalnie)", + "Options": "Opcje", + "Orange": "Pomarańczowy", + "Orange Hue": "Odcień pomarańczowego", + "Orange Luminance": "Luminancja pomarańczowego", + "Orange Saturation": "Nasycenie pomarańczowego", + "Orientation": "Orientacja", + "Original": "Oryginał", + "Original + Settings": "Oryginał + ustawienia", + "Original extension without the dot": "Oryginalne rozszerzenie bez kropki", + "Original file name without its extension": "Oryginalna nazwa pliku bez rozszerzenia", + "Original files": "Pliki oryginalne", + "Original offline · editing the smart preview": "Oryginał niedostępny · edycja podglądu inteligentnego", + "Original offline · no smart preview": "Oryginał niedostępny · brak podglądu inteligentnego", + "Originals": "Oryginały", + "Other images (JPEG, PNG, TIFF, HEIC…)": "Inne obrazy (JPEG, PNG, TIFF, HEIC…)", + "Overlay": "Nakładka", + "Overwrite": "Nadpisz", + "Pages": "Strony", + "Paint": "Malowanie", + "Paint only areas like the one under the brush": "Maluj tylko obszary podobne do tego pod pędzlem", + "Paint over a distraction on the photo to remove it.": "Zamaluj niepożądany element na zdjęciu, aby go usunąć.", + "Paint overlay": "Nakładka malowania", + "Panels": "Panele", + "Panorama": "Panorama", + "Panorama Merge": "Łączenie panoramy", + "Panorama Merge Preview": "Podgląd łączenia panoramy", + "Panorama with Last Settings": "Panorama z ostatnimi ustawieniami", + "Panoramas": "Panoramy", + "Panorama…": "Panorama…", + "Parametric curve": "Krzywa parametryczna", + "Paste": "Wklej", + "Paste Edit Settings": "Wklej ustawienia edycji", + "Paste Metadata": "Wklej metadane", + "Paste Selected Settings": "Wklej wybrane ustawienia", + "Paste Selected Settings…": "Wklej wybrane ustawienia…", + "Paste Settings": "Wklej ustawienia", + "Paste Settings from Previous": "Wklej ustawienia z poprzedniego", + "Paste from Previous": "Wklej z poprzedniego", + "Paste pages": "Wklej strony", + "Pastel Haze": "Pastelowa mgiełka", + "People": "Osoby", + "Percent": "Procent", + "Percentage": "Wartość procentowa", + "Performance": "Wydajność", + "Person": "Osoba", + "Perspective": "Perspektywa", + "Pet Eye": "Oczy zwierząt", + "Pet Eye Catchlight": "Blik w oczach zwierząt", + "Photo": "Zdjęcie", + "Photo Grid": "Siatka zdjęć", + "Photo Grid (G)": "Siatka zdjęć (G)", + "Photo Merge": "Łączenie zdjęć", + "Photos": "Zdjęcia", + "Photos per bracket (0 = auto)": "Zdjęć w serii ekspozycji (0 = automatycznie)", + "Pick": "Wybierz", + "Pick Flag": "Flaga wyboru", + "Pick a colour on the photo with the eyedropper.": "Wybierz kolor ze zdjęcia za pomocą pipety.", + "Pick the sharpest photo of each burst": "Wybierz najostrzejsze zdjęcie z każdej serii", + "Picked": "Wybrane", + "Picks": "Wybrane zdjęcia", + "Pictures": "Obrazy", + "Pixels": "Piksele", + "Point Color": "Kolor punktowy", + "Point Curve": "Krzywa punktowa", + "Point Curve Presets": "Ustawienia wstępne krzywej punktowej", + "Point curve": "Krzywa punktowa", + "Portrait": "Portret", + "Position": "Położenie", + "Preferences": "Preferencje", + "Preferences…": "Preferencje…", + "Prepare AI Masks": "Przygotuj maski AI", + "Preset": "Ustawienie wstępne", + "Preset name": "Nazwa ustawienia wstępnego", + "Presets": "Ustawienia wstępne", + "Presets & Profiles": "Ustawienia wstępne i profile", + "Presets (Shift+P)": "Ustawienia wstępne (Shift+P)", + "Presets and profiles": "Ustawienia wstępne i profile", + "Presets ▾": "Ustawienia wstępne ⌄", + "Press keys…": "Naciśnij klawisze…", + "Preview": "Podgląd", + "Preview Build Progress": "Postęp tworzenia podglądów", + "Preview only": "Tylko podgląd", + "Preview size": "Rozmiar podglądu", + "Previews": "Podglądy", + "Previous Photo": "Poprzednie zdjęcie", + "Print": "Drukuj", + "Print…": "Drukuj…", + "Profile": "Profil", + "Profile Browser": "Przeglądarka profili", + "Profile Distortion": "Dystorsja profilu", + "Profile Menu": "Menu profili", + "Profile Vignetting": "Winietowanie profilu", + "Profile:": "Profil:", + "Profiles": "Profile", + "Projection": "Odwzorowanie", + "Proof copy created": "Utworzono kopię próbną", + "Properties": "Właściwości", + "Public domain": "Domena publiczna", + "Pupil Size": "Rozmiar źrenicy", + "Purple": "Purpurowy", + "Purple Amount": "Ilość purpury", + "Purple Hue": "Odcień purpury", + "Purple Hue High": "Górna granica odcienia purpury", + "Purple Hue Low": "Dolna granica odcienia purpury", + "Purple Luminance": "Luminancja purpury", + "Purple Saturation": "Nasycenie purpury", + "Put the locations you removed from Local back (no files change)": "Przywróć lokalizacje usunięte z widoku Lokalne (bez zmiany plików)", + "Quality": "Jakość", + "Query Photos": "Wyszukaj zdjęcia", + "Quick Collection": "Szybka kolekcja", + "Quick Develop": "Szybka obróbka", + "Quiet Green": "Spokojna zieleń", + "Quit": "Zakończ", + "Quit Anyway": "Zamknij mimo to", + "Quit LightCraft": "Zamknij LightCraft", + "Quit with unsaved changes?": "Zamknąć z niezapisanymi zmianami?", + "RAW decoding": "Dekodowanie RAW", + "Radial": "Promieniowy", + "Radial Gradient": "Gradient promieniowy", + "Radius": "Promień", + "Random": "Losowo", + "Range": "Zakres", + "Rate ★": "Oceń ★", + "Rate ★ and advance": "Oceń ★ i przejdź dalej", + "Rate ★★": "Oceń ★★", + "Rate ★★ and advance": "Oceń ★★ i przejdź dalej", + "Rate ★★★": "Oceń ★★★", + "Rate ★★★ and advance": "Oceń ★★★ i przejdź dalej", + "Rate ★★★★": "Oceń ★★★★", + "Rate ★★★★ and advance": "Oceń ★★★★ i przejdź dalej", + "Rate ★★★★★": "Oceń ★★★★★", + "Rate ★★★★★ and advance": "Oceń ★★★★★ i przejdź dalej", + "Rating": "Ocena", + "Rating cleared": "Usunięto ocenę", + "Ratings & flags": "Oceny i flagi", + "Raw": "RAW", + "Raw default": "Domyślne ustawienia RAW", + "Raw defaults": "Domyślne ustawienia RAW", + "Raw files": "Pliki RAW", + "Raw photos": "Zdjęcia RAW", + "Raw photos as DNG, with the edits embedded. Size, color and output options don't apply.": "Zdjęcia RAW jako DNG z osadzonymi zmianami. Opcje rozmiaru, koloru i wyjścia nie mają zastosowania.", + "Read Metadata from File": "Wczytaj metadane z pliku", + "Read mode": "Tryb odczytu", + "Read the SAM License": "Przeczytaj licencję SAM", + "Reading": "Wczytywanie", + "Reading Lightroom catalog…": "Wczytywanie katalogu Lightroom…", + "Reading folder…": "Wczytywanie folderu…", + "Real name": "Imię i nazwisko", + "Recent": "Ostatnie", + "Recent Keywords": "Ostatnie słowa kluczowe", + "Recently Added": "Ostatnio dodane", + "Recently Deleted": "Ostatnio usunięte", + "Red": "Czerwony", + "Red Eye": "Czerwone oczy", + "Red Hue": "Odcień czerwieni", + "Red Luminance": "Luminancja czerwieni", + "Red Primary": "Czerwony podstawowy", + "Red Saturation": "Nasycenie czerwieni", + "Red channel": "Kanał czerwony", + "Red label": "Czerwona etykieta", + "Red/Cyan Fringe": "Czerwono-turkusowa obwódka", + "Redo": "Ponów", + "Reference": "Odniesienie", + "Reference View": "Widok referencyjny", + "Reference the files where they are": "Pozostaw pliki w bieżącym położeniu", + "Refine": "Dopracuj", + "Refine AI Mask Detail": "Dopracuj szczegóły maski AI", + "Refine Edges": "Dopracuj krawędzie", + "Refine Mask Edges": "Dopracuj krawędzie maski", + "Refine Saturation": "Dopracuj nasycenie", + "Refining the mask's detail…": "Dopracowywanie szczegółów maski…", + "Refresh Source": "Odśwież źródło", + "Refresh Source (/)": "Odśwież źródło (/)", + "Reject": "Odrzuć", + "Reject below focus": "Odrzuć poniżej progu ostrości", + "Rejected": "Odrzucone", + "Rejects": "Odrzucone zdjęcia", + "Reliability": "Niezawodność", + "Relink Photo": "Ponownie połącz zdjęcie", + "Reload Changed Files": "Wczytaj ponownie zmienione pliki", + "Reload from Disk": "Wczytaj ponownie z dysku", + "Remove": "Usuń", + "Remove (H)": "Usuń (H)", + "Remove CA": "Usuń aberrację chromatyczną", + "Remove Chromatic Aberration": "Usuń aberrację chromatyczną", + "Camera local tone mapping": "Lokalne mapowanie tonów aparatu", + "Apply the local tone mapping stored in the raw (Apple ProRAW), as the camera renders it": "Zastosuj lokalne mapowanie tonów zapisane w pliku RAW (Apple ProRAW), tak jak robi to aparat", + "Remove Disk from Library": "Usuń dysk z biblioteki", + "Remove Disk from Library…": "Usuń dysk z biblioteki…", + "Remove Face Box": "Usuń ramkę twarzy", + "Remove Folder from Library": "Usuń folder z biblioteki", + "Remove a filter above, or choose Clear all": "Usuń powyższy filtr lub wybierz Wyczyść wszystko", + "Remove from Album": "Usuń z albumu", + "Remove from Favorites": "Usuń z ulubionych", + "Remove from Library…": "Usuń z biblioteki…", + "Remove from Local": "Usuń z lokalnych", + "Remove from Photo": "Usuń ze zdjęcia", + "Remove from Selected Photos": "Usuń z zaznaczonych zdjęć", + "Remove from Stack": "Usuń ze stosu", + "Remove from Target Album": "Usuń z albumu docelowego", + "Remove from survey": "Usuń z widoku przeglądu", + "Remove location info": "Usuń informacje o lokalizacji", + "Remove shortcut": "Usuń skrót klawiszowy", + "Remove this face box (undo with Edit ▸ Undo)": "Usuń tę ramkę twarzy (cofnij przez Edycja → Cofnij)", + "Remove this filter": "Usuń ten filtr", + "Remove this group": "Usuń tę grupę", + "Remove this rule": "Usuń tę regułę", + "Rename": "Zmień nazwę", + "Rename Album": "Zmień nazwę albumu", + "Rename Component": "Zmień nazwę elementu", + "Rename Folder": "Zmień nazwę folderu", + "Rename Folder…": "Zmień nazwę folderu…", + "Rename Keyword": "Zmień nazwę słowa kluczowego", + "Rename Keyword…": "Zmień nazwę słowa kluczowego…", + "Rename Mask": "Zmień nazwę maski", + "Rename Photos": "Zmień nazwy zdjęć", + "Rename Photos…": "Zmień nazwy zdjęć…", + "Rename Preset": "Zmień nazwę ustawienia wstępnego", + "Rename Preview": "Zmień nazwę podglądu", + "Rename Template Tags": "Zmień nazwy znaczników szablonu", + "Rename Version": "Zmień nazwę wersji", + "Rename…": "Zmień nazwę…", + "Rendering": "Renderowanie", + "Rendering…": "Renderowanie…", + "Reorder Masks": "Zmień kolejność masek", + "Reset": "Resetuj", + "Reset All": "Zresetuj wszystko", + "Reset All Curves": "Zresetuj wszystkie krzywe", + "Reset All Keyboard Shortcuts": "Przywróć wszystkie skróty klawiszowe", + "Reset Before to Import State": "Przywróć stan przed edycją do stanu po imporcie", + "Reset Control": "Zresetuj element sterujący", + "Reset Crop": "Zresetuj kadrowanie", + "Reset Edits": "Zresetuj edycje", + "Reset Panel Layout": "Przywróć układ paneli", + "Reset Parametric Curve": "Zresetuj krzywą parametryczną", + "Reset Section": "Zresetuj sekcję", + "Reset Tone Curve": "Zresetuj krzywą tonalną", + "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Zresetuj wszystkie krzywe: punktowe (wszystkie kanały) i parametryczną (kliknij dwukrotnie kanał, aby zresetować tylko jego krzywą)", + "Reset all edits (Cmd+Shift+R)": "Zresetuj wszystkie edycje (Cmd+Shift+R)", + "Reshuffle": "Wylosuj ponownie", + "Resize Face Box": "Zmień rozmiar ramki twarzy", + "Resize panel window": "Zmień rozmiar okna panelu", + "Resize panels": "Zmień rozmiary paneli", + "Resize to": "Zmień rozmiar do", + "Resolution (ppi)": "Rozdzielczość (ppi)", + "Restore": "Przywróć", + "Restore (no shortcut)": "Przywróć (bez skrótu)", + "Restore Library from Backup…": "Przywróć bibliotekę z kopii zapasowej…", + "Restore Version": "Przywróć wersję", + "Restore keeps their edits; Import as new deletes the old record first.": "Przywracanie zachowuje edycje; import jako nowe zdjęcie najpierw usuwa stary wpis.", + "Restore them": "Przywróć je", + "Retouch": "Retusz", + "Reverse the order": "Odwróć kolejność", + "Revert": "Cofnij zmiany", + "Review": "Przegląd", + "Review Import": "Sprawdź import", + "Right-click a photo ▸ Set as Reference Photo, then View → Reference View (Shift+R)": "Kliknij zdjęcie prawym przyciskiem → Ustaw jako zdjęcie referencyjne, a potem Widok → Widok referencyjny (Shift+R)", + "Right-click a preset to delete it": "Kliknij ustawienie wstępne prawym przyciskiem, aby je usunąć", + "Rights Usage Terms": "Warunki korzystania z praw", + "Rotate": "Obróć", + "Rotate Crop": "Obróć kadr", + "Rotate Crop Aspect": "Obróć proporcje kadru", + "Rotate Left": "Obróć w lewo", + "Rotate Right": "Obróć w prawo", + "Roughness": "Chropowatość", + "Roundness": "Zaokrąglenie", + "Rules": "Reguły", + "SAM 3 download stopped: it resumes where it left off next time.": "Pobieranie SAM 3 zatrzymano; zostanie wznowione przy następnej próbie.", + "Same as raw default": "Tak jak domyślne ustawienia RAW", + "Sample Mask Color": "Pobierz próbkę koloru maski", + "Sample a colour on the photo": "Wybierz próbkę koloru ze zdjęcia", + "Saturation": "Nasycenie", + "Saturation Range": "Zakres nasycenia", + "Saturday": "Sobota", + "Save": "Zapisz", + "Save As…": "Zapisz jako…", + "Save Color Label Set": "Zapisz zestaw etykiet kolorystycznych", + "Save Current Filter as Preset…": "Zapisz bieżący filtr jako ustawienie wstępne…", + "Save Current Keywords as Set…": "Zapisz bieżące słowa kluczowe jako zestaw…", + "Save Export Preset": "Zapisz ustawienie wstępne eksportu", + "Save Filter Preset": "Zapisz ustawienie wstępne filtra", + "Save Keyword Set": "Zapisz zestaw słów kluczowych", + "Save Metadata Preset": "Zapisz ustawienie wstępne metadanych", + "Save Metadata Preset…": "Zapisz ustawienie wstępne metadanych…", + "Save Metadata to File": "Zapisz metadane do pliku", + "Save Point Curve Preset": "Zapisz ustawienie wstępne krzywej punktowej", + "Save Point Curve…": "Zapisz krzywą punktową…", + "Save as Preset": "Zapisz jako ustawienie wstępne", + "Save as Set…": "Zapisz jako zestaw…", + "Save as Smart Album…": "Zapisz jako album inteligentny…", + "Save as set": "Zapisz jako zestaw", + "Save as…": "Zapisz jako…", + "Save preset": "Zapisz ustawienie wstępne", + "Saving Lightroom import index…": "Zapisywanie indeksu importu Lightroom…", + "Scale": "Skala", + "Scan failed": "Skanowanie nie powiodło się", + "Scanning": "Skanowanie", + "Scores stay on the photos: filter or make smart albums with Focus and Best of Similar Shots.": "Oceny pozostają przypisane do zdjęć: możesz filtrować według ostrości i najlepszych podobnych ujęć lub tworzyć albumy inteligentne.", + "Screen": "Ekran", + "Scroll menu down": "Przewiń menu w dół", + "Scroll menu up": "Przewiń menu w górę", + "Search": "Szukaj", + "Search Photos": "Szukaj zdjęć", + "Search commands or keys": "Szukaj poleceń lub klawiszy", + "Search…": "Szukaj…", + "Seasons": "Pory roku", + "Second Window": "Drugie okno", + "Select": "Zaznacz", + "Select All": "Zaznacz wszystko", + "Select Luminance Range": "Zaznacz zakres luminancji", + "Select Mask": "Wybierz maskę", + "Select Photos": "Wybierz zdjęcia", + "Select Spot": "Wybierz punkt", + "Select a photo to edit": "Wybierz zdjęcie do edycji", + "Select by": "Wybierz według", + "Select by Flag, Rating or Label": "Wybierz według flagi, oceny lub etykiety", + "Select photos, then choose View → Survey (N)": "Wybierz zdjęcia, a następnie Widok → Przegląd (N)", + "Select two photos, then choose View → Compare (Shift+C)": "Wybierz dwa zdjęcia, a następnie Widok → Porównaj (Shift+C)", + "Selected": "Zaznaczone", + "Selecting…": "Wybieranie…", + "Selenium Tone": "Tonowanie selenowe", + "Send Feedback…": "Wyślij opinię…", + "Sepia Tone": "Tonowanie sepią", + "Sequence number zero-padded to N digits (1–9)": "Numer kolejny dopełniony zerami do N cyfr (1–9)", + "Sequence number, counting from the start number": "Numer kolejny liczony od numeru początkowego", + "Services": "Usługi", + "Set": "Zestaw", + "Set Album Cover": "Ustaw okładkę albumu", + "Set Before": "Ustaw stan „Przed”", + "Set Color Label": "Ustaw etykietę kolorystyczną", + "Set Crop": "Ustaw kadr", + "Set Develop Values": "Ustaw wartości wywoływania", + "Set Flag": "Ustaw flagę", + "Set Image Zoom and Pan": "Ustaw powiększenie i przesunięcie obrazu", + "Set Keyboard Shortcut": "Ustaw skrót klawiszowy", + "Set Mask Adjustments": "Ustaw korekty maski", + "Set Point Curve": "Ustaw krzywą punktową", + "Set Profile": "Ustaw profil", + "Set Rating": "Ustaw ocenę", + "Set Top of Stack": "Przenieś na wierzch stosu", + "Set as Album Cover": "Ustaw jako okładkę albumu", + "Set as Before": "Ustaw jako stan „Przed”", + "Set as Reference Photo": "Ustaw jako zdjęcie referencyjne", + "Set as Target Album": "Ustaw jako album docelowy", + "Set as Target Album (B adds to it)": "Ustaw jako album docelowy (B dodaje do niego zdjęcia)", + "Set as Top of Stack": "Przenieś na wierzch stosu", + "Set date & time": "Ustaw datę i godzinę", + "Set name": "Ustaw nazwę", + "Set the mix from the photo's colours": "Ustal proporcje na podstawie kolorów zdjęcia", + "Settings": "Ustawienia", + "Settings file problem": "Problem z plikiem ustawień", + "Settings new raw photos start from. Changing them doesn't touch photos already in the library.": "Ustawienia początkowe nowych zdjęć RAW. Zmiana nie wpływa na zdjęcia już znajdujące się w bibliotece.", + "Settings pasted": "Wklejono ustawienia", + "Settings to include": "Ustawienia do uwzględnienia", + "Settings…": "Ustawienia…", + "Shade": "Cień", + "Shadow": "Cień", + "Shadows": "Cienie", + "Shadows Hue": "Odcień cieni", + "Shadows Luminance": "Luminancja cieni", + "Shadows Saturation": "Nasycenie cieni", + "Shadows Tint": "Zabarwienie cieni", + "Shadows split": "Podział cieni", + "Sharpen": "Wyostrz", + "Sharpening": "Wyostrzanie", + "Sharpness": "Wyostrzenie", + "Shift": "Przesunięcie", + "Shift by": "Przesuń o", + "Short Edge": "Krótsza krawędź", + "Show": "Pokaż", + "Show All": "Pokaż wszystko", + "Show Clipping": "Pokaż prześwietlenia i niedoświetlenia", + "Show Deghost Overlay": "Pokaż nakładkę usuwania duchów", + "Show Hidden Local Locations": "Pokaż ukryte lokalizacje lokalne", + "Show Luminance Map": "Pokaż mapę luminancji", + "Show Mask": "Pokaż maskę", + "Show Mask Overlay": "Pokaż nakładkę maski", + "Show Mask Pins": "Pokaż znaczniki masek", + "Show Original": "Pokaż oryginał", + "Show Photo Counts": "Pokaż liczbę zdjęć", + "Show Photos with Keyword": "Pokaż zdjęcia ze słowem kluczowym", + "Show Pins": "Pokaż znaczniki", + "Show Source": "Pokaż źródło", + "Show Thumbnails": "Pokaż miniatury", + "Show commands without a shortcut": "Pokaż polecenia bez skrótu", + "Show destination gamut warning": "Pokaż ostrzeżenie o gamucie docelowym", + "Show display gamut warning": "Pokaż ostrzeżenie o gamucie wyświetlacza", + "Show file names": "Pokaż nazwy plików", + "Show in Explorer": "Pokaż w Eksploratorze", + "Show in File Manager": "Pokaż w menedżerze plików", + "Show in Finder": "Pokaż w Finderze", + "Show on Map": "Pokaż na mapie", + "Show overlay (O)": "Pokaż nakładkę (O)", + "Show ratings, flags and edit badges": "Pokaż oceny, flagi i znaczniki edycji", + "Show the Navigator while zoomed in": "Pokaż Nawigator podczas powiększenia", + "Show the template tags; click one to insert it": "Pokaż znaczniki szablonu; kliknij znacznik, aby go wstawić", + "Show/Hide Mask": "Pokaż/ukryj maskę", + "Show/hide My Photos panel": "Pokaż/ukryj panel Moje zdjęcia", + "Shutter speed": "Czas naświetlania", + "Side panels": "Panele boczne", + "Sidecar names": "Nazwy plików pomocniczych", + "Size": "Rozmiar", + "Size (% of short edge)": "Rozmiar (% krótszej krawędzi)", + "Size limit": "Limit rozmiaru", + "Skip": "Pomiń", + "Sky": "Niebo", + "Slideshow": "Pokaz slajdów", + "Slideshow paused": "Pokaz slajdów wstrzymany", + "Slideshow resumed": "Pokaz slajdów wznowiony", + "Slideshow · Space pauses · Esc ends": "Pokaz slajdów · Spacja: pauza · Esc: koniec", + "Slideshow, second window, All Metadata, System Info.": "Pokaz slajdów, drugie okno, wszystkie metadane, informacje o systemie.", + "Smart Album": "Inteligentny album", + "Smart Album Rule Fields": "Pola reguł albumu inteligentnego", + "Smart Preview Status": "Stan podglądu inteligentnego", + "Smart Previews Location": "Lokalizacja podglądów inteligentnych", + "Smart albums with a rule editor: match all / any / none, nested groups, 26 fields.": "Albumy inteligentne z edytorem reguł: dopasuj wszystkie / dowolne / żadne, grupy zagnieżdżone, 26 pól.", + "Smart preview available": "Dostępny podgląd inteligentny", + "Smart previews": "Podglądy inteligentne", + "Smart previews folder changed": "Zmieniono folder podglądów inteligentnych", + "Smoothness": "Wygładzenie", + "Soft B&W": "Delikatna czerń i biel", + "Soft Pastel": "Delikatne pastele", + "Soft Proofing": "Próba koloru", + "Soft Skin": "Wygładzanie skóry", + "Sort": "Sortuj", + "Sort by": "Sortuj według", + "Source": "Źródło", + "Source code on GitHub": "Kod źródłowy na GitHubie", + "Spherical": "Sferyczny", + "Spiral": "Spirala", + "Split Stack": "Rozdziel stos", + "Spring Fresh": "Wiosenna świeżość", + "Square Grid": "Siatka kwadratowa", + "Square Grid (G toggles)": "Siatka kwadratowa (G przełącza)", + "Square Grid: show file names and formats": "Siatka kwadratowa: pokaż nazwy i formaty plików", + "Stack": "Stos", + "Stack photos taken within this time of each other:": "Umieszczaj na jednym stosie zdjęcia wykonane w odstępie nie większym niż:", + "Stacks": "Stosy", + "Standard": "Standardowy", + "Star rating (0–5)": "Ocena gwiazdkowa (0–5)", + "Start at": "Zacznij od", + "Start number": "Numer początkowy", + "Starting": "Uruchamianie", + "Startup": "Uruchamianie programu", + "State / Province": "Stan / województwo", + "Stop": "Zatrzymaj", + "Stop Building Previews": "Zatrzymaj tworzenie podglądów", + "Stop Using as Target Album": "Przestań używać jako albumu docelowego", + "Stopping…": "Zatrzymywanie…", + "Straighten": "Wyprostuj", + "Straighten Tool": "Narzędzie prostowania", + "Strong Contrast": "Silny kontrast", + "Style": "Styl", + "Subfolder": "Podfolder", + "Subfolder name": "Nazwa podfolderu", + "Subject": "Obiekt", + "Subtract": "Odejmij", + "Subtract from this mask": "Odejmij od tej maski", + "Suggestions": "Sugestie", + "Summer Haze": "Letnia mgiełka", + "Sunday": "Niedziela", + "Survey": "Przegląd", + "Survey (N)": "Przegląd (N)", + "Swap": "Zamień", + "Swap Aspect (X)": "Zamień proporcje (X)", + "Swap Before and After": "Zamień „Przed” i „Po”", + "Swap Before and After Settings": "Zamień ustawienia „Przed” i „Po”", + "Swap Compare Photos": "Zamień porównywane zdjęcia", + "Switch light / dark theme": "Przełącz jasny/ciemny motyw", + "Sync Settings": "Synchronizuj ustawienia", + "System": "System", + "System Info": "Informacje o systemie", + "System Info…": "Informacje o systemie…", + "System default": "Domyślne ustawienie systemowe", + "Table": "Tabela", + "Tags": "Tagi", + "Take a snapshot": "Utwórz migawkę", + "Targeted Adjustment": "Korekta punktowa", + "Targeted adjustment: drag up/down on the photo to adjust the colours there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdujące się tam kolory", + "Targeted adjustment: drag up/down on the photo to adjust the curve there": "Korekta punktowa: przeciągnij w górę lub w dół na zdjęciu, aby dostosować znajdującą się tam krzywą", + "Teal & Amber": "Turkus i bursztyn", + "Teal & Orange": "Turkus i pomarańcz", + "Temp": "Temperatura", + "Temperature": "Temperatura", + "Template": "Szablon", + "Temporary session — your library isn't open, and nothing you do here is saved.": "Sesja tymczasowa — biblioteka nie jest otwarta, a żadne zmiany nie zostaną zapisane.", + "Text": "Tekst", + "Text direction": "Kierunek tekstu", + "Texture": "Tekstura", + "That includes the photos in the folders inside it.": "Dotyczy to również zdjęć w znajdujących się wewnątrz folderach.", + "That key can't be used as a shortcut": "Tego klawisza nie można użyć jako skrótu", + "The SAM 3 model is installed: Object and Describe masks are ready.": "Model SAM 3 jest zainstalowany: maski Obiekt i Opis są gotowe.", + "The download didn't work:": "Pobieranie nie powiodło się:", + "The number at the end of the original name (IMG_0042 → 0042)": "Liczba na końcu oryginalnej nazwy (IMG_0042 → 0042)", + "The original extension is always added; {ext} only puts it inside the name as well.": "Oryginalne rozszerzenie jest zawsze dodawane; {ext} umieszcza je również wewnątrz nazwy.", + "The original files, unchanged, each with an XMP sidecar holding its edits.": "Oryginalne, niezmienione pliki, każdy z plikiem pomocniczym XMP zawierającym zapisane edycje.", + "The photo has these settings": "Zdjęcie ma następujące ustawienia", + "Theme": "Motyw", + "They can be restored from Recently Deleted until it is emptied.": "Można je przywrócić z Ostatnio usuniętych, dopóki ten folder nie zostanie opróżniony.", + "They move to Recently Deleted and can be restored; no file on disk is touched.": "Trafią do Ostatnio usuniętych i będzie można je przywrócić; żadne pliki na dysku nie zostaną zmienione.", + "Thirds": "Trójpodział", + "This Computer": "Ten komputer", + "This folder is not available (drive disconnected?). Smart previews are not built or used until it is back or you choose another folder.": "Ten folder jest niedostępny (odłączony dysk?). Podglądy inteligentne nie będą tworzone ani używane, dopóki folder nie wróci lub nie wybierzesz innego.", + "This photo can't be opened": "Nie można otworzyć tego zdjęcia", + "Threshold": "Próg", + "Thumbnail cache": "Pamięć podręczna miniatur", + "Thursday": "Czwartek", + "Time zone": "Strefa czasowa", + "Tint": "Zabarwienie", + "Title": "Tytuł", + "Title metadata": "Metadane tytułu", + "Toggle Keyword from Set": "Przełącz słowo kluczowe z zestawu", + "Toggle Mask": "Przełącz maskę", + "Toggle Section": "Przełącz sekcję", + "Toggle Zoom": "Przełącz powiększenie", + "Tone Curve": "Krzywa tonalna", + "Tools": "Narzędzia", + "Top Level": "Poziom główny", + "Transfer": "Przenieś", + "Treatment": "Sposób obróbki", + "Triangle": "Trójkąt", + "Try Again": "Spróbuj ponownie", + "Try Saving Again": "Spróbuj ponownie zapisać", + "Tuesday": "Wtorek", + "Tungsten": "Światło żarowe", + "Turn Off": "Wyłącz", + "Turn on Include subfolders, or pick another folder under Local": "Włącz „Uwzględnij podfoldery” lub wybierz inny folder w obszarze Lokalne", + "Type": "Typ", + "Uncheck All": "Odznacz wszystko", + "Uncheck the files of this type": "Odznacz pliki tego typu", + "Undo": "Cofnij", + "Unedited": "Bez edycji", + "Unedited raws": "Nieedytowane pliki RAW", + "Unflag": "Usuń flagę", + "Unflagged": "Bez flagi", + "Ungroup Stack": "Rozgrupuj stos", + "Unknown Date": "Nieznana data", + "Unknown tags stay as typed — check the preview for a {typo}.": "Nieznane znaczniki pozostają bez zmian — sprawdź podgląd pod kątem {typo}.", + "Unrated": "Bez oceny", + "Unreadable": "Nieczytelny", + "Update": "Aktualizuj", + "Update Mask Shape": "Zaktualizuj kształt maski", + "Update Preset with Current Settings": "Zaktualizuj ustawienie wstępne bieżącymi ustawieniami", + "Update Rules from Current Filter": "Zaktualizuj reguły według bieżącego filtra", + "Update Version": "Zaktualizuj wersję", + "Update Version with Current Settings": "Zaktualizuj wersję bieżącymi ustawieniami", + "Update with Current Settings": "Zaktualizuj bieżącymi ustawieniami", + "Updated the edits saved in the external editor": "Zaktualizowano edycje zapisane w edytorze zewnętrznym", + "Upright": "Korekcja perspektywy", + "Urban": "Miejski", + "Use Keyword Set": "Użyj zestawu słów kluczowych", + "Use Library Folder": "Użyj folderu biblioteki", + "Use camera-specific defaults": "Użyj domyślnych ustawień aparatu", + "Use the GPU for rendering": "Użyj procesora graficznego do renderowania", + "User Presets": "Ustawienia wstępne użytkownika", + "Username": "Nazwa użytkownika", + "Value": "Wartość", + "Variance": "Wariancja", + "Version": "Wersja", + "Version name": "Nazwa wersji", + "Versions": "Wersje", + "Versions (Shift+V)": "Wersje (Shift+V)", + "Vertical": "Pionowo", + "Vertical text": "Tekst pionowy", + "Vibrance": "Dynamika", + "Videos": "Wideo", + "View": "Widok", + "Viewing and sharing": "Wyświetlanie i udostępnianie", + "Vignette": "Winieta", + "Vignetting": "Winietowanie", + "Vintage": "Styl retro", + "Virtual Copy": "Kopia wirtualna", + "Visible": "Widoczne", + "Visualize Spots": "Wizualizuj plamki", + "Visualize Spots (A)": "Wizualizuj plamki (A)", + "Visualize range": "Pokaż zakres", + "Vivid": "Żywe kolory", + "Vivid Pop": "Wyraziste kolory", + "Warm Film": "Ciepły film", + "Warm Glow": "Ciepła poświata", + "Warm Print": "Ciepły wydruk", + "Warm Table": "Ciepła paleta", + "Watched folder": "Folder obserwowany", + "Watermark": "Znak wodny", + "Wednesday": "Środa", + "What's New": "Co nowego", + "When rated or hovered": "Po ocenieniu lub wskazaniu kursorem", + "White Balance": "Balans bieli", + "White Balance Selector": "Próbnik balansu bieli", + "White Balance Selector (W)": "Próbnik balansu bieli (W)", + "Whites": "Biele", + "Width": "Szerokość", + "Width (% of photo)": "Szerokość (% zdjęcia)", + "Width (px)": "Szerokość (px)", + "Width × Height": "Szerokość × wysokość", + "Will be replaced": "Zostanie zastąpiony", + "Will be restored": "Zostanie przywrócony", + "Window": "Okno", + "Winter Blue": "Zimowy błękit", + "XMP Sidecar Preferences": "Preferencje plików pomocniczych XMP", + "XMP sidecars": "Pliki pomocnicze XMP", + "Year": "Rok", + "Yellow": "Żółty", + "Yellow Hue": "Odcień żółci", + "Yellow Luminance": "Luminancja żółci", + "Yellow Saturation": "Nasycenie żółci", + "Yellow label": "Żółta etykieta", + "Yes": "Tak", + "You can close this: the download continues, and resumes if interrupted.": "Możesz zamknąć to okno: pobieranie będzie kontynuowane i wznowi się po przerwaniu.", + "Your Name": "Twoje imię i nazwisko", + "Your library couldn't be opened": "Nie można otworzyć biblioteki", + "Zoom": "Powiększenie", + "Zoom 100%": "Powiększenie 100%", + "Zoom In": "Powiększ", + "Zoom Out": "Pomniejsz", + "Zoom to Fit": "Dopasuj powiększenie", + "Zoom to page level": "Dopasuj powiększenie do strony", + "a literal %": "Dosłowny znak %", + "a removed photo": "Usunięte zdjęcie", + "all": "wszystkie", + "and": "i", + "any": "dowolne", + "are empty": "Są puste", + "aren't empty": "Nie są puste", + "blue": "Niebieski", + "comma, separated": "Rozdzielone przecinkami", + "contains": "Zawiera", + "copyrighted": "Chronione prawem autorskim", + "day 01–31": "Dzień 01–31", + "days": "dni", + "discarding smart previews": "Odrzucanie podglądów inteligentnych", + "doesn't contain": "Nie zawiera", + "e.g. sky · the red car · car, road": "np. niebo · czerwony samochód · samochód, droga", + "ends with": "Kończy się na", + "false": "fałsz", + "green": "zielony", + "hour 00–23": "Godzina 00–23", + "hours": "godziny", + "image": "Obraz", + "includes the folders inside it": "Uwzględnia foldery wewnątrz", + "is": "jest", + "is <": "Jest <", + "is >": "Jest >", + "is after": "Jest po", + "is before": "Jest przed", + "is between": "Jest pomiędzy", + "is empty": "jest puste", + "is in the last": "Jest w ciągu ostatnich", + "is unknown": "Jest nieznany", + "is ≤": "Jest ≤", + "is ≥": "Jest ≥", + "isn't": "Nie jest", + "isn't empty": "nie jest puste", + "isn't in the last": "Nie występuje w ciągu ostatnich", + "keep names — or e.g. {date}_{seq:3}": "Zachowaj nazwy — lub np. {date}_{seq:3}", + "latitude, longitude": "Szerokość i długość geograficzna", + "library locked: the lock is the operating system's and goes away with the program.": "Biblioteka zablokowana: blokadę nakłada system operacyjny i znika ona po zamknięciu programu.", + "memory only": "Tylko w pamięci", + "minute": "minuta", + "month 01–12": "Miesiąc 01–12", + "months": "miesiące", + "none": "brak", + "none (CPU rendering)": "Brak (renderowanie przez CPU)", + "none of": "Żaden z", + "not available here": "Niedostępne tutaj", + "of the following rules:": "Następujących reguł:", + "or": "lub", + "pick": "Wybierz", + "previews": "Podglądy", + "publicDomain": "Domena publiczna", + "purple": "Fioletowy", + "raw": "RAW", + "red": "czerwony", + "saved in": "Zapisano w", + "second": "Sekunda", + "smart previews": "Podglądy inteligentne", + "the photos in view": "Zdjęcia w widoku", + "the selected photos": "Zaznaczone zdjęcia", + "this photo": "To zdjęcie", + "true": "prawda", + "unknown": "nieznany", + "volumes (Linux, case-sensitive APFS) `img_1.JPG` next to `IMG_1.JPG` is a different photo and the renamed one gets": "Zdjęcie", + "weeks": "tygodnie", + "year, 2 digits": "Rok, 2 cyfry", + "year, 4 digits": "Rok, 4 cyfry", + "years": "lata", + "yellow": "żółty", + "{date} is the capture time; a photo without one uses the time it was imported.": "{date} oznacza datę wykonania; gdy jej brak, używana jest data importu.", + "© 2026 Your Name": "© 2026 Twoje imię i nazwisko", + "© Your Name": "© Twoje imię i nazwisko", + "− harder border · + softer border": "− ostrzejsza krawędź · + bardziej miękka krawędź", + "≤ at most": "≤ co najwyżej", + "≥ at least": "≥ co najmniej", + "⌘-drag to straighten, ⇧G Guided Upright, a grid while transforming.": "⌘ i przeciągnij, aby wyprostować, ⇧G: korekcja perspektywy z liniami pomocniczymi; podczas przekształcania wyświetlana jest siatka.", + "Any camera": "Dowolny aparat", + "Any lens": "Dowolny obiektyw", + "Any keyword": "Dowolne słowo kluczowe", + "Label Set": "Zestaw etykiet", + ", picked": ", wybrano", + ", rejected": ", odrzucono", + "Preview only: editing the camera's embedded JPEG": "Tylko podgląd: edycja JPEG-a osadzonego przez aparat", + "Captured from": "Wykonane od", + "Captured until": "Wykonane do", + "Only": "Tylko", + "How to install the model": "Jak zainstalować model", + "Install the SAM 3 Model": "Zainstaluj model SAM 3", + "Open Log Folder": "Otwórz folder dzienników", + "Back to this person": "Wróć do tej osoby", + "AI Denoise": "Odszumianie AI", + "AI Denoise Model": "Model odszumiania AI", + "AI Denoise is off: press Use on a model.": "Odszumianie AI jest wyłączone: kliknij „Użyj” przy modelu.", + "AI Denoise needs a model. Install one in Settings.": "Odszumianie AI wymaga modelu. Zainstaluj go w Ustawieniach.", + "Accept & Download": "Akceptuj i pobierz", + "Already installed. Remove it before installing a replacement.": "Już zainstalowano. Usuń obecny model przed zainstalowaniem nowego.", + "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.": "Tryb automatyczny używa karty graficznej, jeśli jest szybsza od procesora. Przy pierwszym przetwarzaniu zdjęcia LightCraft mierzy wydajność obu urządzeń.", + "Cache": "Pamięć podręczna", + "Checking and installing the model…": "Sprawdzanie i instalowanie modelu…", + "Checking…": "Sprawdzanie…", + "Clear cache": "Wyczyść pamięć podręczną", + "Denoise Models": "Modele odszumiania", + "Denoise Settings": "Ustawienia odszumiania", + "Denoise all photos with an Amount": "Odszum wszystkie zdjęcia z ustawioną intensywnością", + "Downloaded and checked. Installing…": "Pobrano i sprawdzono. Instalowanie…", + "Failed its last test": "Nie przeszedł ostatniego testu", + "Graphics card": "Karta graficzna", + "I have read these terms and accept them for my own use": "Przeczytałem warunki i akceptuję je na własny użytek", + "Inspect Denoise Model": "Sprawdź model odszumiania", + "Install Denoise Model": "Zainstaluj model odszumiania", + "Install from file…": "Zainstaluj z pliku…", + "Install your own ONNX model with denoise-model.json beside it. This build offers no model downloads.": "Zainstaluj własny model ONNX z plikiem denoise-model.json w tym samym katalogu. W tej kompilacji nie można pobierać modeli.", + "Its denoised picture is not made yet, and pictures are not made on their own (Settings, AI Denoise).": "Nie utworzono jeszcze odszumionego obrazu, a obrazy nie są przetwarzane automatycznie (Ustawienia, Odszumianie AI).", + "LightCraft does not know this model, so it assumed:": "LightCraft nie rozpoznaje tego modelu, dlatego przyjęto następujące założenia:", + "Limit": "Limit", + "Make it now": "Utwórz teraz", + "Make the denoised picture of the photos I am looking at": "Utwórz odszumione wersje oglądanych zdjęć", + "Next up for AI Denoise…": "Następne w kolejce do odszumiania AI…", + "Nothing is fetched until you accept. The model is kept on this computer only; LightCraft never uploads or shares it.": "Nic nie zostanie pobrane bez Twojej zgody. Model jest przechowywany tylko na tym komputerze; LightCraft go nie przesyła ani nie udostępnia.", + "Only photos whose AI Denoise amount is above 0 are made. Exports always make theirs if it is missing.": "Przetwarzane są tylko zdjęcia z intensywnością odszumiania AI większą od zera. Podczas eksportu brakujące wersje są zawsze tworzone.", + "Open page": "Otwórz stronę", + "Open the model's page": "Otwórz stronę modelu", + "Opens the model's own page in your browser, to read about it or get the file yourself.": "Otwiera stronę modelu w przeglądarce, gdzie można przeczytać o nim lub samodzielnie pobrać plik.", + "Processor": "Procesor", + "Set up AI Denoise…": "Skonfiguruj odszumianie AI…", + "Speed": "Szybkość", + "Starting…": "Uruchamianie…", + "Tested and works on this computer": "Przetestowano — działa na tym komputerze", + "The denoise model is installed and in use: the AI Denoise Amount slider under Detail now works": "Model odszumiania jest zainstalowany i używany: suwak intensywności odszumiania AI w panelu Szczegóły jest teraz aktywny", + "The graphics card is set up when the next photo is made.": "Karta graficzna zostanie skonfigurowana podczas przetwarzania następnego zdjęcia.", + "The model is kept on this computer only. LightCraft never uploads or shares it.": "Model jest przechowywany wyłącznie na tym komputerze. LightCraft nigdy go nie przesyła ani nie udostępnia.", + "The processor does the work.": "Przetwarzanie wykonuje procesor.", + "There is no model file to install": "Brak pliku modelu do zainstalowania", + "This build cannot run denoise models.": "Ta kompilacja nie obsługuje modeli odszumiania.", + "This build has nowhere to keep denoise models, so AI Denoise is not available. (The desktop app has.)": "Ta kompilacja nie ma miejsca na przechowywanie modeli odszumiania, więc odszumianie AI jest niedostępne (dostępne w aplikacji komputerowej).", + "This file cannot be used yet": "Nie można jeszcze użyć tego pliku", + "Tick the box to accept the model's terms first": "Najpierw zaznacz pole akceptacji warunków modelu", + "Try again": "Spróbuj ponownie", + "Try the graphics card again": "Spróbuj ponownie użyć karty graficznej", + "Turn off": "Wyłącz", + "Unknown licence": "Nieznana licencja", + "Use": "Użyj", + "Waiting to start…": "Oczekiwanie na uruchomienie…", + "Works on raw files from cameras with a standard (Bayer) sensor. Other files keep their normal noise reduction.": "Działa na plikach RAW z aparatów z typową matrycą Bayera. Pozostałe pliki zachowują zwykłe odszumianie.", + "cached picture": "obraz w pamięci podręcznej", + "cached pictures": "obrazy w pamięci podręcznej", + "commercial use allowed": "dozwolony użytek komercyjny", + "in use": "w użyciu", + "installed": "zainstalowano", + "makes AI Denoise available": "włącza odszumianie AI", + "non-commercial use only": "wyłącznie do użytku niekomercyjnego", + "photo has its denoised picture.": "zdjęcie ma już odszumioną wersję.", + "photo waiting": "zdjęcie oczekuje", + "photos have their denoised picture.": "zdjęcia mają już odszumione wersje.", + "photos waiting": "zdjęć oczekuje", + "starts using it for AI Denoise": "rozpoczyna używanie modelu do odszumiania AI", + "terms unclear": "niejasne warunki", + "the graphics card": "karta graficzna", + "the graphics card is not used": "karta graficzna nie jest używana", + "Cancel Denoise": "Anuluj odszumianie", + "Cancel Denoise Model Download": "Anuluj pobieranie modelu odszumiania", + "Choose Denoise Model": "Wybierz model odszumiania", + "Clear Denoise Cache": "Wyczyść pamięć podręczną odszumiania", + "Denoise Model Downloads": "Pobieranie modeli odszumiania", + "Denoise Photos": "Odszum zdjęcia", + "Denoise Pump": "Kolejka odszumiania", + "Denoise Status": "Stan odszumiania", + "Download Denoise Model": "Pobierz model odszumiania", + "Remove Denoise Model": "Usuń model odszumiania", + "Test Denoise Model": "Przetestuj model odszumiania", + "AI Denoise is enabled on the requesting photo": "Odszumianie AI włączono dla wybranego zdjęcia", + "After installation, AI Denoise will turn on for the requesting photo.": "Po instalacji odszumianie AI zostanie włączone dla wybranego zdjęcia.", + "Cancel pending action": "Anuluj oczekującą operację", + "Install a denoise model to enable AI Denoise on this photo": "Zainstaluj model odszumiania, aby włączyć odszumianie AI dla tego zdjęcia", + "Pending photo action cancelled": "Anulowano oczekującą operację", + "Pending photo action cancelled because its library or photo changed": "Anulowano oczekującą operację, ponieważ zmieniła się biblioteka lub zdjęcie", + "ONNX model": "Model ONNX", + "commercial use subject to GPL conditions": "użytek komercyjny na warunkach licencji GPL", + "Decrease Rating": "Zmniejsz ocenę", + "Increase Rating": "Zwiększ ocenę", + "Click to type a value, drag to change it": "Kliknij, aby wpisać wartość, przeciągnij, aby ją zmienić", + "Angle": "Kąt", + "Video": "Wideo", + "Copyrighted": "Chronione prawem autorskim", + "Public Domain": "Domena publiczna", + "Unknown": "Nieznany", + "Landscape (wide)": "Poziomo (szeroko)", + "Portrait (tall)": "Pionowo (wysoko)", + "Square": "Kwadrat", + "In Color": "Kolorowe", + "No Label": "Bez etykiety", + "Choose an album…": "Wybierz album…", + "No albums yet": "Brak albumów", + "Fix the marked rules to save this album.": "Popraw oznaczone reguły, aby zapisać album.", + "Some rules need fixing (Edit Smart Album…):": "Niektóre reguły wymagają poprawienia (Edytuj album inteligentny…):", + "isn't a field rules know": "nie jest obsługiwanym polem reguły", + "can't be tested that way": "nie można sprawdzić w ten sposób", + "is yes or no": "wymaga odpowiedzi tak lub nie", + "needs a number": "wymaga liczby", + "needs a time like 1/250 or 2": "wymaga czasu, np. 1/250 lub 2", + "needs a date like 2026, 2026-08 or 2026-08-14": "wymaga daty, np. 2026, 2026-08 lub 2026-08-14", + "has the first date after the second": "pierwsza data jest późniejsza od drugiej", + "needs two values": "wymaga dwóch wartości", + "needs a number more than 0": "wymaga liczby większej od zera", + "needs hours, days, weeks, months or years": "wymaga godzin, dni, tygodni, miesięcy lub lat", + "isn't one of the choices": "nie jest jedną z dostępnych opcji", + "needs something to look for (or use “is empty”)": "wymaga tekstu do wyszukania (lub opcji „jest puste”)", + "needs something to look for (or use “isn't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie jest puste”)", + "needs text": "wymaga tekstu", + "can't match any rating from 0 to 5": "nie może odpowiadać żadnej ocenie od 0 do 5", + "needs an album": "wymaga albumu", + "names an album that no longer exists": "wskazuje album, który już nie istnieje", + "can't test a folder; choose an album in it": "nie można sprawdzić folderu; wybierz album, który się w nim znajduje", + "This group is empty: add a rule or remove it.": "Ta grupa jest pusta: dodaj regułę albo usuń grupę.", + "needs something to look for (or use “are empty”)": "wymaga tekstu do wyszukania (lub opcji „są puste”)", + "needs something to look for (or use “aren't empty”)": "wymaga tekstu do wyszukania (lub opcji „nie są puste”)", + "Mo": "Pn", + "Tu": "Wt", + "We": "Śr", + "Th": "Cz", + "Fr": "Pt", + "Sa": "So", + "Su": "Nd", + "Pick a date": "Wybierz datę", + "would make this album include itself through other smart albums": "spowodowałoby włączenie tego albumu do niego samego przez inne albumy inteligentne", + "Search albums": "Wyszukaj albumy", + "No albums match": "Brak pasujących albumów", + "This is the album you're editing": "To album, który edytujesz", + "It tests this album, so testing it back would loop": "Sprawdza ten album, więc odwrotne sprawdzenie utworzyłoby pętlę", + "Copy Name": "Kopiuj nazwę", + "In a Stack": "W stosie", + "File Extension": "Rozszerzenie pliku", + "Video Duration": "Czas trwania filmu", + "Keyword Count": "Liczba słów kluczowych", + "People Count": "Liczba osób", + "Shutter Speed": "Czas migawki", + "Cropped": "Przycięte", + "Keywords & People": "Słowa kluczowe i osoby", + "Description": "Opis", + "Camera Info": "Informacje o aparacie", + "Develop": "Wywoływanie", + "Keyword List": "Lista słów kluczowych", + "Filter Keywords": "Filtruj słowa kluczowe", + "No keywords yet": "Brak słów kluczowych", + "No keywords match": "Brak pasujących słów kluczowych", + "Keyword Name": "Nazwa słowa kluczowego", + "Synonyms": "Synonimy", + "Separated by commas": "Oddzielone przecinkami", + "Keyword Tag Options": "Opcje etykiety słowa kluczowego", + "Include on Export": "Uwzględnij przy eksporcie", + "Export Containing Keywords": "Eksportuj nadrzędne słowa kluczowe", + "Export Synonyms": "Eksportuj synonimy", + "Create Keyword Tag": "Utwórz słowo kluczowe", + "Edit Keyword Tag": "Edytuj słowo kluczowe", + "Create": "Utwórz", + "No photo has it; the keywords below it go too.": "Żadne zdjęcie go nie używa; podrzędne słowa kluczowe również zostaną usunięte.", + "Edit Keyword Tag…": "Edytuj słowo kluczowe…", + "Put New Keywords Inside This Keyword": "Umieszczaj nowe słowa kluczowe wewnątrz tego", + "Purge Unused Keywords": "Usuń nieużywane słowa kluczowe", + "Delete Keyword…": "Usuń słowo kluczowe…", + "Drop here for the top level": "Upuść tutaj, aby umieścić na najwyższym poziomie", + "Their photos and the keywords below them come together under one keyword.": "Zdjęcia i podrzędne słowa kluczowe zostaną zebrane pod jednym słowem kluczowym.", + "Select photos to give them keywords.": "Wybierz zdjęcia, którym chcesz przypisać słowa kluczowe.", + "Add to All Selected Photos": "Dodaj do wszystkich wybranych zdjęć", + "Will Export": "Zostanie wyeksportowane", + "No keywords are exported": "Nie zostaną wyeksportowane żadne słowa kluczowe", + "& Containing": "i nadrzędne", + "Edit Set…": "Edytuj zestaw…", + "Edit Keyword Set": "Edytuj zestaw słów kluczowych", + "Keywords (⌥1–⌥9)": "Słowa kluczowe (⌥1–⌥9)", + "A keyword set needs a name": "Zestaw słów kluczowych musi mieć nazwę", + "Save as a new set": "Zapisz jako nowy zestaw", + "Recent Keywords is built in: choose another name": "Ostatnie słowa kluczowe to zestaw wbudowany: wybierz inną nazwę", + "Import Keywords…": "Importuj słowa kluczowe…", + "Export Keywords…": "Eksportuj słowa kluczowe…", + "Import Keywords": "Importuj słowa kluczowe", + "Export Keywords": "Eksportuj słowa kluczowe", + "Keyword Lists": "Listy słów kluczowych", + "Synchronize Folder…": "Synchronizuj folder…", + "Synchronize Folder": "Synchronizuj folder", + "Synchronize": "Synchronizuj", + "Scanning…": "Skanowanie…", + "Find photos added to or missing from this folder on disk, and XMP sidecars changed by other apps": "Znajdź zdjęcia dodane do folderu lub brakujące na dysku oraz pliki XMP zmienione przez inne aplikacje", + "Bring the library up to date with this folder and the folders inside it:": "Zaktualizuj bibliotekę na podstawie tego folderu i wszystkich jego podfolderów:", + "Import new photos": "Importuj nowe zdjęcia", + "Remove missing photos from the library": "Usuń brakujące zdjęcia z biblioteki", + "Read metadata updates from XMP sidecars": "Odczytaj aktualizacje metadanych z plików XMP", + "The library is up to date with this folder.": "Biblioteka jest aktualna względem tego folderu.", + "Missing photos move to Recently Deleted and can be restored; no file on disk is touched.": "Brakujące zdjęcia trafiają do Ostatnio usuniętych i można je przywrócić; pliki na dysku pozostają bez zmian.", + "Reading a sidecar replaces the photo's rating, labels, metadata and edits with what it says.": "Odczyt pliku XMP zastępuje ocenę, etykiety, metadane i edycje zdjęcia jego zawartością.", + "Skipped: files already in the library elsewhere": "Pominięto: pliki znajdujące się już w bibliotece w innym miejscu", + "Skipped: files that can't be read": "Pominięto: pliki, których nie można odczytać", + "This folder isn't there: it was moved or renamed, or its disk isn't connected.": "Ten folder nie istnieje: przeniesiono go, zmieniono nazwę albo odłączono dysk.", + "Relink photos whose file was renamed or moved": "Połącz ponownie zdjęcia, których pliki przeniesiono lub przemianowano", + "A folder is already being synchronized": "Trwa już synchronizacja folderu", + "Synchronizing…": "Synchronizowanie…", + "Relinked": "Połączono ponownie", + "Removed": "Usunięto", + "Metadata read": "Odczytano metadane", + "Failed": "Niepowodzenie", + "Stopped": "Zatrzymano", + "Stopped by an error (see the log)": "Zatrzymano z powodu błędu (zobacz dziennik)", + "Not saved": "Nie zapisano", + "A folder is being synchronized": "Trwa synchronizacja folderu", + "Wait for the import to finish": "Poczekaj na zakończenie importu", + "Show less": "Pokaż mniej", + "Quit while tasks are running?": "Zakończyć, gdy trwają zadania?", + "Exporting contact sheet": "Eksportowanie arkusza stykowego", + "Reading folder": "Odczytywanie folderu", + "Scanning folder": "Skanowanie folderu", + "Building previews": "Tworzenie podglądów", + "Building smart previews": "Tworzenie podglądów inteligentnych", + "Discarding smart previews": "Odrzucanie podglądów inteligentnych", + "Importing Lightroom catalog": "Importowanie katalogu Lightroom", + "Reading Lightroom catalog": "Odczytywanie katalogu Lightroom", + "Merging": "Scalanie", + "Downloading model": "Pobieranie modelu", + "Finding faces": "Wyszukiwanie twarzy", + "Synchronizing folder": "Synchronizowanie folderu", + "Looking for changes": "Wyszukiwanie zmian", + "\"saved in memory but not written to disk\", the top bar shows a warning, and LightCraft keeps retrying until the": "„zapisano w pamięci, ale nie na dysku”, na górnym pasku pojawia się ostrzeżenie, a LightCraft ponawia próby, dopóki", + "#106): a DNG is checked against the raw data, written to a temp file, synced and read back before it gets its": "#106): plik DNG jest porównywany z danymi RAW, zapisywany do pliku tymczasowego, synchronizowany i odczytywany ponownie, zanim", + "(`gpuReason`, `gpuFallback`), Help ▸ System Info and Settings ▸ Performance say why the GPU isn't": "(`gpuReason`, `gpuFallback`), Pomoc → Informacje o systemie i Ustawienia → Wydajność wyjaśniają, dlaczego procesor graficzny nie jest", + "(e.g. D750, D780, D850, D7500, Z 50) no longer render nearly black or with crushed shadows: their black level was": "(np. D750, D780, D850, D7500, Z 50) nie wyświetlają się już niemal czarne ani z utraconymi szczegółami w cieniach: ich poziom czerni był", + "(issue #94): an HTTP request from a web page can no longer carry a command in its body. Lines are capped at": "(zgłoszenie #94): żądanie HTTP ze strony internetowej nie może już przekazać polecenia w treści. Długość wierszy ograniczono do", + "123 on studio-mac)\") instead of letting both write and silently drop each other's edits. A crash never leaves the": "123 na studio-mac”) zamiast pozwalać obu procesom zapisywać i po cichu gubić wzajemne zmiany. Awaria nigdy nie pozostawia", + "1620 × 1080 preview, and import with their metadata: capture time, camera, lens, exposure, GPS and XMP. Their raw": "podgląd 1620 × 1080 i są importowane wraz z metadanymi: datą wykonania, aparatem, obiektywem, ekspozycją, GPS i XMP. Ich dane RAW", + "2026-04 or 2026-04-12": "2026-04 lub 2026-04-12", + "23 new built-in presets: Portrait, Landscape, Urban, Food, Seasons, Vintage and B&W toners.": "23 nowe wbudowane ustawienia predefiniowane: Portret, Krajobraz, Miasto, Jedzenie, Pory roku, Vintage oraz tonowanie czarno-białe.", + "4 MiB and connections at 16.": "4 MiB, a liczbę połączeń do 16.", + "630 drivers): on Windows the window and GPU rendering use DirectX 12 only and never load the Vulkan driver unless": "630): w systemie Windows okno i renderowanie GPU korzystają wyłącznie z DirectX 12 i nigdy nie ładują sterownika Vulkan, chyba że", + "A change that can't be saved to disk (full or unplugged drive) is no longer silent: the command reports": "Zmiana, której nie można zapisać na dysku (pełny lub odłączony nośnik), nie pozostaje już bez sygnału: polecenie zgłasza", + "A library is open in one program at a time (issue #99): opening a library that LightCraft or `lightcraft-cli` already": "Jedna biblioteka może być otwarta tylko w jednym programie naraz (zgłoszenie #99): próba otwarcia biblioteki używanej przez LightCraft lub `lightcraft-cli`", + "A save that fails part-way (a full disk, a network share that drops) no longer looks like a damaged catalog": "Zapis przerwany w połowie (pełny dysk, zerwane połączenie z udziałem sieciowym) nie sprawia już wrażenia uszkodzonego katalogu", + "A sleeping NAS, a dropped network share or a USB drive spinning up no longer freezes the window (issue #104):": "Uśpiony NAS, niedostępny udział sieciowy albo uruchamiający się dysk USB nie blokują już okna (zgłoszenie #104):", + "AI masks with SAM 3 (Object and Describe in the Masking panel): click an object to select it (⌥-click leaves a": "Maski AI wykorzystujące SAM 3 (Obiekt i Opis w panelu Maskowanie): kliknij obiekt, aby go zaznaczyć (kliknięcie z ⌥ pozostawia", + "Browser version (experimental), keeping a library safe (issue #107): File ▸ Back Up Library… downloads the catalog": "Wersja przeglądarkowa (eksperymentalna), ochrona biblioteki (zgłoszenie #107): Plik → Utwórz kopię zapasową biblioteki… pobiera katalog", + "Canon CR2 photos from the EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II and 1D Mark IV (and other": "Zdjęcia Canon CR2 z aparatów EOS 7D, 50D, 60D, 550D, 600D, 1200D, 1300D, 5D Mark II i 1D Mark IV (oraz innych", + "Canon CR3 files show their full-size embedded JPEG (e.g. 6960 × 4640 on an EOS R6 Mark III) instead of the": "Pliki Canon CR3 pokazują pełnowymiarowy podgląd JPEG osadzony w pliku (np. 6960 × 4640 w EOS R6 Mark III), zamiast", + "Cmd+Shift+R, keeps the photo and clears its edits.)": "Cmd+Shift+R zachowuje zdjęcie, ale usuwa jego edycje).", + "Compressed Nikon NEFs (lossless and lossy compressed, 12- and 14-bit — e.g. D3200, D5100, D7000, D750, D850, Z 50)": "Skompresowane pliki Nikon NEF (kompresja bezstratna i stratna, 12 i 14 bitów — np. 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": "Konwersja do DNG, Kopiuj jako DNG, Łączenie zdjęć i podglądy inteligentne nie zapisują już bezpośrednio do pliku docelowego (zgłoszenie", + "Edge setting, and get a sharper zoomed-in pass in the background. The model runs inside LightCraft in pure Rust": "ustawienie krawędzi i uzyskaj ostrzejszy podgląd po powiększeniu, obliczany w tle. Model działa wewnątrz LightCraft w czystym Ruście", + "Exported files are written to a temp file and then renamed into place, so a full disk or an unplugged drive": "Wyeksportowane pliki zapisuje się do pliku tymczasowego, a następnie zmienia ich nazwę na docelową, dzięki czemu pełny lub odłączony dysk", + "Exports and renders never write over a photo's original (issue #93): exporting into the photo's own folder with": "Eksport i renderowanie nigdy nie nadpisują oryginału zdjęcia (zgłoszenie #93): eksport do folderu ze zdjęciem przy", + "Exports are never black because of the GPU (issue #78): a GPU render that runs out of device": "Eksporty nie są już czarne z powodu GPU (zgłoszenie #78): renderowanie GPU, któremu zabraknie pamięci urządzenia", + "Faster exports and card imports, with the same protection for what can't be recreated (issue #134): exports,": "Szybszy eksport i import z kart, przy zachowaniu ochrony danych nieodtwarzalnych (zgłoszenie #134): eksporty,", + "Import presets from other editors: XMP presets, classic `.lrtemplate` files, \"DNG presets\" from mobile apps and `.zip`": "Importuj ustawienia predefiniowane z innych edytorów: ustawienia XMP, klasyczne pliki `.lrtemplate`, „ustawienia DNG” z aplikacji mobilnych i archiwa `.zip`", + "Import ▸ Copy verifies every copy, like Move (issue #96): each file is written as a new file, synced to disk and": "Import → Kopiuj weryfikuje każdą kopię, podobnie jak Przenieś (zgłoszenie #96): każdy plik jest zapisywany jako nowy, synchronizowany z dyskiem i", + "Import ▸ Move: photos go into the destination (with the same folders and renaming as Copy, e.g.": "Import → Przenieś: zdjęcia trafiają do folderu docelowego (z takim samym układem folderów i zmianą nazw jak przy kopiowaniu, np.", + "Import: copy to any folder, by day / by month / one folder, rename on import, metadata preset, Copy as DNG.": "Import: kopiowanie do dowolnego folderu, podział według dni lub miesięcy albo do jednego folderu, zmiana nazw przy imporcie, zestaw metadanych, kopiowanie jako DNG.", + "JPEG with the same name (`IMG_0001.CR3` + `IMG_0001.JPG`) no longer share one sidecar: the raw keeps `IMG_0001.xmp`,": "Plik JPEG o tej samej nazwie (`IMG_0001.CR3` i `IMG_0001.JPG`) nie współdzieli już jednego pliku XMP: RAW zachowuje `IMG_0001.xmp`,", + "LightCraft no longer crashes at launch on Windows PCs whose Vulkan driver is broken (issue #136, e.g. some Intel UHD": "LightCraft nie zawiesza się już podczas uruchamiania na komputerach Windows z uszkodzonym sterownikiem Vulkan (zgłoszenie #136, np. niektóre Intel UHD", + "Luminar looks: `.lmp` files and `.mplumpack` collections import as presets (grouped by collection); the sliders": "Style Luminar: pliki `.lmp` i kolekcje `.mplumpack` są importowane jako ustawienia predefiniowane (grupowane według kolekcji); suwaki", + "Meta's terms, not LightCraft's. Downloading it means accepting them.": "Warunki licencji firmy Meta, a nie LightCraft. Pobranie oznacza ich akceptację.", + "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.": "Przenieś: pliki są przenoszone do folderu poniżej; oryginał (wraz z plikiem XMP) jest usuwany ze źródła dopiero po weryfikacji kopii. Duplikaty i pliki z błędami pozostają na miejscu.", + "Nikon NEFs start from a colour and tone look fitted to the camera's own JPEG, as Sony ARWs do, instead of a muted,": "Pliki Nikon NEF otrzymują wstępne kolory i tonalność dopasowane do JPEG z aparatu, podobnie jak Sony ARW, zamiast wyblakłego,", + "Nikon splits into two differently compressed halves still use the preview for now.)": "Pliki Nikon podzielone na dwie części o różnych rodzajach kompresji nadal korzystają na razie z podglądu).", + "Object and Describe masks use SAM 3, Meta's segmentation model. It isn't part of LightCraft, and everything else works without it.": "Maski Obiekt i Opis korzystają z modelu segmentacji SAM 3 firmy Meta. Nie jest on częścią LightCraft; wszystkie pozostałe funkcje działają bez niego.", + "Panasonic and Leica raws (RW2, RWL and the older RAW files, DMC-LX1 to DC-S1R II) develop from the raw data in": "Pliki RAW Panasonic i Leica (RW2, RWL i starsze RAW, od DMC-LX1 do DC-S1R II) są wywoływane z danych RAW w", + "Photo menu. The filmstrip has the photo context menu too. Adding a file again that is in Recently Deleted no": "menu Zdjęcie. Pasek zdjęć ma także menu kontekstowe. Ponowne dodanie pliku znajdującego się w Ostatnio usuniętych nie", + "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).": "Zdjęcie → Edytuj w edytorze zewnętrznym (⇧⌘E) tworzy 16-bitową kopię TIFF, grupuje ją z oryginałem i otwiera w wybranym programie (nazwa aplikacji w macOS, ścieżka programu w pozostałych systemach).", + "Photos in Recently Deleted can be restored from the app: right-click ▸ Restore (or Delete Permanently), also in the": "Zdjęcia w Ostatnio usuniętych można przywrócić w aplikacji: kliknij prawym przyciskiem → Przywróć (lub Usuń trwale), również w", + "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.": "Zdjęcia widoczne w widoku Lokalnie, lecz nigdy niedodane ani niezmienione, są usuwane z katalogu, jeśli ich folder nie był przeglądany przez określony czas (sprawdzane przy otwieraniu biblioteki). Pliki pozostają na dysku; ponowne przejrzenie folderu znów je pokazuje.", + "Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.": "Zdjęcia pojawiające się w tym folderze (z aparatu podłączonego do komputera, skanera lub aplikacji synchronizującej) są dodawane od razu po ukończeniu zapisu.", + "Photos); importing (also by drag and drop), Find Missing Photos, Build / Discard Smart Previews, the Rename preview,": "Zdjęcia); importowanie (także przeciąganiem), Znajdź brakujące zdjęcia, Zbuduj / Odrzuć podglądy inteligentne, podgląd zmiany nazw,", + "Quick Collection and target album (B in the grid), keyword sets (⌥1–⌥9), colour-label sets.": "Kolekcja podręczna i album docelowy (B w siatce), zestawy słów kluczowych (⌥1–⌥9), zestawy etykiet kolorów.", + "Rename Folder… and Move Folder To… (Local) can be undone and redone (issue #97): the folder moves back on disk with": "Zmień nazwę folderu… i Przenieś folder do… (Lokalnie) można cofnąć i ponowić (zgłoszenie #97): folder wraca na dysku wraz ze", + "Rename Photos never overwrites another photo when only the letter case changes (issue #95): on case-sensitive": "Zmień nazwy zdjęć nigdy nie nadpisuje innego zdjęcia przy zmianie wyłącznie wielkości liter (zgłoszenie #95): w systemach z rozróżnianiem", + "Rename Photos reports files it could not move back after a failure (issue #105), e.g. when a network share drops": "Zmień nazwy zdjęć zgłasza pliki, których nie udało się przywrócić po błędzie (zgłoszenie #105), np. po zerwaniu połączenia z udziałem sieciowym", + "Saving metadata to an XMP sidecar another application wrote no longer replaces it (issue #92): LightCraft merges its": "Zapis metadanych do pliku XMP utworzonego przez inną aplikację nie zastępuje już jego zawartości (zgłoszenie #92): LightCraft scala swoje", + "Settings files are never quietly reset (issue #103): a damaged `prefs.json`, `presets.json` or `view.json` is kept": "Pliki ustawień nie są już po cichu resetowane (zgłoszenie #103): uszkodzone `prefs.json`, `presets.json` lub `view.json` zachowuje się", + "Smaller, faster catalogs: photos you only looked at in Local (never added, rated or edited) are forgotten once": "Mniejsze i szybsze katalogi: zdjęcia jedynie przeglądane w widoku Lokalnie (niedodane, nieocenione i nieedytowane) są zapominane po", + "Sony ARWs from before about 2017 (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II and their": "Pliki Sony ARW sprzed około 2017 r. (RX100, RX100 II–V, RX10, NEX, SLT, ILCE-6000, A7 / A7 II / A7R II i pokrewne", + "Sony ILCE-7M4 downsized lossless ARWs now decode subsampled YCbCr tiles into linear RGB,": "Pomniejszone bezstratne pliki ARW z Sony ILCE-7M4 dekodują teraz próbki YCbCr do liniowej przestrzeni 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": "serii TZ/ZS, Leica D-Lux, V-Lux i C-Lux oraz generacji GH6, GH7, G9 II, S5 II i S9. Wstępne kolory są dopasowywane do", + "Tags: see Tags beside the template. Files are renamed on disk (with their XMP sidecars); existing names get -1, -2…": "Znaczniki: zobacz Znaczniki obok szablonu. Nazwy plików na dysku (wraz z plikami XMP) są zmieniane; kolizje otrzymują przyrostek -1, -2…", + "The catalog has a format version (issue #102). Opening a library from an older LightCraft upgrades it; a library": "Katalog ma wersję formatu (zgłoszenie #102). Otwarcie biblioteki ze starszego LightCraft aktualizuje ją; biblioteka", + "The control port (`--control`) closes a connection as soon as it receives anything that isn't a JSON request": "Port sterujący (`--control`) zamyka połączenie natychmiast po otrzymaniu danych innych niż żądanie JSON", + "The thumbnail cache only ever counts and deletes its own files (issue #98): a library opened on a folder that": "Pamięć podręczna miniaturek zlicza i usuwa wyłącznie własne pliki (zgłoszenie #98): otwarcie biblioteki w folderze, który", + "Watched-folder auto import; Convert to DNG; Duplicate; Build Standard / 1:1 / Smart Previews.": "Automatyczny import z obserwowanego folderu; Konwertuj do DNG; Duplikuj; Zbuduj podglądy standardowe / 1:1 / inteligentne.", + "Without Saving and Quit (issue #100). It no longer quietly starts a demo session that looked like a reset library and": "Bez zapisywania i Zakończ (zgłoszenie #100). Program nie uruchamia już po cichu sesji demonstracyjnej wyglądającej jak zresetowana biblioteka, przez co", + "`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`) wraz z ich plikami XMP; każdy oryginał znika z karty dopiero po", + "`img_1-1.JPG`; on case-insensitive volumes the case change still goes through.": "`img_1-1.JPG`; na woluminach nierozróżniających wielkości liter zmiana wielkości liter również przechodzi poprawnie.", + "a full drive) no longer counts as built and Build Smart Previews replaces it.": "pełny dysk) nie jest już uznawany za gotowy, a funkcja Zbuduj podglądy inteligentne zastępuje go.", + "afterwards (issue #101): the partial write is cut off before LightCraft retries, so the next launch replays every": "potem (zgłoszenie #101): niepełny zapis jest obcinany przed ponowieniem przez LightCraft, więc przy następnym uruchomieniu odtwarzane są wszystkie", + "again. Change the period (or turn it off) in Settings → Performance.": "ponownie. Zmień ten okres (lub wyłącz go) w Ustawienia → Wydajność.", + "against the library's originals no longer scans the whole library when nothing is at that path.": "z oryginałami biblioteki nie skanuje już całej biblioteki, jeśli pod wskazaną ścieżką nie ma żadnych plików.", + "already has a `thumbs/` folder of other pictures no longer loses them when the cache is trimmed or cleared.": "ma już folder `thumbs/` z innymi zdjęciami — nie tracą one danych przy przycinaniu lub czyszczeniu pamięci podręcznej.", + "and checks the file before using it. Masks keep their selection, so they render and export without the model.": "i sprawdza plik przed użyciem. Maski zachowują swoje zaznaczenie, więc renderowanie i eksport działają także bez modelu.", + "and every imported photo as one zip, and File ▸ Restore Library from Backup… brings it back (the current library": "i każde importowane zdjęcie w jednym archiwum ZIP, a Plik → Przywróć bibliotekę z kopii zapasowej… przywraca je (bieżąca biblioteka", + "and never freezes the window. It is optional and not part of LightCraft (Meta's SAM License, about 3.4 GB): the": "oraz nie blokuje okna. Funkcja jest opcjonalna i nie należy do LightCraft (licencja SAM firmy Meta, około 3,4 GB):", + "as `<name>.corrupt-<time>` and you're told; one that can't be read (e.g. locked by another program) is left alone": "jako `<name>.corrupt-<time>` i informuje o tym; plik, którego nie można odczytać (np. zablokowany przez inny program), pozostaje bez zmian", + "asked to. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (or wgpu's `WGPU_BACKEND`, which GPU rendering": "gdy o to poprosisz. `LIGHTCRAFT_GPU_BACKEND=dx12 | vulkan | metal | off` (lub `WGPU_BACKEND` biblioteki wgpu, którą renderowanie GPU", + "bundles of any of these — whole folders at once, grouped by pack. Masks inside presets come along.": "pakiety zawierające dowolne z nich — całe foldery naraz, grupowane według pakietu. Maski zawarte w ustawieniach również są przenoszone.", + "camera no longer gets baked in. Photos already imported as \"preview only\" switch over on Reload. (Files that": "zapis aparatu nie jest już na stałe stosowany do obrazu. Zdjęcia wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu. (Pliki, których", + "change. Catalogs already holding such a fragment load in full. Quitting while the catalog log can't be written": "takiej zmiany. Katalogi zawierające już taki fragment wczytują się w całości. Zamykanie programu, gdy dziennika katalogu nie da się zapisać", + "changes couldn't be saved tries once more, then asks: Try Saving Again, Quit Anyway or Cancel.": "zmian nie udało się zapisać, podejmuje jeszcze jedną próbę, a następnie pyta: Spróbuj zapisać ponownie, Zakończ mimo to lub Anuluj.", + "channel or MCP, a merge preview path or `lightcraft-cli render IMG.jpg -o IMG.jpg` is refused with a clear": "przez kanał sterujący lub MCP, ścieżka docelowa łączenia albo `lightcraft-cli render IMG.jpg -o IMG.jpg` są odrzucane z czytelnym", + "checked against the content read from the card. A copy that fails or differs is removed and reported as a failed": "weryfikowane względem zawartości odczytanej z karty. Nieudaną lub różniącą się kopię usuwa się i zgłasza jako nieudany", + "colour and tone look fitted to the camera's own JPEG, as Nikon and Sony raws do, framed in the aspect ratio set in": "kolory i tonację dopasowane do JPEG z aparatu, podobnie jak w plikach Nikon i Sony RAW, z kadrem zgodnym z proporcjami ustawionymi w", + "copy is verified and in the library. Duplicates and files that fail stay where they were.": "kopia została zweryfikowana i dodana do biblioteki. Duplikaty i pliki z błędami pozostają na swoim miejscu.", + "data is not decoded yet, so they stay preview-only. For CR3s imported earlier, Photo ▸ Reload from Disk (now in": "dane nie są jeszcze dekodowane, więc pozostają dostępne tylko jako podgląd. Dla plików CR3 zaimportowanych wcześniej funkcja Zdjęcie → Wczytaj ponownie z dysku (teraz w", + "edge are cropped away, and \"12-bit uncompressed\" files are no longer clipped. The RX100 series renders much closer": "usuwane są krawędzie, a „12-bitowe nieskompresowane” pliki nie są już przycinane tonalnie. Seria RX100 wyświetla się znacznie bliżej", + "every format the cameras write. Most bodies opened as preview only before: GH1–GH5, the G, GX, GF, GM, FZ, LX and": "każdy format zapisywany przez aparaty. Większość modeli wcześniej otwierała tylko podgląd: GH1–GH5, serie G, GX, GF, GM, FZ, LX i", + "export. The catalog, XMP sidecars, settings, DNGs, merges, smart previews and the copies an import makes are still": "eksport. Katalog, pliki XMP, ustawienia, DNG, połączone zdjęcia, podglądy inteligentne i kopie utworzone podczas importu nadal są", + "fields in and keeps the rest — e.g. that application's develop settings and edit history — byte for byte. A": "pola i zachowuje pozostałe — np. ustawienia wywoływania oraz historię edycji z tamtej aplikacji — bajt po bajcie. Plik", + "file (Sony stores them only in scrambled maker-note data on these bodies), the few columns of padding at the right": "pliku (Sony przechowuje je w tych modelach jedynie w zaszyfrowanych danych producenta), kilka kolumn wyrównania przy prawej", + "first time you use an AI mask, LightCraft asks before downloading it, shows the progress, can cancel and resume,": "przy pierwszym użyciu maski AI LightCraft prosi o zgodę na pobranie, pokazuje postęp, pozwala anulować i wznowić,", + "for the session instead of being overwritten with defaults. The app settings (`ui.json`, which remembers your": "na czas sesji, zamiast nadpisywać je wartościami domyślnymi. Ustawienia aplikacji (`ui.json`, które zapamiętują", + "gets -1, -2… instead of being replaced.": "otrzymuje przyrostek -1, -2… zamiast zostać zastąpiony.", + "greenish neutral rendering (issue #150); white balance is adjusted relative to the as-shot look. 12-bit NEFs": "zielonkawego, neutralnego obrazu (zgłoszenie #150); balans bieli korygowany jest względem wyglądu zapisanego przez aparat. Pliki NEF 12-bitowe", + "has open — on this computer or another one sharing the folder — says who has it (\"already open in LightCraft (process": "jest otwarta — na tym komputerze lub innym współdzielącym folder — wskazuje kto ją otworzył („już otwarta w LightCraft (proces", + "how to restore it or delete it permanently and import it afresh. (For a fresh start on a photo, Reset Edits,": "jak je przywrócić albo trwale usunąć i ponownie zaimportować. (Aby zacząć od nowa z danym zdjęciem, użyj Resetuj edycje,", + "ignored before) chooses the graphics backend; `off` renders on the CPU. The GPU now starts after the window": "wcześniej ignorowany) wybiera zaplecze graficzne; `off` renderuje na CPU. GPU uruchamia się teraz po otwarciu okna", + "immutable, and HOSTING.md describes the actual build.": "i niezmienne, a HOSTING.md opisuje rzeczywistą konfigurację kompilacji.", + "import — so \"import complete\" means the copies are good before you format the card — and a name that is taken": "importu — dlatego „import ukończony” oznacza zweryfikowane kopie, zanim sformatujesz kartę — a zajęta nazwa", + "imported as \"preview only\" switch over on Reload.": "wcześniej importowane jako „tylko podgląd” przełączają się po ponownym wczytaniu.", + "in short pieces so slow integrated GPUs aren't reset by their watchdog. `ui.inspect` → `perf`": "w krótkich partiach, aby mechanizmy nadzoru wolnych zintegrowanych GPU nie resetowały urządzenia. `ui.inspect` → `perf`", + "incomplete is redone on the CPU — the file is the same image either way. Work is sent to the GPU": "niedokończone renderowanie jest powtarzane na CPU — plik wynikowy zawiera ten sam obraz w obu przypadkach. Praca jest wysyłana do GPU", + "is kept). A failed save (storage full) now shows the unsaved warning and is retried, a photo that can't be stored": "jest zachowywany). Nieudany zapis (pełny nośnik) wyświetla teraz ostrzeżenie o niezapisanych zmianach i jest ponawiany; zdjęcie, którego nie można zapisać", + "is refused and nothing is overwritten.": "jest odrzucana i nic nie zostaje nadpisane.", + "is up, and only when Settings ▸ Performance ▸ Use the GPU for rendering is on; if LightCraft ever dies while": "działa, i tylko jeśli w Ustawienia → Wydajność włączono używanie GPU do renderowania; jeśli LightCraft kiedykolwiek zakończy się awaryjnie podczas", + "isn't added, a second tab shows a message instead of overwriting the first, `?reset` asks first, and the page says": "nie zostaje dodana, druga karta pokazuje komunikat zamiast nadpisywać pierwszą, `?reset` najpierw pyta, a strona informuje", + "it away from the other (issue #92). Find Missing Photos also finds renamed files by their content, prefers a content match": "z dala od drugiego pliku (zgłoszenie #92). Znajdź brakujące zdjęcia wyszukuje również pliki o zmienionych nazwach na podstawie zawartości, preferuje zgodność zawartości", + "its photos and XMP sidecars, and the photos point at it again. If something now occupies the old place, the undo": "ego zdjęcia i pliki XMP, a zdjęcia ponownie na niego wskazują. Jeśli stare miejsce jest już zajęte, cofnięcie", + "layout is read from each file instead of assumed.": "układ jest odczytywany z każdego pliku, zamiast przyjmować go z góry.", + "leaves no DNG and keeps the raw. Smart previews are written the same way; a damaged one (cut short by a crash or": "nie pozostawia pliku DNG i zachowuje RAW. Podglądy inteligentne zapisuje się tak samo; uszkodzony podgląd (ucięty przez awarię lub", + "library) are written atomically and saved as soon as you open another library, not only at quit. Quitting while": "biblioteki) są zapisywane atomowo i natychmiast po otwarciu innej biblioteki, nie tylko przy wychodzeniu. Zamykanie programu, gdy", + "longer just says \"duplicate skipped\": it opens the side panel on Recently Deleted with the photo selected and says": "nie ogranicza się już do komunikatu „pominięto duplikat”: otwiera panel boczny Ostatnio usunięte, zaznacza zdjęcie i informuje,", + "lost everything at quit; a temporary session shows a banner the whole time and never writes to your library.": "wszystko zostało utracone przy wyjściu; sesja tymczasowa przez cały czas pokazuje baner i nigdy nie zapisuje w Twojej bibliotece.", + "memory, exceeds the GPU's buffer limits, hits a driver error or reset, or comes back": "pamięci, przekroczy limity bufora GPU, napotka błąd lub reset sterownika albo zwróci", + "message, and the original is left byte for byte. Ordinary earlier exports are still overwritten when asked.": "komunikatem, a oryginał pozostaje niezmieniony bajt po bajcie. Wcześniejsze zwykłe eksporty nadal są nadpisywane na żądanie.", + "mid-batch: the error lists them (old → new) and the library points at their new names (an undoable partial rename),": "w trakcie partii: błąd wymienia pliki (stara → nowa nazwa), a biblioteka wskazuje ich nowe nazwy (częściową zmianę nazw można cofnąć),", + "models whose sensor starts on a green-blue row) no longer come out magenta (issue #85): the colour-filter": "modele z matrycą zaczynającą się od rzędu zielono-niebieskiego) nie mają już magentowego zafarbu (zgłoszenie #85): układ filtrów barwnych", + "name (never replacing a file), and only then is the photo relinked or the raw copy removed — a failed write": "nazwę (bez zastępowania pliku), a dopiero potem łączy zdjęcie z plikiem albo usuwa kopię RAW — nieudany zapis", + "never leaves a truncated file; the XMP sidecar of an \"Original\" export follows the \"If file exists\" choice too.": "nigdy nie pozostawia uciętego pliku; plik XMP eksportu „Oryginał” także respektuje ustawienie „Jeśli plik istnieje”.", + "now develop from the raw data instead of the camera's embedded JPEG, so a B&W or other picture style set in the": "są teraz wywoływane z danych RAW, zamiast z JPEG osadzonego przez aparat, dzięki czemu styl czarno-biały lub inny ustawiony w", + "on the card after Review Import is reported instead of being imported with stale details. Checking an export path": "na karcie po przeglądzie importu jest zgłaszana, zamiast być importowana z nieaktualnymi danymi. Sprawdzanie ścieżki eksportu", + "one by one — they can always be exported again, and on a USB drive or a NAS that per-file sync dominated a large": "pojedynczo — można je zawsze wyeksportować ponownie, a na dysku USB lub NAS synchronizacja każdego pliku osobno znacznie wydłużała duże", + "over a same-name same-size look-alike, and skips (and reports) photos it can't tell apart instead of guessing.": "zamiast pliku o tej samej nazwie i rozmiarze, ale innej zawartości, a niejednoznaczne zdjęcia pomija (i zgłasza), zamiast zgadywać.", + "part out), or type what to select (\"sky\", \"the red car\", \"car, road\"); both combine with other masks, have an": "część na zewnątrz) albo wpisz, co zaznaczyć („niebo”, „czerwony samochód”, „samochód, droga”); obie maski można łączyć z innymi, mają", + "preserving RAW editing & full-resolution export instead of using embedded JPEG previews.": "z zachowaniem edycji RAW i eksportu w pełnej rozdzielczości, zamiast korzystać z osadzonych podglądów JPEG.", + "read in the wrong units.": "odczytywano w niewłaściwych jednostkach.", + "reading the card a third time (Move still compares byte for byte before it deletes a source); a file that changed": "ponowne odczytanie karty po raz trzeci (funkcja Przenieś nadal porównuje dane bajt po bajcie przed usunięciem źródła); plik, który zmienił się", + "reject": "odrzuć", + "renders and screenshots are still written to a temp file and renamed into place, but no longer forced to disk": "renderowane obrazy i zrzuty ekranu nadal trafiają najpierw do pliku tymczasowego, potem otrzymują nazwę docelową, ale nie są już wymuszane zapisy na dysk", + "save goes through.": "zapis powiedzie się.", + "siblings) no longer open bright green (issue #148): their as-shot white balance and black level are read from the": "pokrewne modele) nie mają już jaskrawozielonych zdjęć (zgłoszenie #148): balans bieli i poziom czerni zapisane przez aparat odczytuje się z", + "sidecar that isn't valid XMP is copied to `<name>.xmp.bak-<time>` first. With the default stem naming, a raw and a": "plik XMP, który nie jest prawidłowym XMP, zostaje najpierw skopiowany do `<name>.xmp.bak-<time>`. Przy domyślnym nazewnictwie według podstawy nazwy pliki RAW i", + "so none shows as missing. Renaming one of a raw + JPEG pair copies their shared `IMG_0001.xmp` instead of taking": "żaden plik nie jest oznaczany jako brakujący. Zmiana nazwy jednego z pary RAW + JPEG kopiuje ich wspólny `IMG_0001.xmp`, zamiast zabierać go", + "starting the GPU, the next launch starts with GPU rendering off and says how to turn it back on.": "podczas uruchamiania GPU następny start odbywa się z wyłączonym renderowaniem GPU i informacją, jak je ponownie włączyć.", + "starts with": "zaczyna się od", + "still saves your queued changes in the closing snapshot.": "nadal zapisuje oczekujące zmiany w migawce zamykającej.", + "synced. Import ▸ Copy now checks each copy against the content hash taken while scanning the card instead of": "synchronizowane. Import → Kopiuj sprawdza teraz każdą kopię względem skrótu zawartości obliczonego podczas skanowania karty, zamiast", + "the JPEG uses `IMG_0001.JPG.xmp`.": "a JPEG korzysta z `IMG_0001.JPG.xmp`.", + "the Local folder tree and Auto Import read the disk on worker threads too. The import progress window has Cancel.": "drzewo folderów Lokalnie i Automatyczny import również odczytują dysk w wątkach roboczych. Okno postępu importu ma przycisk Anuluj.", + "the Photo menu and the photo context menu) picks up the full-size preview and fills in the camera metadata they": "menu Zdjęcie i menu kontekstowym zdjęcia) pobiera pełnowymiarowy podgląd i uzupełnia metadane aparatu, których", + "the camera; mapped-out sensor defects are filled in. `.rwl` and `.raw` files are imported too. Photos already": "gdy aparat; uszkodzone piksele matrycy są wypełniane. Importowane są również pliki `.rwl` i `.raw`. Zdjęcia wcześniej", + "the same name and \"Overwrite\" (or Export with Previous repeating it), an exact output path from the control": "tej samej nazwie i wybraniu „Nadpisz” (lub ponownym eksporcie z poprzednimi ustawieniami), dokładna ścieżka wyjściowa z kanału sterującego", + "their folder has not been browsed for 30 days — your files and sidecars stay, and browsing the folder shows them": "ich folder nie był przeglądany przez 30 dni — pliki i XMP pozostają na dysku, a ponowne wejście do folderu je pokazuje", + "to the camera's own JPEG.": "wyglądu JPEG prosto z aparatu.", + "untouched — older versions no longer read part of it as a damaged log. Once this version has opened a library,": "nienaruszone — starsze wersje nie odczytują już fragmentu jako uszkodzonego dziennika. Gdy ta wersja otworzy bibliotekę,", + "used (e.g. a skipped software adapter such as llvmpipe) and why the last render fell back.": "użyto (np. pominięty adapter programowy llvmpipe) i dlaczego ostatnie renderowanie przełączono na inne rozwiązanie.", + "video": "wideo", + "were missing, without touching anything already set.": "brakowało, bez zmiany już ustawionych wartości.", + "when the browser may evict the library. Hosting: the sample cache headers no longer mark the (unhashed) files": "gdy przeglądarka może usunąć bibliotekę. Hosting: przykładowe nagłówki pamięci podręcznej nie oznaczają już plików bez skrótów w nazwach jako", + "whether originals are there is checked on a worker thread (grid thumbnails, the Info panel, the photo menu, Missing": "sprawdzanie obecności oryginałów odbywa się w wątku roboczym (miniatury siatki, panel Informacje, menu zdjęcia, Brakujące", + "with a counterpart here come along, the rest is listed.": "z odpowiednikami w tym programie również zostają przeniesione, pozostałe są wypisane.", + "written by a newer LightCraft is refused with \"this library was written by a newer version of LightCraft\" and left": "zapisana przez nowszą wersję LightCraft zostaje odrzucona z komunikatem „ta biblioteka została zapisana przez nowszą wersję LightCraft” i pozostawiona", + "written by a newer LightCraft), LightCraft says so and why, and offers Try Again, Choose Another Library…, Continue": "zapisana przez nowszą wersję LightCraft), LightCraft wyjaśnia przyczynę i proponuje Spróbuj ponownie, Wybierz inną bibliotekę… lub Kontynuuj", + "日本語": "日本語", + "繁體中文(台灣)": "繁體中文(台灣)", + "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.": "Ta wersja programu nie może jeszcze pobrać modelu. Aby zainstalować go ręcznie, umieść pliki model.safetensors, vocab.json i merges.txt od Meta (facebook/sam3) w powyższym folderze: funkcje Obiekt i Opis zadziałają, gdy pliki się tam znajdą.", + "denoiseModel.accept": "denoiseModel.accept", + "Add Face Model…": "Dodaj model twarzy…", + "Add to Apple Photos": "Dodaj do aplikacji Zdjęcia", + "Add to My Photos First": "Najpierw dodaj do Moich zdjęć", + "Add to favorites": "Dodaj do ulubionych", + "Adding photos…": "Dodawanie zdjęć…", + "Adding to Apple Photos": "Dodawanie do aplikacji Zdjęcia", + "Adding to Apple Photos…": "Dodawanie do aplikacji Zdjęcia…", + "Albums hold library photos: Add to My Photos first": "Albumy zawierają zdjęcia z biblioteki: najpierw dodaj je do Moich zdjęć", + "Appearance": "Wygląd", + "Appearance Mode": "Tryb wyglądu", + "Appearance Settings…": "Ustawienia wyglądu…", + "Cancel Face Model Download": "Anuluj pobieranie modelu twarzy", + "Cancel Pending Photo Action": "Anuluj oczekującą operację na zdjęciu", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Pliki C-RAW z aparatu Canon EOS R5 Mark II, które otwierały się jako „tylko podgląd” (w zależności od", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "sceny ponad połowa z nich), są teraz wywoływane z danych matrycy wszystkimi narzędziami, jak inne pliki RAW. Zdjęcia", + "imported earlier switch over with Photo ▸ Reload from Disk.": "zaimportowane wcześniej przełączysz poleceniem Zdjęcie → Wczytaj ponownie z dysku.", + "Charcoal": "Grafit", + "Choose Face Recognition Model": "Wybierz model rozpoznawania twarzy", + "Choose a model below (Use) to start.": "Aby zacząć, wybierz model poniżej (Użyj).", + "Clear Recent Libraries": "Wyczyść listę ostatnich bibliotek", + "Content-Aware": "Z uwzględnieniem zawartości", + "Dark Mode": "Tryb ciemny", + "Dark Theme": "Ciemny motyw", + "Detect Faces": "Wykryj twarze", + "Detect Faces (Photo menu) needs it: a small one-time download.": "Potrzebuje go polecenie Wykryj twarze (menu Zdjęcie): to niewielkie, jednorazowe pobranie.", + "Download Face Model": "Pobierz model twarzy", + "Download Place Names (GeoNames)": "Pobierz nazwy miejsc (GeoNames)", + "Downloading place names": "Pobieranie nazw miejsc", + "Drag Crop Handle": "Przeciągnij uchwyt kadrowania", + "Evaluate Face Recognition": "Oceń rozpoznawanie twarzy", + "Face Model Downloads": "Pobieranie modeli twarzy", + "Face Models": "Modele twarzy", + "Face Recognition On/Off": "Rozpoznawanie twarzy wł./wył.", + "Face detector": "Detektor twarzy", + "Face recognition": "Rozpoznawanie twarzy", + "Faces": "Twarze", + "Faces of a Person": "Twarze osoby", + "Files with the same content as another file in this import: only the first is imported.": "Pliki o tej samej zawartości co inny plik w tym imporcie: importowany jest tylko pierwszy.", + "Fill Location": "Uzupełnij lokalizację", + "Fill Location from GPS": "Uzupełnij lokalizację z GPS", + "Fill empty City, State and Country from GPS (offline GeoNames place names)": "Uzupełnij puste pola Miasto, Region i Kraj na podstawie GPS (nazwy miejsc GeoNames, offline)", + "Go back to listing them by name": "Wróć do listy według nazwy", + "Hide This Section": "Ukryj tę sekcję", + "Horizontal inset (% of short edge)": "Margines poziomy (% krótszego boku)", + "In Use": "W użyciu", + "Include selected photos": "Uwzględnij zaznaczone zdjęcia", + "Index Faces": "Indeksuj twarze", + "Index Faces in the Background": "Indeksuj twarze w tle", + "Inspect Face Model File": "Sprawdź plik modelu twarzy", + "Install Face Model": "Zainstaluj model twarzy", + "Intent:": "Metoda odwzorowania:", + "Library Folders": "Foldery biblioteki", + "Library name": "Nazwa biblioteki", + "Light Mode": "Tryb jasny", + "Light Theme": "Jasny motyw", + "Lights Out": "Zgaszone światła", + "Loading preview…": "Wczytywanie podglądu…", + "Locate Missing Files…": "Odszukaj brakujące pliki…", + "Looking up places": "Wyszukiwanie miejsc", + "Midnight": "Północ", + "Move Selected Photos Here": "Przenieś tu zaznaczone zdjęcia", + "Move Sidebar Section": "Przenieś sekcję paska bocznego", + "Move to Folder": "Przenieś do folderu", + "Move to Folder…": "Przenieś do folderu…", + "Moves the files and their XMP sidecars into this folder; never overwrites": "Przenosi pliki wraz z plikami towarzyszącymi XMP do tego folderu; niczego nie nadpisuje", + "Name Face": "Nazwij twarz", + "Name Faces": "Nazwij twarze", + "New Folder on Disk": "Nowy folder na dysku", + "New Folder on Disk…": "Nowy folder na dysku…", + "New Library": "Nowa biblioteka", + "New Library: Choose Where to Put It": "Nowa biblioteka: wybierz miejsce zapisu", + "New Library…": "Nowa biblioteka…", + "Next Appearance Mode": "Następny tryb wyglądu", + "No Recent Libraries": "Brak ostatnich bibliotek", + "No folders to browse": "Brak folderów do przeglądania", + "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nie znaleziono procesora graficznego: LightCraft renderuje na CPU, co jest znacznie wolniejsze. Szczegóły w Pomoc → Informacje o systemie.", + "No imported folders yet": "Brak zaimportowanych folderów", + "No photos yet": "Brak zdjęć", + "On NixOS, start it from `nix develop`.": "W systemie NixOS uruchom go z `nix develop`.", + "Open Recent Library": "Otwórz ostatnią bibliotekę", + "Other…": "Inne…", + "Paper": "Papier", + "Perceptual": "Percepcyjna", + "Place Names Status": "Stan nazw miejsc", + "Place names download cancelled": "Anulowano pobieranie nazw miejsc", + "Place names downloaded": "Pobrano nazwy miejsc", + "Preview unavailable": "Podgląd niedostępny", + "Reading photos…": "Odczytywanie zdjęć…", + "Recent Libraries": "Ostatnie biblioteki", + "Recognise faces (experimental)": "Rozpoznawaj twarze (eksperymentalne)", + "Recognition models": "Modele rozpoznawania", + "Relative": "Względna", + "Remove Face Model": "Usuń model twarzy", + "Remove from favorites": "Usuń z ulubionych", + "Reorder Album": "Zmień kolejność w albumie", + "Reset Sidebar Sections": "Przywróć sekcje paska bocznego", + "Show All Mask Overlays": "Pokaż wszystkie nakładki masek", + "Show Person": "Pokaż osobę", + "Show Sidebar Section": "Pokaż sekcję paska bocznego", + "Show all overlays": "Pokaż wszystkie nakładki", + "Sidebar Sections": "Sekcje paska bocznego", + "Silver": "Srebrny", + "Simulate Paper & Ink": "Symuluj papier i tusz", + "Sort Albums A–Z": "Sortuj albumy A–Z", + "Sort Albums by Name": "Sortuj albumy według nazwy", + "Sort Contents A–Z": "Sortuj zawartość A–Z", + "Suggest Names for Faces": "Proponuj imiona dla twarzy", + "Suggests who is in a photo from the faces you have named. Everything stays on your computer.": "Podpowiada, kto jest na zdjęciu, na podstawie nazwanych przez Ciebie twarzy. Wszystko zostaje na Twoim komputerze.", + "Sync with System": "Zgodnie z systemem", + "Test Face Model": "Przetestuj model twarzy", + "The first time, macOS asks whether LightCraft may control Photos.": "Za pierwszym razem macOS zapyta, czy LightCraft może sterować aplikacją Zdjęcia.", + "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Import odczytuje katalog bez zmieniania go i dodaje jego zdjęcia, edycje, słowa kluczowe i kolekcje do otwartej teraz biblioteki.", + "This build cannot run recognition models: they can be added and chosen, not used.": "Ta wersja nie może uruchamiać modeli rozpoznawania: można je dodać i wybrać, ale nie używać.", + "This build has nowhere to keep face models, so the model list is not available. (The desktop app has.)": "Ta wersja nie ma gdzie przechowywać modeli twarzy, więc lista modeli jest niedostępna. (Aplikacja na komputer ją ma.)", + "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "To jest katalog Lightrooma, a nie biblioteka LightCraft. Zaimportować go zamiast tego do biblioteki LightCraft?", + "Unnamed Faces": "Nienazwane twarze", + "Vertical inset (% of short edge)": "Margines pionowy (% krótszego boku)", + "not added to Apple Photos (cancelled)": "nie dodano do aplikacji Zdjęcia (anulowano)" +} diff --git a/crates/ui-egui/locales/pt-br-formats.json b/crates/ui-egui/locales/pt-br-formats.json index e81969e0f..2b17909ad 100644 --- a/crates/ui-egui/locales/pt-br-formats.json +++ b/crates/ui-egui/locales/pt-br-formats.json @@ -17,6 +17,7 @@ "All {n} selected photos move by the same amount.": "Todas as {n} fotos selecionadas se movem pela mesma quantidade.", "Already in the library, in Recently Deleted: {} photo{} selected · right-click ▸ Restore, or Delete Permanently to import afresh": "Já na biblioteca, em Excluídos recentemente: {0} foto{1} selecionada{1} · clique com o botão direito → Restaurar, ou Excluir permanentemente para importar de novo", "Already in the library: {} photo{} selected in All Photos": "Já na biblioteca: {0} foto{1} selecionada{1} em Todas as fotos", + "Already in “{}”": "Já está em “{}”", "Auto ({})": "Automático ({})", "Auto Sync: edits apply to {n} photos": "Sincronização automática: edições aplicadas a {n} fotos", "Automatic ({} MB)": "Automático ({} MB)", @@ -24,6 +25,7 @@ "Build Smart Previews ({scope})": "Gerar pré-visualizações inteligentes ({scope})", "Build Standard-Sized Previews ({scope})": "Gerar pré-visualizações em tamanho padrão ({scope})", "Click {}:1": "Clique em {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Espaço de cor: Rec. 2020 {curve} (AVIF HDR)", "Create {n} Virtual Copies": "Criar {n} cópias virtuais", "Decoder: {reason}": "Decodificador: {reason}", "Delete \"{label}\"": "Excluir \"{label}\"", @@ -56,7 +58,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "O LightCraft não conseguiu abrir sua janela: {e}\n\nO arquivo de log tem os detalhes: {path}", "Matches: {}": "Correspondências: {}", "Merging… {stage} {:.0}%": "Mesclando… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "Mover {n} foto{0} para “{1}”", + "Move {n} photo{} to “{}”?": "Mover {n} foto{0} para “{1}”?", "Move {what} to Recently Deleted?": "Mover {what} para Excluídos recentemente?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "{n} foto{0} movida{0} para “{1}”. Editar → Desfazer as traz de volta.", "Named ({named_n})": "Nomeadas ({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "Colar em {n} foto{} — somente os ajustes copiados são colados", "Preset: {name}": "Predefinição: {name}", @@ -87,6 +92,7 @@ "Show {hidden} hidden location{}": "Mostrar {hidden} local(is) oculto(s){:.0}", "Showing {n} of {}": "Mostrando {n} de {}", "Smart album: {}": "Álbum inteligente: {}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Não foi possível gravar os arquivos XMP de {n} fotos (a pasta está offline?). O LightCraft continua tentando.", "Undo {}": "Desfazer {}", "Updated “{name}”": "“{name}” atualizado", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "Versão {} — uma biblioteca de fotos e revelador RAW em Rust puro, desenvolvida de forma independente.", @@ -117,6 +123,7 @@ "{n} photo{}": "{n} foto{}", "{n} photo{} match · updates automatically as photos change": "{n} foto{0} correspondente{0} · atualiza automaticamente conforme as fotos mudam", "{n} photo{} now · updates automatically as photos change": "{n} foto{} agora · atualiza automaticamente conforme as fotos mudam", + "{n} selected photos · {pages} pages": "{n} fotos selecionadas · {pages} páginas", "{sel_n} selected · {counted}": "{sel_n} selecionadas · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} foto{}", @@ -235,6 +242,10 @@ "{done} of {total} MB": "{done} de {total} MB", "{pct} %": "{pct}%", "+{n} more": "+{n} mais", + "Create the keyword “{name}”": "Criar a palavra-chave “{name}”", + "new, inside {parent}": "nova, dentro de {parent}", + "Keywords of {n} selected photos": "Palavras-chave de {n} fotos selecionadas", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "“{name}” removida de {n} fotos. Editar → Desfazer a traz de volta.", "1 task is still running: {names}. Quitting stops it.": "1 tarefa ainda está em andamento: {names}. Sair a interrompe.", "{n} tasks are still running: {names}. Quitting stops them.": "{n} tarefas ainda estão em andamento: {names}. Sair as interrompe.", "{what} merge cancelled": "{what} mesclagem cancelada", @@ -245,7 +256,44 @@ "{n} added to Apple Photos album “{album}”": "{n} adicionadas ao álbum “{album}” do Fotos da Apple", "{n} added to Apple Photos": "{n} adicionadas ao Fotos da Apple", "{n} photos opened for editing; each is stacked with its original": "{n} fotos abertas para edição; cada uma está empilhada com a original", + " in {g} group{}": " em {g} grupo(s){:.0}", + " · kept, not applied: {}": " · mantido, não aplicado: {}", + " — kept but not applied: {}": " — mantidos, mas não aplicados: {}", + "Apply to “{mask}” on Selected Photos": "Aplicar a “{mask}” nas fotos selecionadas", + "Delete the effect preset “{name}”?": "Excluir a predefinição de efeito “{name}”?", + "Delete these {n} effect presets?": "Excluir estas {n} predefinições de efeito?", + "Delete {n} effect preset{}? This can't be undone.": "Excluir {n} predefinição(ões) de efeito?{:.0} Não é possível desfazer.", + "Effect: {name}": "Efeito: {name}", + "Exported {} effect preset{}": "Predefinições de efeito exportadas: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "{e} efeito(s) de máscara importado(s){:.0} (Máscaras → Efeito)", + "Update “{name}” with Current Settings": "Atualizar “{name}” com as configurações atuais", + "{n} preset{}": "{n} predefinição(ões){:.0}", "{name} opened in the other editor": "{name} aberto no outro editor", "{n} originals opened in the other editor": "{n} originais abertos no outro editor", - "· {in_source} identical copies": "· {in_source} cópias idênticas" + "· {in_source} identical copies": "· {in_source} cópias idênticas", + "{count} more warnings.": "Mais {count} avisos.", + "{photos} catalog records processed; {collections} collections.": "{photos} registros do catálogo processados; {collections} coleções.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "{missing} originais estão ausentes. Seus registros no catálogo estão disponíveis; use Encontrar fotos ausentes para vinculá-los novamente.", + "Applied {name}": "{name} aplicado", + "Delete “{name}”?": "Excluir “{name}”?", + "Updated {name}": "{name} atualizado", + "Exported to {path}": "Exportado para {path}", + "Saved template {name}": "Modelo {name} salvo", + "Imported {name}": "{name} importado", + "Skipped {problem}": "Ignorado: {problem}", + "…and {more} more": "…e mais {more}", + "Template preview: {name}": "Prévia do modelo: {name}", + "{n} photos in view · {slides} slides · {time}": "{n} fotos na visualização · {slides} slides · {time}", + "{n} pages": "{n} páginas", + "{n} JPEG files": "{n} arquivos JPEG", + "{s} s of AV1 video": "{s} s de vídeo AV1", + "Slideshow exported: {what}": "Apresentação exportada: {what}", + " · {n} skipped (could not be rendered)": " · {n} ignoradas (não foi possível renderizar)", + "{n} raws can now be developed from their raw data": "{n} RAW agora podem ser revelados a partir dos dados RAW", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "O LightCraft parou inesperadamente: {e}\n\nO arquivo de log tem os detalhes: {path}", + "LightCraft stopped unexpectedly: {e}": "O LightCraft parou inesperadamente: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Renomeia {n} rosto{} na biblioteca. Os arquivos não são alterados.", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}” já é uma pessoa. Combiná-las deixa uma só; Desfazer as separa de novo.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what} mesclagem adicionada em {mp}: o tamanho completo, {full}, precisa de mais memória do que este computador tem", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what} mesclagem adicionada em {mp}, o maior tamanho possível para um panorama (tamanho completo: {full})" } diff --git a/crates/ui-egui/locales/pt-br.json b/crates/ui-egui/locales/pt-br.json index f8e4753e5..2c9e9da05 100644 --- a/crates/ui-egui/locales/pt-br.json +++ b/crates/ui-egui/locales/pt-br.json @@ -10,11 +10,13 @@ ", rejected": ", rejeitada", "0: nothing is rejected.": "0: nada é rejeitado.", "1 day": "1 dia", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · edições atuais · ajustado sem cortar", "2026-04 or 2026-04-12": "2026-04 ou 2026-04-12", "= exactly": "= exatamente", "A blank template keeps the original names.": "Um modelo em branco mantém os nomes originais.", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Um filtro está ocultando as fotos desta exibição: altere-o ou limpe-o (Exibir → Limpar filtros)", "A one-time download of about": "Um download único de cerca de", + "A pen's pressure sets the size and flow of the brush": "A pressão de uma caneta define o tamanho e o fluxo do pincel", "AI Mask Model Status": "Status do modelo de máscaras de IA", "About": "Sobre", "About LightCraft": "Sobre o LightCraft", @@ -40,6 +42,9 @@ "Add to Favorites": "Adicionar aos favoritos", "Add to Local": "Adicionar a Local", "Add to My Photos": "Adicionar às Minhas fotos", + "Can't be moved here": "Não podem ser movidas para cá", + "This row covers more than one disk: drop on a folder inside it": "Esta linha abrange mais de um disco: solte em uma pasta dentro dela", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "As fotos locais ainda não estão na biblioteca: primeiro Adicionar às Minhas fotos, depois mova-as", "Add to Selected Photos": "Adicionar às fotos selecionadas", "Add to Target Album": "Adicionar ao álbum de destino", "Add to this mask": "Adicionar a esta máscara", @@ -204,6 +209,7 @@ "Choose Edit Settings to Copy": "Escolher ajustes de edição a copiar", "Choose Edit Settings to Copy…": "Escolher ajustes de edição a copiar…", "Choose Folder…": "Escolher pasta…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Escolha onde salvar o PDF depois de confirmar. Imprima o PDF pelo seu visualizador.", "Choose a photo in the grid or filmstrip": "Escolha uma foto na grade ou na película", "Choose…": "Escolher…", "Cinematic": "Cinematográfico", @@ -254,6 +260,9 @@ "Color Range": "Intervalo de cor", "Color label cleared": "Rótulo de cor removido", "Color space": "Espaço de cor", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Espaço de cor: Rec. 2020 PQ (AVIF HDR)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Espaço de cor: Rec. 2020 PQ (JPEG XL HDR)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Espaço de cor: Rec. 2020 PQ, 16 bits (PNG HDR)", "Color space: sRGB (AVIF)": "Espaço de cor: sRGB (AVIF)", "Colors": "Cores", "Combine files…": "Combinar arquivos…", @@ -270,7 +279,14 @@ "Concrete Cool": "Concreto frio", "Confirm before deleting": "Confirmar antes de excluir", "Confirm before moving photos to Recently Deleted": "Confirmar antes de mover as fotos para Excluídos recentemente", + "Move Photos": "Mover fotos", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Os arquivos e os arquivos XMP auxiliares são movidos no disco. Editar → Desfazer os traz de volta.", + "Don't ask again": "Não perguntar novamente", + "Another library was opened since the photos were dropped: nothing was moved": "Outra biblioteca foi aberta depois que as fotos foram soltas: nada foi movido", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Ajustes → Confirmar antes de mover fotos para outra pasta no disco o reativa", + "Confirm before moving photos to another folder on disk": "Confirmar antes de mover fotos para outra pasta no disco", "Constrain Crop": "Restringir recorte", + "Contact Sheet PDF": "Folha de contato em PDF", "Continue": "Continuar", "Continue Without Saving": "Continuar sem salvar", "Contrast": "Contraste", @@ -495,6 +511,7 @@ "File name": "Nome do arquivo", "File types:": "Tipos de arquivo:", "Filename": "Nome do arquivo", + "Filename captions": "Legendas com o nome do arquivo", "Fill": "Preencher", "Fill Edges": "Preencher bordas", "Film": "Filme", @@ -554,6 +571,10 @@ "Friday": "Sexta-feira", "Full": "Completa", "Full Screen Preview": "Pré-visualização em tela cheia", + "Hold View Until Refreshed": "Manter visualização até atualizar", + "HDR TIFF needs 32-bit float.": "TIFF HDR requer ponto flutuante de 32 bits.", + "HDR output": "Saída HDR", + "Letter": "Carta", "Lights Out": "Luzes apagadas", "Full screen mode": "Modo tela cheia", "Full size": "Tamanho completo", @@ -609,6 +630,7 @@ "HDR panorama": "Panorama HDR", "HDR panoramas": "panoramas HDR", "HDR with Last Settings": "HDR com os últimos ajustes", + "HDR for Each Bracket Set": "HDR para cada bracketing", "HDR…": "HDR…", "Has Edits": "Tem edições", "Has GPS": "Tem GPS", @@ -909,7 +931,7 @@ "No edits yet.": "Ainda sem edições.", "No exposure information": "Sem informações de exposição", "No filter presets yet": "Ainda não há predefinições de filtro", - "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", + "No lens data embedded in this file.": "Nenhum dado de lente incorporado neste arquivo.", "No masks yet. Choose a mask type above.": "Ainda não há máscaras. Escolha um tipo de máscara acima.", "No matching commands": "Nenhum comando corresponde", "No matching photos": "Nenhuma foto correspondente", @@ -920,6 +942,11 @@ "No new presets": "Nenhuma predefinição nova", "No photo selected": "Nenhuma foto selecionada", "No photos": "Sem fotos", + "Your library is empty": "Sua biblioteca está vazia", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "As fotos que você importar aparecem aqui. Nada é copiado ou movido, a menos que você escolha.", + "or drop photos and folders into this window": "ou arraste fotos e pastas para esta janela", + "Import photos to get started": "Importe fotos para começar", + "or drop photos into this window": "ou arraste fotos para esta janela", "No photos found": "Nenhuma foto encontrada", "No photos in this folder": "Nenhuma foto nesta pasta", "No photos match the active filters": "Nenhuma foto corresponde aos filtros ativos", @@ -950,6 +977,7 @@ "Open Library": "Abrir biblioteca", "Open Library…": "Abrir biblioteca…", "Open Log Folder": "Abrir pasta de logs", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Fotos editadas em HDR: JPEG com mapa de ganho HDR (fica certo em qualquer lugar, realces mais brilhantes em telas HDR), AVIF em 10 bits, PNG e JPEG XL em 16 bits Rec. 2020 PQ; TIFF requer ponto flutuante de 32 bits", "Reading photos…": "Lendo fotos…", "Adding photos…": "Adicionando fotos…", "Open in": "Abrir em", @@ -999,6 +1027,7 @@ "Paste from Previous": "Colar da anterior", "Paste pages": "Colar páginas", "Pastel Haze": "Névoa pastel", + "Pen Pressure": "Pressão da caneta", "People": "Pessoas", "Percent": "Porcentagem", "Percentage": "Porcentagem", @@ -1137,6 +1166,7 @@ "Refining the mask's detail…": "Refinando os detalhes da máscara…", "Refresh Source": "Atualizar origem", "Refresh Source (/)": "Atualizar origem (/)", + "Refresh View": "Atualizar visualização", "Reject": "Rejeitar", "Reject below focus": "Rejeitar abaixo do foco", "Rejected": "Rejeitada", @@ -1202,7 +1232,7 @@ "Reset Section": "Redefinir seção", "Reset Tone Curve": "Redefinir curva de tonalidade", "Reset all curves: point curves (all channels) and parametric (double-click a channel to reset only that one)": "Redefine todas as curvas: curvas de pontos (todos os canais) e paramétrica (clique duas vezes em um canal para redefinir apenas ele)", - "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", + "Reset all edits": "Redefinir todas as edições", "Reshuffle": "Embaralhar de novo", "Resize Face Box": "Redimensionar caixa de rosto", "Resize panel window": "Redimensionar janela do painel", @@ -1361,6 +1391,7 @@ "Show overlay (O)": "Mostrar sobreposição (O)", "Show ratings, flags and edit badges": "Mostrar avaliações, marcações e indicadores de edição", "Show the Navigator while zoomed in": "Mostrar o Navegador quando ampliado", + "Show the import review when a camera or card is connected": "Abrir a importação ao conectar uma câmera ou cartão", "Show the template tags; click one to insert it": "Mostrar os marcadores do modelo; clique em um para inseri-lo", "Show/Hide Mask": "Mostrar/ocultar máscara", "Show/hide My Photos panel": "Mostrar/ocultar painel Minhas fotos", @@ -1388,6 +1419,7 @@ "Soft Pastel": "Pastel suave", "Soft Proofing": "Prova suave", "Soft Skin": "Pele suave", + "Solo Mode": "Modo solo", "Sort": "Classificar", "Sort by": "Classificar por", "Source": "Origem", @@ -1534,6 +1566,7 @@ "Vibrance": "Intensidade", "Videos": "Vídeos", "View": "Exibir", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Visualizadores que não leem o bloco cICP do PNG mostram os valores PQ como sRGB: chapados e desbotados. Para uma imagem que fique certa em qualquer lugar, use JPEG (mapa de ganho).", "Viewing and sharing": "Visualização e compartilhamento", "Vignette": "Vinheta", "Vignetting": "Vinheta", @@ -1566,6 +1599,7 @@ "Will be restored": "Será restaurada", "Window": "Janela", "Winter Blue": "Azul invernal", + "Wrote the XMP sidecars that were waiting": "Os arquivos XMP pendentes foram gravados", "XMP Sidecar Preferences": "Preferências de sidecar XMP", "XMP sidecars": "Sidecars XMP", "Year": "Ano", @@ -1755,7 +1789,7 @@ "commercial use subject to GPL conditions": "uso comercial sujeito às condições da GPL", "Decrease Rating": "Diminuir avaliação", "Increase Rating": "Aumentar avaliação", - "Click to type a value": "Clique para digitar um valor", + "Click to type a value, drag to change it": "Clique para digitar um valor, arraste para alterá-lo", "Angle": "Ângulo", "Video": "Vídeo", "Copyrighted": "Protegido por direitos autorais", @@ -1819,6 +1853,15 @@ "Keyword List": "Lista de palavras-chave", "Filter Keywords": "Filtrar palavras-chave", "No keywords yet": "Ainda não há palavras-chave", + "Show fewer": "Mostrar menos", + "Whole names": "Nomes completos", + "Grouped by parent": "Agrupadas pela palavra-chave superior", + "Keywords of the selection": "Palavras-chave da seleção", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Agrupadas: as palavras-chave dentro da mesma palavra-chave compartilham uma linha com o nome dela; listas longas e as sugestões são encurtadas.", + "Show every keyword of the selected photos": "Mostrar todas as palavras-chave das fotos selecionadas", + "new keyword": "nova palavra-chave", + "Return creates this keyword and gives it to the selected photos": "Enter cria esta palavra-chave e a atribui às fotos selecionadas", + "Separate several keywords with commas. Return gives them to the selected photos.": "Separe várias palavras-chave com vírgulas. Enter as atribui às fotos selecionadas.", "No keywords match": "Nenhuma palavra-chave corresponde", "Keyword Name": "Nome da palavra-chave", "Synonyms": "Sinônimos", @@ -1998,5 +2041,150 @@ "Recognition models": "Modelos de reconhecimento", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Nenhum processador gráfico encontrado: o LightCraft renderiza na CPU, o que é muito mais lento. Detalhes em Ajuda → Informações do sistema.", "On NixOS, start it from `nix develop`.": "No NixOS, inicie-o a partir de `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado." + "Files with the same content as another file in this import: only the first is imported.": "Arquivos com o mesmo conteúdo de outro arquivo desta importação: apenas o primeiro é importado.", + "Lightroom Catalog Import": "Importação de catálogo do Lightroom", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Estas fotos estão apenas na memória. Abra uma biblioteca no disco e importe o catálogo novamente para salvá-las.", + "Show in Library": "Mostrar na biblioteca", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Os catálogos do Lightroom são importados para a biblioteca aberta; nenhuma pasta de catálogo separada é criada.", + "Destination library": "Biblioteca de destino", + "Playback": "Reprodução", + "Layout": "Layout", + "Link all margins": "Vincular todas as margens", + "Margins": "Margens", + "Left": "Esquerda", + "Right": "Direita", + "Top": "Superior", + "Bottom": "Inferior", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "As margens são uma parte do lado menor do slide, então os slides ficam iguais em qualquer tela.", + "Show guides": "Mostrar guias", + "Aspect preview": "Visualização da proporção", + "Zoom to fill frame": "Zoom para preencher o quadro", + "Stroke border": "Borda", + "Colour": "Cor", + "Cast shadow": "Projetar sombra", + "Offset": "Deslocamento", + "Backdrop": "Fundo", + "Background colour": "Cor de fundo", + "Colour wash": "Degradê de cor", + "Background image": "Imagem de fundo", + "(not in the library)": "(não está na biblioteca)", + "Use selected photo": "Usar a foto selecionada", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Selecione uma foto e ative esta opção para preencher com ela o fundo atrás de cada foto.", + "Reset layout": "Redefinir layout", + "Select a photo to preview its slide": "Selecione uma foto para visualizar o slide dela", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "A foto selecionada como slide. A reprodução preenche a tela; a visualização da proporção muda só esta visualização.", + "Overlays": "Sobreposições", + "Identity plate": "Placa de identidade", + "Your name or studio": "Seu nome ou estúdio", + "Anchored to": "Ancorado em", + "Slide": "Slide", + "Render behind image": "Renderizar atrás da imagem", + "Rating stars": "Estrelas de classificação", + "Text overlays": "Sobreposições de texto", + "Add text overlay": "Adicionar sobreposição de texto", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. O restante do texto aparece como digitado.", + "Use export watermark": "Usar a marca d'água de exportação", + "The export watermark, drawn on the photo and sized relative to it.": "A marca d'água de exportação, desenhada sobre a foto e dimensionada em relação a ela.", + "Titles": "Títulos", + "Intro screen": "Tela de abertura", + "Ending screen": "Tela de encerramento", + "Shows": "Mostra", + "Colour only": "Somente cor", + "Override colour": "Substituir cor", + "Own duration (off: the slide duration)": "Duração própria (desativada: a duração do slide)", + "Duration": "Duração", + "Title text": "Texto do título", + "Intro": "Abertura", + "Ending": "Encerramento", + "End": "Fim", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "A abertura é exibida uma vez antes da primeira foto; o encerramento, após a última foto quando Repetir está desativado. A placa de identidade é a definida em Sobreposições. Os títulos fazem transição como os slides e a ordem aleatória nunca os move.", + "This screen is off: turn it on under Titles to play it.": "Esta tela está desativada: ative-a em Títulos para exibi-la.", + "The intro screen, before the first photo.": "A tela de abertura, antes da primeira foto.", + "The ending screen, after the last photo.": "A tela de encerramento, após a última foto.", + "Templates": "Modelos", + "Built-in": "Integrados", + "Your templates": "Seus modelos", + "None yet: save the current settings as a template below.": "Nenhum ainda: salve as configurações atuais como modelo abaixo.", + "Before the last template": "Antes do último modelo", + "Previous settings": "Configurações anteriores", + "New template name": "Nome do novo modelo", + "Save as Template": "Salvar como modelo", + "Import…": "Importar…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Seus próprios modelos precisam de uma pasta de configurações, que esta versão não tem; os integrados funcionam.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Clique em um modelo para aplicá-lo; aponte para um para prévia na foto selecionada. Clique com o botão direito nos seus para atualizar, renomear, exportar ou excluir.", + "Import Slideshow Templates": "Importar modelos de apresentação", + "Export Slideshow Template": "Exportar modelo de apresentação", + "Slideshow Templates": "Modelos de apresentação", + "Plain Black": "Preto simples", + "Gallery Wall": "Parede de galeria", + "Edge to Edge": "De ponta a ponta", + "Cinema Wide": "Cinema widescreen", + "Shooting Data": "Dados da captura", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Cada foto o maior possível, sobre preto, com uma transição curta. Nada desenhado por cima.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Uma parede cinza-clara com passe-partout branco e sombra suave; a legenda e a classificação abaixo da foto.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Cada foto recortada para preencher a tela inteira, com transições lentas.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Um quadro 16:9 com faixas pretas acima e abaixo, o título no canto, transições pelo preto e uma cartela final.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Um fundo ardósia escuro com a exposição, ISO e distância focal, e depois a câmera e a lente, abaixo de cada foto.", + "Exporting slideshow": "Exportando apresentação", + "Export Slideshow…": "Exportar apresentação…", + "Export Slideshow": "Exportar apresentação", + "Export Slideshow: folder for the JPEG files": "Exportar apresentação: pasta para os arquivos JPEG", + "Frame rate": "Taxa de quadros", + "Container": "Contêiner", + "Export PDF…": "Exportar PDF…", + "Export JPEG…": "Exportar JPEG…", + "Export Video…": "Exportar vídeo…", + "Automatically show full screen": "Mostrar automaticamente em tela cheia", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Uma página por slide (o lado maior em pixels = pontos), com a duração e o esmaecimento do slide para o modo de apresentação do visualizador.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Arquivos JPEG numerados (nome-001.jpg…) em uma pasta nova cujo nome é escolhido na próxima etapa.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Vídeo AV1, cada slide exibido pela sua duração com seus esmaecimentos. Sem som: a música ainda não é exportada.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Os slides usam o layout, as sobreposições e as telas de abertura e encerramento da apresentação, na ordem da visualização.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "As fotos da visualização como slides, com os ajustes acima. O progresso aparece na pilha de atividades.", + "Checking raw files": "Verificando arquivos RAW", + "1 raw can now be developed from its raw data": "1 RAW agora pode ser revelado a partir dos dados RAW", + "This raw is now developed from its raw data": "Este RAW agora é revelado a partir dos dados RAW", + "This raw variant still can't be decoded": "Esta variante RAW ainda não pode ser decodificada", + "No lens data embedded in this file (DNG lens opcodes only).": "Nenhum dado de lente incorporado neste arquivo (somente opcodes de lente DNG).", + "Export to": "Exportar para", + "Specific folder": "Pasta específica", + "Same folder as original photo": "Mesma pasta da foto original", + "Tidying the thumbnail cache": "Organizando o cache de miniaturas", + "Reset all edits (Cmd+Shift+R)": "Redefinir todas as edições (Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Álbuns inteligentes se atualizam sozinhos: altere as regras ou use Foto → Excluir foto", + "LightCraft stopped": "O LightCraft parou", + "Show Focus Point": "Mostrar ponto de foco", + "Zoom to Focus Point": "Zoom no ponto de foco", + "Focus": "Foco", + "Focus point": "Ponto de foco", + "Wide": "Amplo", + "Center": "Centro", + "Flexible Spot": "Ponto flexível", + "Zone": "Zona", + "Expanded Flexible Spot": "Ponto flexível expandido", + "Custom AF Area": "Área de AF personalizada", + "Face tracking": "Rastreamento de rosto", + "Lock On AF": "AF com travamento", + "Multi": "Multi", + "Spot": "Ponto", + "Tracking": "Rastreamento", + "Face Tracking": "Rastreamento de rosto", + "Animal Eye Tracking": "Rastreamento de olhos de animais", + "Human Eye Tracking": "Rastreamento de olhos humanos", + "Read Focus Points": "Ler pontos de foco", + "Reading focus points": "Lendo pontos de foco", + "Click to type a value": "Clique para digitar um valor", + "Run face models on the CPU only": "Executar os modelos de rostos só na CPU", + "Detection": "Detecção", + "Recognition": "Reconhecimento", + "Not loaded yet": "Ainda não carregado", + "Checking the GPU…": "Verificando a GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Um modelo de rostos só usa a GPU quando uma verificação mostra que ela dá os mesmos resultados mais rápido.", + "Checking the GPU for faces": "Verificando a GPU para rostos", + "Include selected photos": "Incluir fotos selecionadas", + "Color Overlay": "Sobreposição de cor", + "Color Overlay on B&W": "Sobreposição de cor em P&B", + "Image on Black": "Imagem sobre preto", + "Image on White": "Imagem sobre branco", + "White on Black": "Branco sobre preto" } diff --git a/crates/ui-egui/locales/ru-formats.json b/crates/ui-egui/locales/ru-formats.json index 38e28e356..55d5671aa 100644 --- a/crates/ui-egui/locales/ru-formats.json +++ b/crates/ui-egui/locales/ru-formats.json @@ -38,6 +38,7 @@ "Build Standard-Sized Previews ({scope})": "Создать просмотры стандартного размера ({scope})", "Building previews for {} photos…": "Создание просмотров для фото: {}…", "Click {}:1": "Щелчок {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Цветовое пространство: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не удалось открыть редактор: {e}", "Create {n} Virtual Copies": "Создать виртуальные копии: {n}", "Creates {} stacks from {} of {scope}": "Будет создано стопок: {} из {} фото ({scope})", @@ -64,6 +65,7 @@ "Import cancelled · {} photo{} added": "Импорт отменён · добавлено фото: {}{:.0}", "Import failed: {e}": "Ошибка импорта: {e}", "Import warnings ({n}):\n{warnings}": "Предупреждения импорта ({n}):\n{warnings}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не удалось записать XMP-файлы {n} фото (папка недоступна?). LightCraft продолжает попытки.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профиль XMP \"{profile}\" не применён к {path}; цвета могут отличаться от Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрия Lightroom для {path} не сохранена; режим {mode} будет пересчитан.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрия Lightroom для {path} не сохранена; сохранённое преобразование игнорируется, поскольку Upright выключен.", @@ -166,6 +168,7 @@ "{n} photo{}": "Фото: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Подходящих фото: {n}{:.0} · обновляется автоматически при изменении фото", "{n} photo{} now · updates automatically as photos change": "Сейчас фото: {n}{:.0} · обновляется автоматически при изменении фото", + "{n} selected photos · {pages} pages": "Выбрано фото: {n} · страниц: {pages}", "{n} similar photo{} · View ▸ Clear Filters to see all": "Похожих фото: {n}{:.0} · Вид → Сбросить фильтры, чтобы увидеть все", "{pid} (missing)": "{pid} (отсутствует)", "{sel_n} selected · {counted}": "Выбрано: {sel_n} · {counted}", @@ -235,6 +238,10 @@ "{done} of {total} MB": "{done} из {total} МБ", "{pct} %": "{pct} %", "+{n} more": "ещё {n}", + "Create the keyword “{name}”": "Создать ключевое слово «{name}»", + "new, inside {parent}": "новое, внутри {parent}", + "Keywords of {n} selected photos": "Ключевые слова выбранных фото: {n}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» убрано с фото: {n}. Правка → Отменить вернёт его.", "1 task is still running: {names}. Quitting stops it.": "Ещё выполняется задача: {names}. Выход прервёт её.", "{n} tasks are still running: {names}. Quitting stops them.": "Ещё выполняется задач: {n} — {names}. Выход прервёт их.", "{what} merge cancelled": "{what}: слияние отменено", @@ -245,7 +252,48 @@ "{n} added to Apple Photos album “{album}”": "Добавлено в альбом «{album}» Apple Фото: {n}", "{n} added to Apple Photos": "Добавлено в Apple Фото: {n}", "{n} photos opened for editing; each is stacked with its original": "Открыто для редактирования фото: {n}; каждое объединено в стопку с оригиналом", + " in {g} group{}": " в группах: {g}{:.0}", + " · kept, not applied: {}": " · сохранено, не применяется: {}", + " — kept but not applied: {}": " — сохранено, но не применяется: {}", + "Apply to “{mask}” on Selected Photos": "Применить к «{mask}» на выбранных фото", + "Delete the effect preset “{name}”?": "Удалить пресет эффекта «{name}»?", + "Delete these {n} effect presets?": "Удалить эти пресеты эффектов ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Удалить пресеты эффектов ({n})?{:.0} Это нельзя отменить.", + "Effect: {name}": "Эффект: {name}", + "Exported {} effect preset{}": "Экспортировано пресетов эффектов: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Импортировано эффектов маски: {e}{:.0} (Маски → Эффект)", + "Update “{name}” with Current Settings": "Обновить «{name}» текущими настройками", + "{n} preset{}": "Пресетов: {n}{:.0}", "{name} opened in the other editor": "{name} открыт в другом редакторе", "{n} originals opened in the other editor": "Открыто оригиналов в другом редакторе: {n}", - "· {in_source} identical copies": "· идентичных копий: {in_source}" + "· {in_source} identical copies": "· идентичных копий: {in_source}", + "{count} more warnings.": "Других предупреждений: {count}.", + "{photos} catalog records processed; {collections} collections.": "Обработано записей каталога: {photos}; коллекций: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не найдены оригиналы: {missing}. Их записи каталога доступны; используйте поиск отсутствующих фотографий, чтобы восстановить ссылки.", + "Applied {name}": "Применено: {name}", + "Delete “{name}”?": "Удалить «{name}»?", + "Updated {name}": "Обновлено: {name}", + "Exported to {path}": "Экспортировано в {path}", + "Saved template {name}": "Шаблон {name} сохранён", + "Imported {name}": "Импортировано: {name}", + "Skipped {problem}": "Пропущено: {problem}", + "…and {more} more": "…и ещё {more}", + "Template preview: {name}": "Просмотр шаблона: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в просмотре: {n} · слайдов: {slides} · {time}", + "{n} pages": "Страниц: {n}", + "{n} JPEG files": "Файлов JPEG: {n}", + "{s} s of AV1 video": "{s} с видео AV1", + "Slideshow exported: {what}": "Слайд-шоу экспортировано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не удалось отрисовать)", + "{n} raws can now be developed from their raw data": "RAW-файлов, которые теперь можно проявлять из RAW-данных: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft неожиданно остановился: {e}\n\nПодробности в файле журнала: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft неожиданно остановился: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Переименовывает {n} лиц в библиотеке. Файлы не изменяются.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже есть среди людей. Объединение оставляет одного человека; отмена снова разделяет их.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: слияние добавлено в размере {mp}: для полного размера ({full}) не хватает памяти этого компьютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: слияние добавлено в размере {mp} — это наибольший размер панорамы (полный размер: {full})", + "Already in “{}”": "Уже в «{}»", + "Move {n} photo{} to “{}”": "Переместить в «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Переместить в «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "В «{1}» перемещено фото: {n}{0:.0}. Правка → Отменить вернёт их." } diff --git a/crates/ui-egui/locales/ru.json b/crates/ui-egui/locales/ru.json index c840218a0..5ead5101f 100644 --- a/crates/ui-egui/locales/ru.json +++ b/crates/ui-egui/locales/ru.json @@ -2,8 +2,10 @@ "+ Group": "+ Группа", "+ Rule": "+ Условие", "0: nothing is rejected.": "0: ничего не отклоняется.", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · текущие правки · вписано без обрезки", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "Фильтр скрывает фото в этом представлении: измените его или сбросьте (Вид → Сбросить фильтры)", "A one-time download of about": "Однократная загрузка около", + "A pen's pressure sets the size and flow of the brush": "Нажим пера задаёт размер и поток кисти", "About LightCraft": "О программе LightCraft", "Activity": "Активность", "Actual size": "Реальный размер", @@ -23,6 +25,9 @@ "Add to Album": "Добавить в альбом", "Add to Favorites": "Добавить в избранное", "Add to My Photos": "Добавить в «Мои фото»", + "Can't be moved here": "Сюда переместить нельзя", + "This row covers more than one disk: drop on a folder inside it": "Эта строка охватывает несколько дисков: перетащите на папку внутри неё", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальных фото ещё нет в библиотеке: сначала «Добавить в «Мои фото»», затем перемещайте", "Add to Selected Photos": "Добавить к выбранным фото", "Add to Target Album": "Добавить в целевой альбом", "Add to this mask": "Добавить к этой маске", @@ -164,6 +169,7 @@ "Choose Edit Settings to Copy": "Выбрать настройки для копирования", "Choose Edit Settings to Copy…": "Выбрать настройки для копирования…", "Choose Folder…": "Выбрать папку…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "После подтверждения выберите, куда сохранить PDF. Печатайте PDF из программы просмотра.", "Choose a photo in the grid or filmstrip": "Выберите фото в сетке или на ленте", "Choose…": "Выбрать…", "Cinematic": "Кинематографичный", @@ -208,6 +214,9 @@ "Color Range": "Цветовой диапазон", "Color label cleared": "Цветовая метка сброшена", "Color space": "Цветовое пространство", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Цветовое пространство: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Цветовое пространство: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Цветовое пространство: Rec. 2020 PQ, 16 бит (HDR PNG)", "Color space: sRGB (AVIF)": "Цветовое пространство: sRGB (AVIF)", "Combine files…": "Объединить файлы…", "Command Journal": "Журнал команд", @@ -221,7 +230,14 @@ "Concrete Cool": "Холодный бетон", "Confirm before deleting": "Подтверждать удаление", "Confirm before moving photos to Recently Deleted": "Подтверждать перемещение фото в «Недавно удалённые»", + "Move Photos": "Перемещение фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Их файлы и XMP-файлы перемещаются на диске. Правка → Отменить вернёт их.", + "Don't ask again": "Больше не спрашивать", + "Another library was opened since the photos were dropped: nothing was moved": "После перетаскивания фото была открыта другая библиотека: ничего не перемещено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Настройки → «Подтверждать перемещение фото в другую папку на диске» включает его снова", + "Confirm before moving photos to another folder on disk": "Подтверждать перемещение фото в другую папку на диске", "Constrain Crop": "Ограничить кадрирование", + "Contact Sheet PDF": "PDF контактного листа", "Continue Without Saving": "Продолжить без сохранения", "Contrast": "Контрастность", "Convert to DNG": "Преобразовать в DNG", @@ -406,6 +422,7 @@ "File Size": "Размер файла", "File name": "Имя файла", "File types:": "Типы файлов:", + "Filename captions": "Подписи с именем файла", "Fill Edges": "Заполнить края", "Film": "Плёнка", "Filmstrip": "Лента", @@ -455,6 +472,10 @@ "Friday": "Пятница", "Full": "Полный", "Full Screen Preview": "Полноэкранный просмотр", + "Hold View Until Refreshed": "Удерживать вид до обновления", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF нужен 32-битный формат с плавающей точкой.", + "HDR output": "Вывод HDR", + "Letter": "Letter", "Lights Out": "Выключить свет", "Full screen mode": "Полноэкранный режим", "Full size": "Полный размер", @@ -501,6 +522,7 @@ "HDR Panorama with Last Settings": "HDR-панорама с последними настройками", "HDR Panorama…": "HDR-панорама…", "HDR with Last Settings": "HDR с последними настройками", + "HDR for Each Bracket Set": "HDR для каждого брекетинга", "HDR…": "HDR…", "Heal": "Лечение", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "Убирать мелкие мягкие тёмные пятна от пыли на матрице на ровных участках", @@ -752,7 +774,7 @@ "No edits yet.": "Правок пока нет.", "No exposure information": "Нет данных об экспозиции", "No filter presets yet": "Пресетов фильтров пока нет", - "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", + "No lens data embedded in this file.": "В этом файле нет данных об объективе.", "No masks yet. Choose a mask type above.": "Масок пока нет. Выберите тип маски выше.", "No matching commands": "Подходящих команд нет", "No matching photos": "Нет подходящих фото", @@ -760,6 +782,11 @@ "No new presets": "Новых пресетов нет", "No photo selected": "Фото не выбрано", "No photos": "Нет фото", + "Your library is empty": "Библиотека пуста", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Импортированные фото появятся здесь. Ничего не копируется и не перемещается, пока вы сами этого не выберете.", + "or drop photos and folders into this window": "или перетащите фото и папки в это окно", + "Import photos to get started": "Импортируйте фото, чтобы начать", + "or drop photos into this window": "или перетащите фото в это окно", "No photos found": "Фото не найдены", "No photos in this folder": "В этой папке нет фото", "No photos match the active filters": "Нет фото, соответствующих активным фильтрам", @@ -785,6 +812,7 @@ "Open Library": "Открыть библиотеку", "Open Library…": "Открыть библиотеку…", "Open Log Folder": "Открыть папку журналов", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, обработанные в HDR: JPEG с картой усиления HDR (выглядит правильно везде, на HDR-экранах светлые участки ярче), AVIF — 10 бит, PNG и JPEG XL — 16 бит Rec. 2020 PQ; для TIFF нужен 32-битный формат с плавающей точкой", "Reading photos…": "Чтение фото…", "Adding photos…": "Добавление фото…", "Open in": "Открыть в", @@ -827,6 +855,7 @@ "Paste Settings from Previous": "Вставить настройки из предыдущего", "Paste pages": "Вставить страницы", "Pastel Haze": "Пастельная дымка", + "Pen Pressure": "Нажим пера", "Percent": "Проценты", "Percentage": "Процент", "Performance": "Производительность", @@ -942,6 +971,7 @@ "Refine Mask Edges": "Уточнить края маски", "Refresh Source": "Обновить источник", "Refresh Source (/)": "Обновить источник (/)", + "Refresh View": "Обновить вид", "Reject": "Отклонить", "Reject below focus": "Отклонять при недостаточной резкости", "Rejected": "Отклонено", @@ -994,7 +1024,7 @@ "Reset Section": "Сбросить раздел", "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)", + "Reset all edits": "Сбросить все правки", "Reshuffle": "Перемешать", "Resize panel window": "Изменить размер окна панели", "Resize panels": "Изменить размеры панелей", @@ -1129,6 +1159,7 @@ "Show overlay (O)": "Показать наложение (O)", "Show ratings, flags and edit badges": "Показывать оценки, флаги и значки правок", "Show the Navigator while zoomed in": "Показывать навигатор при увеличении", + "Show the import review when a camera or card is connected": "Открывать импорт при подключении камеры или карты", "Show the template tags; click one to insert it": "Показать теги шаблона; щёлкните по тегу, чтобы вставить его", "Show/Hide Mask": "Показать/скрыть маску", "Show/hide My Photos panel": "Показать/скрыть панель «Мои фото»", @@ -1152,6 +1183,7 @@ "Soft Pastel": "Мягкий пастельный", "Soft Proofing": "Экранная цветопроба", "Soft Skin": "Мягкая кожа", + "Solo Mode": "Режим соло", "Sort": "Сортировка", "Sort by": "Сортировать по", "Source": "Источник", @@ -1280,6 +1312,7 @@ "Vibrance": "Красочность", "Videos": "Видео", "View": "Вид", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Программы, не читающие блок cICP в PNG, показывают значения PQ как sRGB: плоско и блекло. Чтобы изображение везде выглядело правильно, используйте JPEG (карта усиления).", "Vignette": "Виньетка", "Vignetting": "Виньетирование", "Vintage": "Винтаж", @@ -1309,6 +1342,7 @@ "Width × Height": "Ширина × высота", "Window": "Окно", "Winter Blue": "Зимняя синева", + "Wrote the XMP sidecars that were waiting": "Ожидавшие XMP-файлы записаны", "XMP Sidecar Preferences": "Настройки XMP-файлов", "XMP sidecars": "XMP-файлы", "Year": "Год", @@ -1428,7 +1462,7 @@ "commercial use subject to GPL conditions": "коммерческое использование на условиях GPL", "Decrease Rating": "Уменьшить оценку", "Increase Rating": "Повысить оценку", - "Click to type a value": "Щёлкните, чтобы ввести значение", + "Click to type a value, drag to change it": "Щёлкните, чтобы ввести значение, или перетащите, чтобы изменить", "Angle": "Угол", "Raw": "RAW", "Video": "Видео", @@ -1528,6 +1562,15 @@ "Keyword List": "Список ключевых слов", "Filter Keywords": "Фильтровать ключевые слова", "No keywords yet": "Ключевых слов пока нет", + "Show fewer": "Показать меньше", + "Whole names": "Полные имена", + "Grouped by parent": "По родительскому ключевому слову", + "Keywords of the selection": "Ключевые слова выбранных фото", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Сгруппированы: ключевые слова внутри одного ключевого слова стоят под строкой с его именем; длинные списки и подсказки сокращаются.", + "Show every keyword of the selected photos": "Показать все ключевые слова выбранных фото", + "new keyword": "новое ключевое слово", + "Return creates this keyword and gives it to the selected photos": "Клавиша Enter создаст это ключевое слово и добавит его выбранным фото", + "Separate several keywords with commas. Return gives them to the selected photos.": "Несколько ключевых слов разделяйте запятыми. Клавиша Enter добавит их выбранным фото.", "No keywords match": "Подходящих ключевых слов нет", "Keyword Name": "Название ключевого слова", "Synonyms": "Синонимы", @@ -2151,5 +2194,144 @@ "Adding to Apple Photos": "Добавление в Apple Фото", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графический процессор не найден: LightCraft выполняет рендеринг на CPU, а это намного медленнее. Подробности: Справка → Сведения о системе.", "On NixOS, start it from `nix develop`.": "В NixOS запускайте его из `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый." + "Files with the same content as another file in this import: only the first is imported.": "Файлы с тем же содержимым, что и другой файл этого импорта: импортируется только первый.", + "Lightroom Catalog Import": "Импорт каталога Lightroom", + "Destination library": "Библиотека назначения", + "Show in Library": "Показать в библиотеке", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom импортируются в открытую библиотеку; отдельная папка каталога не создаётся.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Эти фотографии находятся только в памяти. Откройте библиотеку на диске и импортируйте каталог повторно, чтобы сохранить их.", + "Playback": "Воспроизведение", + "Layout": "Макет", + "Link all margins": "Связать все поля", + "Margins": "Поля", + "Left": "Слева", + "Right": "Справа", + "Top": "Сверху", + "Bottom": "Снизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаются долей короткой стороны слайда, поэтому слайды выглядят одинаково на любом экране.", + "Show guides": "Показывать направляющие", + "Aspect preview": "Предпросмотр пропорций", + "Zoom to fill frame": "Масштабировать по рамке", + "Stroke border": "Обводка", + "Colour": "Цвет", + "Cast shadow": "Отбрасывать тень", + "Offset": "Смещение", + "Backdrop": "Фон", + "Background colour": "Цвет фона", + "Colour wash": "Цветовой градиент", + "Background image": "Фоновое изображение", + "(not in the library)": "(нет в библиотеке)", + "Use selected photo": "Использовать выбранное фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Выберите фото и включите этот параметр, чтобы заполнить им фон за каждым фото.", + "Reset layout": "Сбросить макет", + "Select a photo to preview its slide": "Выберите фото, чтобы увидеть его слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Выбранное фото в виде слайда. При воспроизведении слайд заполняет экран; предпросмотр пропорций меняет только этот предпросмотр.", + "Overlays": "Наложения", + "Identity plate": "Фирменная табличка", + "Your name or studio": "Ваше имя или студия", + "Anchored to": "Привязка к", + "Slide": "Слайд", + "Render behind image": "Рисовать за изображением", + "Rating stars": "Звёзды рейтинга", + "Text overlays": "Текстовые наложения", + "Add text overlay": "Добавить текстовое наложение", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токены: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Остальной текст выводится как написан.", + "Use export watermark": "Использовать водяной знак экспорта", + "The export watermark, drawn on the photo and sized relative to it.": "Водяной знак экспорта, рисуется на фото и масштабируется относительно него.", + "Titles": "Титры", + "Intro screen": "Вступительный экран", + "Ending screen": "Заключительный экран", + "Shows": "Показывает", + "Colour only": "Только цвет", + "Override colour": "Заменить цвет", + "Own duration (off: the slide duration)": "Своя длительность (выкл.: длительность слайда)", + "Duration": "Длительность", + "Title text": "Текст титра", + "Intro": "Вступление", + "Ending": "Финал", + "End": "Конец", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступление показывается один раз перед первым фото, финал — после последнего фото, если повтор выключен. Фирменная табличка — та, что задана в разделе «Наложения». Титры появляются и исчезают как слайды, а случайный порядок их не перемещает.", + "This screen is off: turn it on under Titles to play it.": "Этот экран выключен: включите его в разделе «Титры», чтобы показывать.", + "The intro screen, before the first photo.": "Вступительный экран, перед первым фото.", + "The ending screen, after the last photo.": "Заключительный экран, после последнего фото.", + "Templates": "Шаблоны", + "Built-in": "Встроенные", + "Your templates": "Ваши шаблоны", + "None yet: save the current settings as a template below.": "Пока нет: сохраните текущие настройки как шаблон ниже.", + "Before the last template": "До последнего шаблона", + "Previous settings": "Предыдущие настройки", + "New template name": "Имя нового шаблона", + "Save as Template": "Сохранить как шаблон", + "Import…": "Импорт…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Для своих шаблонов нужна папка настроек, которой в этой сборке нет; встроенные работают.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Щёлкните шаблон, чтобы применить его; наведите указатель, чтобы увидеть его на выбранном фото. Щёлкните свой шаблон правой кнопкой, чтобы обновить, переименовать, экспортировать или удалить его.", + "Import Slideshow Templates": "Импорт шаблонов слайд-шоу", + "Export Slideshow Template": "Экспорт шаблона слайд-шоу", + "Slideshow Templates": "Шаблоны слайд-шоу", + "Plain Black": "Просто чёрный", + "Gallery Wall": "Стена галереи", + "Edge to Edge": "От края до края", + "Cinema Wide": "Широкий экран", + "Shooting Data": "Данные съёмки", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Каждое фото как можно крупнее на чёрном, с короткой сменой наплывом. Поверх ничего.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Светло-серая стена с белым паспарту и мягкой тенью; подпись и рейтинг под фото.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Каждое фото обрезано на весь экран, с медленной сменой наплывом.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 с чёрными полосами сверху и снизу, название в углу, затемнения через чёрный и финальная карточка.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Тёмный грифельный фон с экспозицией, ISO и фокусным расстоянием, а ниже камерой и объективом под каждым фото.", + "Exporting slideshow": "Экспорт слайд-шоу", + "Export Slideshow…": "Экспортировать слайд-шоу…", + "Export Slideshow": "Экспорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Экспорт слайд-шоу: папка для файлов JPEG", + "Frame rate": "Частота кадров", + "Container": "Контейнер", + "Export PDF…": "Экспорт в PDF…", + "Export JPEG…": "Экспорт в JPEG…", + "Export Video…": "Экспорт видео…", + "Automatically show full screen": "Автоматически открывать во весь экран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна страница на слайд (длинная сторона в пикселях = пунктах), с длительностью и переходом слайда для режима презентации в просмотрщике.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумерованные файлы JPEG (имя-001.jpg…) в новой папке, имя которой задаётся на следующем шаге.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Видео AV1: каждый слайд показывается свою длительность, с переходами. Без звука: музыка пока не экспортируется.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайды используют макет, наложения, вступительный и финальный экраны слайд-шоу, в порядке просмотра.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографии в просмотре как слайды, с настройками выше. Ход выполнения виден в списке задач.", + "Checking raw files": "Проверка RAW-файлов", + "1 raw can now be developed from its raw data": "1 RAW-файл теперь можно проявлять из RAW-данных", + "This raw is now developed from its raw data": "Этот RAW-файл теперь проявляется из RAW-данных", + "This raw variant still can't be decoded": "Этот вариант RAW пока не удаётся декодировать", + "No lens data embedded in this file (DNG lens opcodes only).": "В этом файле нет данных об объективе (только опкоды объектива DNG).", + "Export to": "Экспорт в", + "Specific folder": "Указанная папка", + "Same folder as original photo": "Та же папка, что и у оригинала", + "Tidying the thumbnail cache": "Упорядочивание кэша миниатюр", + "Reset all edits (Cmd+Shift+R)": "Сбросить все правки (Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Смарт-альбомы обновляются сами: измените их правила или выберите Фото → Удалить фото", + "LightCraft stopped": "LightCraft остановился", + "Show Focus Point": "Показать точку фокусировки", + "Zoom to Focus Point": "Увеличить по точке фокусировки", + "Focus": "Фокус", + "Focus point": "Точка фокусировки", + "Wide": "Широкая", + "Center": "Центр", + "Flexible Spot": "Гибкая точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Расширенная гибкая точка", + "Custom AF Area": "Своя зона АФ", + "Face tracking": "Отслеживание лиц", + "Lock On AF": "АФ с захватом", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Слежение", + "Face Tracking": "Отслеживание лиц", + "Animal Eye Tracking": "Слежение за глазами животных", + "Human Eye Tracking": "Слежение за глазами людей", + "Read Focus Points": "Прочитать точки фокусировки", + "Reading focus points": "Чтение точек фокусировки", + "Click to type a value": "Щёлкните, чтобы ввести значение", + "Run face models on the CPU only": "Запускать модели лиц только на ЦП", + "Detection": "Обнаружение", + "Recognition": "Распознавание", + "Not loaded yet": "Ещё не загружена", + "Checking the GPU…": "Проверка ГП…", + "CPU": "ЦП", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель лиц использует ГП, только если проверка показала, что он даёт те же результаты быстрее.", + "Checking the GPU for faces": "Проверка ГП для лиц" } diff --git a/crates/ui-egui/locales/uk-formats.json b/crates/ui-egui/locales/uk-formats.json index 55636422f..4cf3dc80b 100644 --- a/crates/ui-egui/locales/uk-formats.json +++ b/crates/ui-egui/locales/uk-formats.json @@ -36,6 +36,7 @@ "Build Standard-Sized Previews ({scope})": "Створити перегляди стандартного розміру ({scope})", "Building previews for {} photos…": "Створення переглядів для фото: {}…", "Click {}:1": "Клацання {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "Колірний простір: Rec. 2020 {curve} (HDR AVIF)", "Couldn't open the editor: {e}": "Не вдалося відкрити редактор: {e}", "Create {n} Virtual Copies": "Створити віртуальні копії: {n}", "Creates {} stacks from {} of {scope}": "Буде створено стосів: {} із {} ({scope})", @@ -65,6 +66,7 @@ "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} (Оглядач профілів → їхні групи)", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "Не вдалося записати XMP-файли {n} фото (папка недоступна?). LightCraft продовжує спроби.", "XMP profile \"{profile}\" was not applied for {path}; colors may differ from Lightroom.": "Профіль XMP «{profile}» не застосовано до {path}; кольори можуть відрізнятися від Lightroom.", "Lightroom geometry was not preserved for {path}; {mode} will be recalculated.": "Геометрію Lightroom для {path} не збережено; режим {mode} буде перераховано.", "Lightroom geometry for {path} was not preserved; saved transform is ignored because Upright is Off.": "Геометрію Lightroom для {path} не збережено; збережене перетворення ігнорується, оскільки Upright вимкнено.", @@ -161,6 +163,7 @@ "{n} photo{}": "Фото: {n}{:.0}", "{n} photo{} match · updates automatically as photos change": "Відповідних фото: {n}{:.0} · оновлюється автоматично зі зміною фото", "{n} photo{} now · updates automatically as photos change": "Зараз фото: {n}{:.0} · оновлюється автоматично зі зміною фото", + "{n} selected photos · {pages} pages": "Вибрано фото: {n} · сторінок: {pages}", "{pid} (missing)": "{pid} (відсутній)", "{sel_n} selected · {counted}": "Вибрано: {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", @@ -235,6 +238,10 @@ "{done} of {total} MB": "{done} з {total} МБ", "{pct} %": "{pct} %", "+{n} more": "+{n} ще", + "Create the keyword “{name}”": "Створити ключове слово «{name}»", + "new, inside {parent}": "нове, всередині {parent}", + "Keywords of {n} selected photos": "Ключові слова вибраних фото: {n}", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "«{name}» вилучено з фото: {n}. Редагування → Скасувати поверне його.", "1 task is still running: {names}. Quitting stops it.": "1 завдання ще виконується: {names}. Вихід зупинить його.", "{n} tasks are still running: {names}. Quitting stops them.": "Ще виконуються завдання ({n}): {names}. Вихід зупинить їх.", "{what} merge cancelled": "Об’єднання {what} скасовано", @@ -245,7 +252,48 @@ "{n} added to Apple Photos album “{album}”": "Додано до альбому Apple Фото «{album}»: {n}", "{n} added to Apple Photos": "Додано до Apple Фото: {n}", "{n} photos opened for editing; each is stacked with its original": "Відкрито для редагування фото: {n}; кожне об’єднано в стос з оригіналом", + " in {g} group{}": " у групах: {g}{:.0}", + " · kept, not applied: {}": " · збережено, не застосовується: {}", + " — kept but not applied: {}": " — збережено, але не застосовується: {}", + "Apply to “{mask}” on Selected Photos": "Застосувати до «{mask}» на вибраних фото", + "Delete the effect preset “{name}”?": "Видалити пресет ефекту «{name}»?", + "Delete these {n} effect presets?": "Видалити ці пресети ефектів ({n})?", + "Delete {n} effect preset{}? This can't be undone.": "Видалити пресети ефектів ({n})?{:.0} Це не можна скасувати.", + "Effect: {name}": "Ефект: {name}", + "Exported {} effect preset{}": "Експортовано пресетів ефектів: {}{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "Імпортовано ефектів маски: {e}{:.0} (Маски → Ефект)", + "Update “{name}” with Current Settings": "Оновити «{name}» поточними налаштуваннями", + "{n} preset{}": "Пресетів: {n}{:.0}", "{name} opened in the other editor": "{name} відкрито в іншому редакторі", "{n} originals opened in the other editor": "Відкрито оригіналів в іншому редакторі: {n}", - "· {in_source} identical copies": "· ідентичних копій: {in_source}" + "· {in_source} identical copies": "· ідентичних копій: {in_source}", + "{count} more warnings.": "Інших попереджень: {count}.", + "{photos} catalog records processed; {collections} collections.": "Оброблено записів каталогу: {photos}; колекцій: {collections}.", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "Не знайдено оригінали: {missing}. Їхні записи каталогу доступні; скористайтеся пошуком відсутніх фотографій, щоб відновити посилання.", + "Applied {name}": "Застосовано: {name}", + "Delete “{name}”?": "Видалити «{name}»?", + "Updated {name}": "Оновлено: {name}", + "Exported to {path}": "Експортовано до {path}", + "Saved template {name}": "Шаблон {name} збережено", + "Imported {name}": "Імпортовано: {name}", + "Skipped {problem}": "Пропущено: {problem}", + "…and {more} more": "…і ще {more}", + "Template preview: {name}": "Перегляд шаблону: {name}", + "{n} photos in view · {slides} slides · {time}": "Фото в перегляді: {n} · слайдів: {slides} · {time}", + "{n} pages": "Сторінок: {n}", + "{n} JPEG files": "Файлів JPEG: {n}", + "{s} s of AV1 video": "{s} с відео AV1", + "Slideshow exported: {what}": "Слайд-шоу експортовано: {what}", + " · {n} skipped (could not be rendered)": " · пропущено: {n} (не вдалося відтворити)", + "{n} raws can now be developed from their raw data": "RAW-файлів, які тепер можна проявляти з RAW-даних: {n}", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft несподівано зупинився: {e}\n\nПодробиці у файлі журналу: {path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft несподівано зупинився: {e}", + "Renames {n} face{} across the library. The files are not changed.": "Перейменовує {n} облич у бібліотеці. Файли не змінюються.{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "«{to}» уже є серед людей. Об’єднання лишає одну людину; скасування знову розділяє їх.", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "{what}: об’єднання додано в розмірі {mp}: для повного розміру ({full}) бракує пам’яті цього комп’ютера", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "{what}: об’єднання додано в розмірі {mp} — це найбільший розмір панорами (повний розмір: {full})", + "Already in “{}”": "Уже в «{}»", + "Move {n} photo{} to “{}”": "Перемістити до «{1}» фото: {n}{0:.0}", + "Move {n} photo{} to “{}”?": "Перемістити до «{1}» фото: {n}{0:.0}?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "До «{1}» переміщено фото: {n}{0:.0}. Редагування → Скасувати поверне їх." } diff --git a/crates/ui-egui/locales/uk.json b/crates/ui-egui/locales/uk.json index 85a838098..fbdef1c5f 100644 --- a/crates/ui-egui/locales/uk.json +++ b/crates/ui-egui/locales/uk.json @@ -1,5 +1,7 @@ { "% of all commits": "% усіх комітів", + "A pen's pressure sets the size and flow of the brush": "Натиск пера задає розмір і потік пензля", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · поточні правки · вписано без обрізання", "About": "Про програму", "Add": "Додати", "Add to": "Додати до", @@ -30,6 +32,7 @@ "Cancel": "Скасувати", "Caption": "Підпис", "Change": "Змінити", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "Після підтвердження виберіть, куди зберегти PDF. Друкуйте PDF із програми перегляду.", "Choose…": "Вибрати…", "City": "Місто", "Clarity": "Чіткість", @@ -45,10 +48,14 @@ "Color Priority": "Пріоритет кольору", "Color Range": "Колірний діапазон", "Color space": "Колірний простір", + "Color space: Rec. 2020 PQ (HDR AVIF)": "Колірний простір: Rec. 2020 PQ (HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "Колірний простір: Rec. 2020 PQ (HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "Колірний простір: Rec. 2020 PQ, 16 біт (HDR PNG)", "Colors": "Кольори", "Commits": "Коміти", "Company": "Компанія", "Compression": "Стиснення", + "Contact Sheet PDF": "PDF контактного аркуша", "Contrast": "Контрастність", "Contributors": "Учасники", "Copy": "Копіювати", @@ -90,12 +97,16 @@ "Favorites": "Улюблені", "Feather": "Розтушовування", "File": "Файл", + "Filename captions": "Підписи з назвою файлу", "Fill": "Заливка", "First": "Перший", "First commit": "Перший коміт", "Focal Length": "Фокусна відстань", "Grab bag": "Випадкова добірка", "Group name": "Назва групи", + "Hold View Until Refreshed": "Утримувати вигляд до оновлення", + "HDR TIFF needs 32-bit float.": "Для HDR TIFF потрібен 32-бітний формат із рухомою комою.", + "HDR output": "Вивід HDR", "Home": "Домашня тека", "Horizontal inset (% of short edge)": "Відступ по горизонталі (% короткого краю)", "ISO Speed": "Чутливість ISO", @@ -105,6 +116,7 @@ "Label": "Мітка", "Last commit": "Останній коміт", "Lens Blur": "Розмиття об’єктива", + "Letter": "Letter", "Line delta": "Різниця рядків", "Linear": "Лінійний", "Lines +/−": "Рядки +/−", @@ -121,6 +133,8 @@ "No contributor data was built into this copy.": "У цю копію не вбудовано дані про учасників.", "No matching commands": "Немає відповідних команд", "No model credits were built into this copy.": "У цю копію не вбудовано дані про використані моделі.", + "Pen Pressure": "Натиск пера", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "Фото, оброблені в HDR: JPEG із картою підсилення HDR (виглядає правильно всюди, на HDR-екранах світлі ділянки яскравіші), AVIF — 10 біт, PNG і JPEG XL — 16 біт Rec. 2020 PQ; для TIFF потрібен 32-бітний формат із рухомою комою", "Press keys…": "Натисніть клавіші…", "Preview unavailable": "Перегляд недоступний", "Radial": "Радіальний", @@ -233,6 +247,7 @@ "Red": "Червоний", "Red Eye": "Червоні очі", "Redo": "Повторити", + "Refresh View": "Оновити вигляд", "Remove": "Видалити", "Rename": "Перейменувати", "Rename…": "Перейменувати…", @@ -300,11 +315,13 @@ "Vertical inset (% of short edge)": "Відступ по вертикалі (% короткого краю)", "Vibrance": "Соковитість", "View": "Вигляд", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "Програми, які не читають блок cICP у PNG, показують значення PQ як sRGB: пласко й вицвіло. Щоб зображення всюди виглядало правильно, використовуйте JPEG (карта підсилення).", "Vignetting": "Віньєтування", "Visible": "Видимий", "Whites": "Білі тони", "Width": "Ширина", "Window": "Вікно", + "Wrote the XMP sidecars that were waiting": "XMP-файли, що очікували, записано", "Yellow": "Жовтий", "Yes": "Так", "Zoom": "Масштаб", @@ -376,6 +393,9 @@ "Add to Favorites": "Додати до улюблених", "Add to Local": "Додати до локальних", "Add to My Photos": "Додати до моїх фото", + "Can't be moved here": "Сюди перемістити не можна", + "This row covers more than one disk: drop on a folder inside it": "Цей рядок охоплює кілька дисків: перетягніть на теку всередині нього", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "Локальних фото ще немає в бібліотеці: спершу «Додати до моїх фото», потім переміщуйте", "Add to Selected Photos": "Додати до вибраних фото", "Add to Target Album": "Додати до цільового альбому", "Add to this mask": "Додати до цієї маски", @@ -547,6 +567,12 @@ "Concrete Cool": "Холодний бетон", "Confirm before deleting": "Підтверджувати перед видаленням", "Confirm before moving photos to Recently Deleted": "Підтверджувати перед переміщенням фото до нещодавно видалених", + "Move Photos": "Переміщення фото", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "Їхні файли та XMP-файли переміщуються на диску. Редагування → Скасувати поверне їх.", + "Don't ask again": "Більше не питати", + "Another library was opened since the photos were dropped: nothing was moved": "Після перетягування фото було відкрито іншу бібліотеку: нічого не переміщено", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "Налаштування → «Підтверджувати перед переміщенням фото до іншої теки на диску» вмикає його знову", + "Confirm before moving photos to another folder on disk": "Підтверджувати перед переміщенням фото до іншої теки на диску", "Constrain Crop": "Обмежити кадрування", "Continue": "Продовжити", "Convert to DNG": "Перетворити на DNG", @@ -747,6 +773,7 @@ "HDR panorama": "HDR-панорама", "HDR panoramas": "HDR-панорами", "HDR with Last Settings": "HDR з останніми налаштуваннями", + "HDR for Each Bracket Set": "HDR для кожної серії експозицій", "Has Edits": "Має зміни", "Has GPS": "Має GPS", "High Contrast B&W": "Контрастне чорно-біле", @@ -1086,13 +1113,18 @@ "No edits yet.": "Змін поки немає.", "No exposure information": "Немає інформації про експозицію", "No filter presets yet": "Наборів налаштувань фільтрів поки немає", - "No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).", + "No lens data embedded in this file.": "У цьому файлі немає даних об’єктива.", "No masks yet. Choose a mask type above.": "Масок поки немає. Виберіть тип маски вище.", "No matching photos": "Немає відповідних фото", "No metadata found": "Метаданих не знайдено", "No new presets": "Немає нових наборів налаштувань", "No photo selected": "Фото не вибрано", "No photos": "Немає фото", + "Your library is empty": "Бібліотека порожня", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "Імпортовані фото з’являться тут. Нічого не копіюється й не переміщується, доки ви самі цього не оберете.", + "or drop photos and folders into this window": "або перетягніть фото й теки в це вікно", + "Import photos to get started": "Імпортуйте фото, щоб почати", + "or drop photos into this window": "або перетягніть фото в це вікно", "No photos found": "Фото не знайдено", "No photos in this folder": "У цій теці немає фото", "No photos match the active filters": "Жодне фото не відповідає активним фільтрам", @@ -1228,7 +1260,7 @@ "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)", + "Reset all edits": "Скинути всі зміни", "Resize Face Box": "Змінити розмір рамки обличчя", "Resize to": "Змінити розмір до", "Resolution (ppi)": "Роздільність (ppi)", @@ -1351,6 +1383,7 @@ "Show overlay (O)": "Показати накладання (O)", "Show ratings, flags and edit badges": "Показати оцінки, прапорці й позначки змін", "Show the Navigator while zoomed in": "Показувати навігатор під час збільшення", + "Show the import review when a camera or card is connected": "Відкривати імпорт під час підключення камери або карти", "Show the template tags; click one to insert it": "Показати теги шаблону; клацніть тег, щоб вставити його", "Show/Hide Mask": "Показати/приховати маску", "Show/hide My Photos panel": "Показати/приховати панель моїх фото", @@ -1374,6 +1407,7 @@ "Soft Pastel": "М’яка пастель", "Soft Proofing": "Екранна кольоропроба", "Soft Skin": "М’яка шкіра", + "Solo Mode": "Режим соло", "Sort by": "Сортувати за", "Spherical": "Сферична", "Split Stack": "Розділити стос", @@ -1939,7 +1973,7 @@ "Recognition models": "Моделі розпізнавання", "Decrease Rating": "Зменшити оцінку", "Increase Rating": "Збільшити оцінку", - "Click to type a value": "Клацніть, щоб ввести значення", + "Click to type a value, drag to change it": "Клацніть, щоб ввести значення, або перетягніть, щоб змінити", "Angle": "Кут", "Video": "Відео", "Copyrighted": "Захищено авторським правом", @@ -2003,6 +2037,15 @@ "Keyword List": "Список ключових слів", "Filter Keywords": "Фільтрувати ключові слова", "No keywords yet": "Ключових слів ще немає", + "Show fewer": "Показати менше", + "Whole names": "Повні назви", + "Grouped by parent": "За батьківським ключовим словом", + "Keywords of the selection": "Ключові слова вибраних фото", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "Згруповано: ключові слова всередині одного ключового слова стоять під рядком із його назвою; довгі списки та пропозиції скорочуються.", + "Show every keyword of the selected photos": "Показати всі ключові слова вибраних фото", + "new keyword": "нове ключове слово", + "Return creates this keyword and gives it to the selected photos": "Клавіша Enter створить це ключове слово й додасть його вибраним фото", + "Separate several keywords with commas. Return gives them to the selected photos.": "Кілька ключових слів розділяйте комами. Клавіша Enter додасть їх вибраним фото.", "No keywords match": "Немає відповідних ключових слів", "Keyword Name": "Назва ключового слова", "Synonyms": "Синоніми", @@ -2079,14 +2122,14 @@ "Finding faces": "Пошук облич", "Synchronizing folder": "Синхронізація папки", "Looking for changes": "Пошук змін", - "Arrange Panel": "Arrange Panel", - "Close panel": "Close panel", - "Float panel": "Float panel", - "Move to group": "Move to group", - "Panels": "Panels", - "Reset Panel Layout": "Reset Panel Layout", - "Resize panel window": "Resize panel window", - "Resize panels": "Resize panels", + "Arrange Panel": "Розташувати панель", + "Close panel": "Закрити панель", + "Float panel": "Зробити панель плаваючою", + "Move to group": "Перемістити до групи", + "Panels": "Панелі", + "Reset Panel Layout": "Скинути розташування панелей", + "Resize panel window": "Змінити розмір вікна панелі", + "Resize panels": "Змінити розміри панелей", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "Це каталог Lightroom, а не бібліотека LightCraft. Імпортувати його до бібліотеки LightCraft?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "Імпорт читає каталог, не змінюючи його, і додає його фото, редагування, ключові слова та колекції до відкритої зараз бібліотеки.", "Albums hold library photos: Add to My Photos first": "В альбомах лише фото з бібліотеки: спершу додайте їх до моїх фото", @@ -2149,7 +2192,151 @@ "not added to Apple Photos (cancelled)": "не додано до Apple Фото (скасовано)", "The first time, macOS asks whether LightCraft may control Photos.": "Першого разу macOS запитає, чи може LightCraft керувати програмою «Фото».", "Adding to Apple Photos": "Додавання до Apple Фото", + "Canon EOS R5 Mark II C-RAW files that opened as \"preview only\" (more than half of them, depending on the": "Файли Canon EOS R5 Mark II C-RAW, які відкривалися «лише як перегляд» (більше половини, залежно від", + "imported earlier switch over with Photo ▸ Reload from Disk.": "імпортовані раніше, переходять на них через Фото → Перезавантажити з диска.", + "scene) now develop from their sensor data with every editing tool, like the camera's other raws. Photos": "сцени), тепер проявляються з даних сенсора з усіма інструментами редагування, як інші raw-файли камери. Фото,", + "smart album value|No": "Ні", + "smart album value|Yes": "Так", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "Графічний процесор не знайдено: LightCraft виконує рендеринг на ЦП, а це набагато повільніше. Докладніше: Довідка → Інформація про систему.", "On NixOS, start it from `nix develop`.": "У NixOS запускайте його з `nix develop`.", - "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший." + "Files with the same content as another file in this import: only the first is imported.": "Файли з тим самим вмістом, що й інший файл цього імпорту: імпортується лише перший.", + "Lightroom Catalog Import": "Імпорт каталогу Lightroom", + "Destination library": "Бібліотека призначення", + "Show in Library": "Показати в бібліотеці", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Каталоги Lightroom імпортуються у відкриту бібліотеку; окрема папка каталогу не створюється.", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "Ці фотографії є лише в пам’яті. Відкрийте бібліотеку на диску та імпортуйте каталог повторно, щоб зберегти їх.", + "Playback": "Відтворення", + "Layout": "Макет", + "Link all margins": "Зв’язати всі поля", + "Margins": "Поля", + "Left": "Ліворуч", + "Right": "Праворуч", + "Top": "Угорі", + "Bottom": "Унизу", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "Поля задаються часткою короткої сторони слайда, тож слайди виглядають однаково на будь-якому екрані.", + "Show guides": "Показувати напрямні", + "Aspect preview": "Попередній перегляд пропорцій", + "Zoom to fill frame": "Масштабувати за рамкою", + "Stroke border": "Обведення", + "Colour": "Колір", + "Cast shadow": "Відкидати тінь", + "Offset": "Зсув", + "Backdrop": "Тло", + "Background colour": "Колір тла", + "Colour wash": "Кольоровий градієнт", + "Background image": "Фонове зображення", + "(not in the library)": "(немає в бібліотеці)", + "Use selected photo": "Використати вибране фото", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "Виберіть фото й увімкніть цей параметр, щоб заповнити ним тло за кожним фото.", + "Reset layout": "Скинути макет", + "Select a photo to preview its slide": "Виберіть фото, щоб переглянути його слайд", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "Вибране фото як слайд. Під час відтворення слайд заповнює екран; попередній перегляд пропорцій змінює лише цей перегляд.", + "Overlays": "Накладання", + "Identity plate": "Фірмова табличка", + "Your name or studio": "Ваше ім'я або студія", + "Anchored to": "Прив'язка до", + "Slide": "Слайд", + "Render behind image": "Малювати за зображенням", + "Rating stars": "Зірки рейтингу", + "Text overlays": "Текстові накладання", + "Add text overlay": "Додати текстове накладання", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "Токени: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Решта тексту виводиться як написано.", + "Use export watermark": "Використати водяний знак експорту", + "The export watermark, drawn on the photo and sized relative to it.": "Водяний знак експорту, малюється на фото й масштабується відносно нього.", + "Titles": "Титри", + "Intro screen": "Вступний екран", + "Ending screen": "Завершальний екран", + "Shows": "Показує", + "Colour only": "Лише колір", + "Override colour": "Замінити колір", + "Own duration (off: the slide duration)": "Власна тривалість (вимк.: тривалість слайда)", + "Duration": "Тривалість", + "Title text": "Текст титру", + "Intro": "Вступ", + "Ending": "Фінал", + "End": "Кінець", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "Вступ показується один раз перед першим фото, фінал — після останнього фото, якщо повтор вимкнено. Фірмова табличка — та, що задана в розділі «Накладання». Титри з'являються й зникають як слайди, а випадковий порядок їх не переміщує.", + "This screen is off: turn it on under Titles to play it.": "Цей екран вимкнено: увімкніть його в розділі «Титри», щоб показувати.", + "The intro screen, before the first photo.": "Вступний екран, перед першим фото.", + "The ending screen, after the last photo.": "Завершальний екран, після останнього фото.", + "Templates": "Шаблони", + "Built-in": "Вбудовані", + "Your templates": "Ваші шаблони", + "None yet: save the current settings as a template below.": "Поки немає: збережіть поточні налаштування як шаблон нижче.", + "Before the last template": "До останнього шаблону", + "Previous settings": "Попередні налаштування", + "New template name": "Назва нового шаблону", + "Save as Template": "Зберегти як шаблон", + "Import…": "Імпорт…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "Для власних шаблонів потрібна тека налаштувань, якої в цій збірці немає; вбудовані працюють.", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "Клацніть шаблон, щоб застосувати його; наведіть вказівник, щоб побачити його на вибраному фото. Клацніть власний шаблон правою кнопкою, щоб оновити, перейменувати, експортувати чи видалити його.", + "Import Slideshow Templates": "Імпорт шаблонів слайд-шоу", + "Export Slideshow Template": "Експорт шаблону слайд-шоу", + "Slideshow Templates": "Шаблони слайд-шоу", + "Plain Black": "Просто чорний", + "Gallery Wall": "Стіна галереї", + "Edge to Edge": "Від краю до краю", + "Cinema Wide": "Широкий екран", + "Shooting Data": "Дані зйомки", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "Кожне фото якомога більше на чорному, з короткою зміною накладанням. Поверх нічого.", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "Світло-сіра стіна з білим паспарту та м'якою тінню; підпис і рейтинг під фото.", + "Every photo cropped to fill the whole screen, with slow crossfades.": "Кожне фото обрізане на весь екран, з повільною зміною накладанням.", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "Кадр 16:9 з чорними смугами згори й знизу, назва в куті, затемнення через чорний і фінальна картка.", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "Темне грифельне тло з експозицією, ISO та фокусною відстанню, а нижче камерою й об'єктивом під кожним фото.", + "Exporting slideshow": "Експорт слайд-шоу", + "Export Slideshow…": "Експортувати слайд-шоу…", + "Export Slideshow": "Експорт слайд-шоу", + "Export Slideshow: folder for the JPEG files": "Експорт слайд-шоу: тека для файлів JPEG", + "Frame rate": "Частота кадрів", + "Container": "Контейнер", + "Export PDF…": "Експорт у PDF…", + "Export JPEG…": "Експорт у JPEG…", + "Export Video…": "Експорт відео…", + "Automatically show full screen": "Автоматично відкривати на весь екран", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "Одна сторінка на слайд (довга сторона в пікселях = пунктах), з тривалістю та переходом слайда для режиму презентації в переглядачі.", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "Пронумеровані файли JPEG (назва-001.jpg…) у новій теці, назву якої задаєте на наступному кроці.", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "Відео AV1: кожен слайд показується свою тривалість, з переходами. Без звуку: музика поки не експортується.", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "Слайди використовують макет, накладання, вступний і фінальний екрани слайд-шоу, у порядку перегляду.", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "Фотографії в перегляді як слайди, з налаштуваннями вище. Поступ видно у списку завдань.", + "Checking raw files": "Перевірка RAW-файлів", + "1 raw can now be developed from its raw data": "1 RAW-файл тепер можна проявляти з RAW-даних", + "This raw is now developed from its raw data": "Цей RAW-файл тепер проявляється з RAW-даних", + "This raw variant still can't be decoded": "Цей варіант RAW поки що не вдається декодувати", + "No lens data embedded in this file (DNG lens opcodes only).": "У цьому файлі немає даних об’єктива (лише інструкції об’єктива DNG).", + "Export to": "Експорт до", + "Specific folder": "Вказана папка", + "Same folder as original photo": "Та сама папка, що й оригінал", + "Tidying the thumbnail cache": "Упорядкування кешу мініатюр", + "Reset all edits (Cmd+Shift+R)": "Скинути всі зміни (Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "Розумні альбоми оновлюються самі: змініть їхні правила або виберіть Фото → Видалити фото", + "LightCraft stopped": "LightCraft зупинився", + "Show Focus Point": "Показати точку фокусування", + "Zoom to Focus Point": "Збільшити до точки фокусування", + "Focus": "Фокус", + "Focus point": "Точка фокусування", + "Wide": "Широка", + "Center": "Центр", + "Flexible Spot": "Гнучка точка", + "Zone": "Зона", + "Expanded Flexible Spot": "Розширена гнучка точка", + "Custom AF Area": "Власна зона АФ", + "Face tracking": "Відстеження облич", + "Lock On AF": "АФ із захопленням", + "Multi": "Мульти", + "Spot": "Точка", + "Tracking": "Стеження", + "Face Tracking": "Відстеження облич", + "Animal Eye Tracking": "Стеження за очима тварин", + "Human Eye Tracking": "Стеження за очима людей", + "Read Focus Points": "Прочитати точки фокусування", + "Reading focus points": "Читання точок фокусування", + "Click to type a value": "Клацніть, щоб ввести значення", + "Run face models on the CPU only": "Запускати моделі облич лише на ЦП", + "Detection": "Виявлення", + "Recognition": "Розпізнавання", + "Not loaded yet": "Ще не завантажено", + "Checking the GPU…": "Перевірка ГП…", + "CPU": "ЦП", + "A face model uses the GPU only when a check shows it gives the same results faster.": "Модель облич використовує ГП, лише якщо перевірка показала, що він дає ті самі результати швидше.", + "Checking the GPU for faces": "Перевірка ГП для облич" } diff --git a/crates/ui-egui/locales/zh-hans-formats.json b/crates/ui-egui/locales/zh-hans-formats.json index 782f8d731..0d96d2960 100644 --- a/crates/ui-egui/locales/zh-hans-formats.json +++ b/crates/ui-egui/locales/zh-hans-formats.json @@ -13,9 +13,11 @@ "Added {n} photo{} to My Photos": "已将 {n} 张照片添加到我的照片{0:.0}", "Added {n} photo{} to “{}”": "已将 {n} 张照片添加到“{1}”{0:.0}", "All {n} selected photos move by the same amount.": "所选 {n} 张照片将按相同幅度移动。", + "Already in “{}”": "已在“{}”中", "Auto Sync: edits apply to {n} photos": "自动同步:编辑将应用到 {n} 张照片", "Automatic ({} MB)": "自动({0} MB)", "Click {}:1": "单击 {0}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "色彩空间:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "创建 {n} 个虚拟副本", "Decoder: {reason}": "解码器:{reason}", "Delete \"{label}\"": "删除 \"{label}\"", @@ -39,7 +41,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 无法打开窗口:{e}\n\n详细信息见日志文件:{path}", "Matches: {}": "匹配项:{0}", "Merging… {stage} {:.0}%": "合成中… {stage} {0:.0}%", + "Move {n} photo{} to “{}”": "将 {n} 张照片移到“{1}”{0:.0}", + "Move {n} photo{} to “{}”?": "将 {n} 张照片移到“{1}”?{0:.0}", "Move {what} to Recently Deleted?": "将 {what} 移到最近删除?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已将 {n} 张照片移到“{1}”{0:.0}。编辑 → 撤销 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "粘贴到 {n} 张照片 — 仅粘贴已复制的设置{0:.0}", "Preset: {name}": "预设:{name}", @@ -66,6 +71,7 @@ "Show {hidden} hidden location{}": "显示 {hidden} 个隐藏位置{0:.0}", "Showing {n} of {}": "显示 {0} 张中的 {n} 张", "Smart album: {}": "智能相册:{0}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "无法写入 {n} 张照片的 XMP 附属文件(其文件夹是否离线?)。LightCraft 会继续重试。", "Undo {}": "撤销 {0}", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {0} — 净室实现的纯 Rust 照片图库与 RAW 编辑工具。", "Version: {}": "版本:{0}", @@ -93,6 +99,7 @@ "{n} photo{}": "{n} 张照片{0:.0}", "{n} photo{} match · updates automatically as photos change": "{n} 张照片符合 · 随照片变化自动更新{0:.0}", "{n} photo{} now · updates automatically as photos change": "当前 {n} 张照片 · 随照片变化自动更新{0:.0}", + "{n} selected photos · {pages} pages": "已选 {n} 张照片 · {pages} 页", "{sel_n} selected · {counted}": "已选择 {sel_n} · {counted}", "{stage}… {:.0}%": "{stage}… {0:.0}%", "{what} merge added": "已添加 {what} 合并", @@ -235,6 +242,10 @@ "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", "+{n} more": "还有 {n} 项", + "Create the keyword “{name}”": "创建关键字“{name}”", + "new, inside {parent}": "新关键字,位于 {parent} 内", + "Keywords of {n} selected photos": "所选 {n} 张照片的关键字", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已从 {n} 张照片中移除“{name}”。编辑 → 撤销可恢复。", "1 task is still running: {names}. Quitting stops it.": "仍有 1 个任务在运行:{names}。退出将停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 个任务在运行:{names}。退出将停止它们。", "{what} merge cancelled": "已取消 {what} 合并", @@ -245,7 +256,44 @@ "{n} added to Apple Photos album “{album}”": "已将 {n} 个添加到 Apple 照片相册“{album}”", "{n} added to Apple Photos": "已将 {n} 个添加到 Apple 照片", "{n} photos opened for editing; each is stacked with its original": "已打开 {n} 张照片进行编辑;每张都与其原片堆叠在一起", + " in {g} group{}": "({g} 个分组){:.0}", + " · kept, not applied: {}": " · 已保留,未应用:{}", + " — kept but not applied: {}": " — 已保留但未应用:{}", + "Apply to “{mask}” on Selected Photos": "应用到所选照片的“{mask}”", + "Delete the effect preset “{name}”?": "删除效果预设“{name}”?", + "Delete these {n} effect presets?": "删除这 {n} 个效果预设?", + "Delete {n} effect preset{}? This can't be undone.": "删除 {n} 个效果预设?{:.0}此操作无法撤销。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已导出 {} 个效果预设{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已导入 {e} 个蒙版效果{:.0}(蒙版 → 效果)", + "Update “{name}” with Current Settings": "用当前设置更新“{name}”", + "{n} preset{}": "{n} 个预设{:.0}", "{name} opened in the other editor": "已在其他编辑器中打开 {name}", "{n} originals opened in the other editor": "已在其他编辑器中打开 {n} 个原始文件", - "· {in_source} identical copies": "· {in_source} 个相同副本" + "· {in_source} identical copies": "· {in_source} 个相同副本", + "{count} more warnings.": "另有 {count} 条警告。", + "{photos} catalog records processed; {collections} collections.": "已处理 {photos} 条目录记录;{collections} 个收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 个原始文件缺失。其目录记录可用;请使用“查找缺失照片”重新链接文件。", + "Applied {name}": "已应用 {name}", + "Delete “{name}”?": "删除“{name}”?", + "Updated {name}": "已更新 {name}", + "Exported to {path}": "已导出到 {path}", + "Saved template {name}": "已保存模板 {name}", + "Imported {name}": "已导入 {name}", + "Skipped {problem}": "已跳过:{problem}", + "…and {more} more": "…还有 {more} 个", + "Template preview: {name}": "模板预览:{name}", + "{n} photos in view · {slides} slides · {time}": "视图中 {n} 张照片 · {slides} 张幻灯片 · {time}", + "{n} pages": "{n} 页", + "{n} JPEG files": "{n} 个 JPEG 文件", + "{s} s of AV1 video": "{s} 秒 AV1 视频", + "Slideshow exported: {what}": "幻灯片放映已导出:{what}", + " · {n} skipped (could not be rendered)": " · 已跳过 {n} 张(无法渲染)", + "{n} raws can now be developed from their raw data": "{n} 个 RAW 现在可以从 RAW 数据冲印", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n详细信息见日志文件:{path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", + "Renames {n} face{} across the library. The files are not changed.": "在整个图库中重命名 {n} 张面孔。不会改动文件。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "“{to}”已经是另一个人。合并后成为一个人;撤销会再把他们分开。", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已添加 {what} 合并({mp}):完整尺寸({full})所需的内存超出了这台电脑的内存", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已添加 {what} 合并({mp}),这是全景图的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/locales/zh-hans.json b/crates/ui-egui/locales/zh-hans.json index ad379f1ef..2750dd4c8 100644 --- a/crates/ui-egui/locales/zh-hans.json +++ b/crates/ui-egui/locales/zh-hans.json @@ -10,11 +10,13 @@ ", rejected": ",排除", "0: nothing is rejected.": "0:不排除任何照片。", "1 day": "1 天", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 当前编辑 · 完整适合,不裁剪", "2026-04 or 2026-04-12": "2026-04 或 2026-04-12", "= exactly": "= 等于", "A blank template keeps the original names.": "模板留空则保留原始名称。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "筛选器正在隐藏此视图中的照片:请修改筛选条件,或清除筛选(视图 → 清除筛选)", "A one-time download of about": "一次性下载,大小约", + "A pen's pressure sets the size and flow of the brush": "笔的压力决定画笔的大小和流量", "AI Mask Model Status": "AI 蒙版模型状态", "About": "关于", "About LightCraft": "关于 LightCraft", @@ -40,6 +42,9 @@ "Add to Favorites": "添加到收藏夹", "Add to Local": "添加到本地", "Add to My Photos": "添加到我的照片", + "Can't be moved here": "无法移到这里", + "This row covers more than one disk: drop on a folder inside it": "此行涵盖多个磁盘:请拖放到其中的文件夹", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本地照片尚未在图库中:请先“添加到我的照片”,再移动", "Add to Selected Photos": "添加到所选照片", "Add to Target Album": "添加到目标相册", "Add to this mask": "添加到此蒙版", @@ -203,6 +208,7 @@ "Choose Edit Settings to Copy": "选择要复制的编辑设置", "Choose Edit Settings to Copy…": "选择要复制的编辑设置…", "Choose Folder…": "选择文件夹…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "确认后选择 PDF 的保存位置。请在查看器中打印 PDF。", "Choose a photo in the grid or filmstrip": "请在网格视图或胶片窗格中选择一张照片", "Choose…": "选择…", "Cinematic": "电影感", @@ -252,6 +258,9 @@ "Color Priority": "颜色优先", "Color Range": "颜色范围", "Color space": "色彩空间", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空间:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空间:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空间:Rec. 2020 PQ,16 位(HDR PNG)", "Color space: sRGB (AVIF)": "色彩空间:sRGB(AVIF)", "Colors": "颜色", "Combine files…": "合并文件…", @@ -268,7 +277,14 @@ "Concrete Cool": "冷调混凝土", "Confirm before deleting": "删除前确认", "Confirm before moving photos to Recently Deleted": "将照片移到“最近删除”前确认", + "Move Photos": "移动照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它们的文件和 XMP 附属文件会在磁盘上移动。编辑 → 撤销 可移回。", + "Don't ask again": "不再询问", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片后打开了另一个图库:未移动任何内容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "设置 → “将照片移到磁盘上其他文件夹前确认”可重新开启", + "Confirm before moving photos to another folder on disk": "将照片移到磁盘上其他文件夹前确认", "Constrain Crop": "限制裁剪", + "Contact Sheet PDF": "联系表 PDF", "Continue": "继续", "Continue Without Saving": "不保存继续", "Contrast": "对比度", @@ -491,6 +507,7 @@ "File Type": "文件类型", "File types:": "文件类型:", "Filename": "文件名", + "Filename captions": "文件名标题", "Fill": "填满", "First": "最早", "First commit": "首次提交", @@ -511,10 +528,13 @@ "HDR Merge Preview": "HDR 合并预览", "HDR Panorama Merge Preview": "HDR 全景合并预览", "HDR Panoramas": "HDR 全景", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位浮点。", + "HDR output": "HDR 输出", "HDR panorama": "HDR 全景", "HDR panoramas": "HDR 全景", "Has Edits": "有编辑", "Has GPS": "有 GPS", + "Hold View Until Refreshed": "刷新前保持视图", "Home": "主文件夹", "Horizontal inset (% of short edge)": "水平边距 (占短边 %)", "Hours": "小时", @@ -541,6 +561,7 @@ "Last commit": "最近一次提交", "Last loupe render": "最近一次放大视图渲染", "Lens Blur": "镜头模糊", + "Letter": "Letter", "Library saved": "图库已保存", "Licence:": "许可证:", "LightCraft could not start": "LightCraft 无法启动", @@ -592,8 +613,10 @@ "Panoramas": "全景", "Paste Settings": "粘贴设置", "Paste from Previous": "粘贴上一张照片的设置", + "Pen Pressure": "笔压", "People": "人物", "Person": "人物", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 编辑的照片:JPEG 带 HDR 增益图(在任何地方都显示正确,在 HDR 显示器上高光更亮),AVIF 为 10 位,PNG 和 JPEG XL 为 16 位 Rec. 2020 PQ;TIFF 需要 32 位浮点", "Photos per bracket (0 = auto)": "每组包围曝光的照片数(0 = 自动)", "Pick Flag": "留用旗标", "Pictures": "图片", @@ -631,6 +654,7 @@ "Refine Edges": "细化边缘", "Refine Saturation": "细化饱和度", "Refining the mask's detail…": "正在细化蒙版细节…", + "Refresh View": "刷新视图", "Reliability": "可靠性", "Relink Photo": "重新链接照片", "Reload Changed Files": "重新载入已更改的文件", @@ -738,6 +762,7 @@ "HDR Panorama with Last Settings": "使用上次设置合并 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次设置合并 HDR", + "HDR for Each Bracket Set": "为每组包围曝光合并 HDR", "HDR…": "HDR…", "Heal": "修复", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修复传感器灰尘在平滑区域留下的小而柔和的暗斑", @@ -971,13 +996,18 @@ "No edits yet.": "尚未编辑。", "No exposure information": "无曝光信息", "No filter presets yet": "暂无筛选器预设", - "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "No lens data embedded in this file.": "此文件中未嵌入镜头数据。", "No masks yet. Choose a mask type above.": "尚无蒙版。请在上方选择蒙版类型。", "No matching photos": "没有匹配的照片", "No metadata found": "未找到元数据", "No new presets": "没有新预设", "No photo selected": "未选择照片", "No photos": "没有照片", + "Your library is empty": "图库是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "导入的照片会显示在这里。除非你选择,否则不会复制或移动任何文件。", + "or drop photos and folders into this window": "或将照片和文件夹拖放到此窗口", + "Import photos to get started": "导入照片以开始使用", + "or drop photos into this window": "或将照片拖放到此窗口", "No photos found": "未找到照片", "No photos in this folder": "此文件夹中没有照片", "No photos match the active filters": "没有照片符合当前筛选条件", @@ -1188,7 +1218,7 @@ "Reset Section": "重置此区段", "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)", + "Reset all edits": "重置所有编辑", "Resize Face Box": "调整人脸框大小", "Resize panel window": "调整面板窗口大小", "Resize panels": "调整面板大小", @@ -1346,6 +1376,7 @@ "Show overlay (O)": "显示叠加 (O)", "Show ratings, flags and edit badges": "显示星级、旗标和编辑标记", "Show the Navigator while zoomed in": "放大时显示导航器", + "Show the import review when a camera or card is connected": "连接相机或存储卡时显示导入窗口", "Show the template tags; click one to insert it": "显示模板标签;单击即可插入", "Show/Hide Mask": "显示/隐藏蒙版", "Show/hide My Photos panel": "显示/隐藏我的照片面板", @@ -1373,6 +1404,7 @@ "Soft Pastel": "柔和粉彩", "Soft Proofing": "软打样", "Soft Skin": "柔和肤质", + "Solo Mode": "单独模式", "Sort": "排序", "Sort by": "排序方式", "Source": "来源", @@ -1515,6 +1547,7 @@ "Vibrance": "鲜艳度", "Videos": "视频", "View": "视图", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不读取 PNG cICP 块的查看器会把 PQ 值当作 sRGB 显示:平淡且发灰。要在任何地方都显示正确,请使用 JPEG(增益图)。", "Viewing and sharing": "查看与分享", "Vignette": "暗角", "Vignetting": "暗角", @@ -1547,6 +1580,7 @@ "Will be restored": "将恢复", "Window": "窗口", "Winter Blue": "冬日蓝调", + "Wrote the XMP sidecars that were waiting": "已写入等待中的 XMP 附属文件", "XMP Sidecar Preferences": "XMP 附属文件偏好设置", "XMP sidecars": "XMP 附属文件", "Year": "年", @@ -1755,7 +1789,7 @@ "commercial use subject to GPL conditions": "商用须遵循 GPL 条款", "Decrease Rating": "降低星级", "Increase Rating": "提高星级", - "Click to type a value": "单击以输入数值", + "Click to type a value, drag to change it": "单击以输入数值,拖动以更改", "Angle": "角度", "Video": "视频", "Copyrighted": "受版权保护", @@ -1819,6 +1853,15 @@ "Keyword List": "关键字列表", "Filter Keywords": "过滤关键字", "No keywords yet": "还没有关键字", + "Show fewer": "显示较少", + "Whole names": "完整名称", + "Grouped by parent": "按上级关键字分组", + "Keywords of the selection": "所选照片的关键字", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "分组:同一关键字内的关键字共用一行并标出该关键字的名称;较长的列表和建议会被缩短。", + "Show every keyword of the selected photos": "显示所选照片的全部关键字", + "new keyword": "新关键字", + "Return creates this keyword and gives it to the selected photos": "按回车键创建此关键字并添加到所选照片", + "Separate several keywords with commas. Return gives them to the selected photos.": "用逗号分隔多个关键字。按回车键将它们添加到所选照片。", "No keywords match": "没有匹配的关键字", "Keyword Name": "关键字名称", "Synonyms": "同义词", @@ -1997,5 +2040,144 @@ "Adding to Apple Photos": "正在添加到 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "未找到图形处理器:LightCraft 改用 CPU 渲染,速度会慢很多。详情见“帮助 → 系统信息”。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,请从 `nix develop` 启动。", - "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。" + "Files with the same content as another file in this import: only the first is imported.": "与本次导入中另一个文件内容相同的文件:只导入第一个。", + "Lightroom Catalog Import": "Lightroom 目录导入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "这些照片仅保存在内存中。请打开磁盘上的图库并再次导入目录以保存照片。", + "Show in Library": "在图库中显示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目录会导入到当前打开的图库中,不会创建单独的目录文件夹。", + "Destination library": "目标图库", + "Playback": "播放", + "Layout": "布局", + "Link all margins": "链接所有边距", + "Margins": "边距", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "边距按幻灯片短边的比例设置,因此幻灯片在任何屏幕上看起来都一样。", + "Show guides": "显示参考线", + "Aspect preview": "长宽比预览", + "Zoom to fill frame": "缩放以填充框架", + "Stroke border": "描边", + "Colour": "颜色", + "Cast shadow": "投影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景颜色", + "Colour wash": "颜色渐变", + "Background image": "背景图像", + "(not in the library)": "(不在图库中)", + "Use selected photo": "使用所选照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "选择一张照片,然后打开此项,用它填充每张照片后面的幻灯片背景。", + "Reset layout": "重置布局", + "Select a photo to preview its slide": "选择一张照片以预览其幻灯片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "将所选照片显示为幻灯片。播放时会填满屏幕;长宽比预览只改变此预览。", + "Overlays": "叠加", + "Identity plate": "身份标识", + "Your name or studio": "你的名字或工作室", + "Anchored to": "锚定到", + "Slide": "幻灯片", + "Render behind image": "在图像后面渲染", + "Rating stars": "评级星标", + "Text overlays": "文本叠加", + "Add text overlay": "添加文本叠加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "标记:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原样显示。", + "Use export watermark": "使用导出水印", + "The export watermark, drawn on the photo and sized relative to it.": "导出水印,绘制在照片上,大小相对于照片。", + "Titles": "标题", + "Intro screen": "片头画面", + "Ending screen": "片尾画面", + "Shows": "显示", + "Colour only": "仅颜色", + "Override colour": "覆盖颜色", + "Own duration (off: the slide duration)": "单独时长(关闭:使用幻灯片时长)", + "Duration": "时长", + "Title text": "标题文字", + "Intro": "片头", + "Ending": "片尾", + "End": "结束", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片头在第一张照片之前播放一次;关闭“重复”时,片尾在最后一张照片之后播放。身份标识即“叠加”中设置的那个。标题与幻灯片一样淡入淡出,随机顺序不会移动它们。", + "This screen is off: turn it on under Titles to play it.": "此画面已关闭:在“标题”中打开即可播放。", + "The intro screen, before the first photo.": "片头画面,在第一张照片之前。", + "The ending screen, after the last photo.": "片尾画面,在最后一张照片之后。", + "Templates": "模板", + "Built-in": "内置", + "Your templates": "你的模板", + "None yet: save the current settings as a template below.": "还没有:在下方将当前设置保存为模板。", + "Before the last template": "应用上一个模板之前", + "Previous settings": "之前的设置", + "New template name": "新模板名称", + "Save as Template": "存储为模板", + "Import…": "导入…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "你自己的模板需要设置文件夹,而此版本没有;内置模板可以使用。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "单击模板即可应用;指向模板可在所选照片上预览。右键单击你自己的模板可更新、重命名、导出或删除。", + "Import Slideshow Templates": "导入幻灯片放映模板", + "Export Slideshow Template": "导出幻灯片放映模板", + "Slideshow Templates": "幻灯片放映模板", + "Plain Black": "纯黑", + "Gallery Wall": "画廊墙", + "Edge to Edge": "满屏", + "Cinema Wide": "宽银幕", + "Shooting Data": "拍摄数据", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "每张照片尽可能大地显示在黑色背景上,带短暂交叉淡化。上面不绘制任何内容。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "浅灰色墙面、白色卡纸和柔和阴影;照片下方显示说明和星级。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "每张照片裁剪以填满整个屏幕,带缓慢交叉淡化。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 画面,上下黑边,标题在角落,经黑色淡入淡出,并有结尾卡。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每张照片下方显示曝光、ISO 和焦距,再下面是相机和镜头。", + "Exporting slideshow": "正在导出幻灯片放映", + "Export Slideshow…": "导出幻灯片放映…", + "Export Slideshow": "导出幻灯片放映", + "Export Slideshow: folder for the JPEG files": "导出幻灯片放映:JPEG 文件所在的文件夹", + "Frame rate": "帧率", + "Container": "容器格式", + "Export PDF…": "导出 PDF…", + "Export JPEG…": "导出 JPEG…", + "Export Video…": "导出视频…", + "Automatically show full screen": "自动全屏显示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每张幻灯片一页(长边像素数 = 点数),并带有幻灯片时长和淡化效果,供查看器的演示模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "带编号的 JPEG 文件(name-001.jpg…),放在下一步命名的新文件夹中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 视频,每张幻灯片按其时长显示并带有淡化效果。无声音:暂不导出音乐。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻灯片使用幻灯片放映的版面、叠加以及片头和片尾画面,按视图顺序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "将视图中的照片按上方设置制作为幻灯片。进度显示在活动列表中。", + "Checking raw files": "正在检查 RAW 文件", + "1 raw can now be developed from its raw data": "1 个 RAW 现在可以从 RAW 数据冲印", + "This raw is now developed from its raw data": "此 RAW 现在从 RAW 数据冲印", + "This raw variant still can't be decoded": "这种 RAW 变体仍无法解码", + "No lens data embedded in this file (DNG lens opcodes only).": "此文件中未嵌入镜头数据 (仅支持 DNG 镜头操作码)。", + "Export to": "导出到", + "Specific folder": "指定文件夹", + "Same folder as original photo": "与原始照片相同的文件夹", + "Tidying the thumbnail cache": "正在整理缩略图缓存", + "Reset all edits (Cmd+Shift+R)": "重置所有编辑 (Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智能相册会自动更新:请更改其规则,或使用“照片 → 删除照片”", + "LightCraft stopped": "LightCraft 已停止", + "Show Focus Point": "显示对焦点", + "Zoom to Focus Point": "缩放至对焦点", + "Focus": "对焦", + "Focus point": "对焦点", + "Wide": "广域", + "Center": "中间", + "Flexible Spot": "自由点", + "Zone": "区", + "Expanded Flexible Spot": "扩展自由点", + "Custom AF Area": "自定义AF区域", + "Face tracking": "人脸追踪", + "Lock On AF": "锁定AF", + "Multi": "多重", + "Spot": "点", + "Tracking": "追踪", + "Face Tracking": "人脸追踪", + "Animal Eye Tracking": "动物眼部追踪", + "Human Eye Tracking": "人眼追踪", + "Read Focus Points": "读取对焦点", + "Reading focus points": "正在读取对焦点", + "Click to type a value": "单击以输入数值", + "Run face models on the CPU only": "仅在 CPU 上运行人脸模型", + "Detection": "检测", + "Recognition": "识别", + "Not loaded yet": "尚未加载", + "Checking the GPU…": "正在检查 GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "只有检查表明 GPU 能更快给出相同结果时,人脸模型才会使用 GPU。", + "Checking the GPU for faces": "正在检查用于人脸的 GPU" } diff --git a/crates/ui-egui/locales/zh-hant-formats.json b/crates/ui-egui/locales/zh-hant-formats.json index 56db559c6..62bc3e681 100644 --- a/crates/ui-egui/locales/zh-hant-formats.json +++ b/crates/ui-egui/locales/zh-hant-formats.json @@ -14,6 +14,7 @@ "Added {n} photo{} to “{}”": "已將 {n} 張照片加入「{1}」{0:.0}", "Adding photos… {} of {}": "正在新增照片… {}/{}", "All {n} selected photos move by the same amount.": "所有 {n} 張選取的照片會調整相同的時間。", + "Already in “{}”": "已在「{}」中", "Auto ({})": "自動({})", "Auto Sync: edits apply to {n} photos": "自動同步:編輯會套用至 {n} 張照片", "Automatic ({} MB)": "自動({} MB)", @@ -21,6 +22,7 @@ "Build Smart Previews ({scope})": "建立智慧型預覽({scope})", "Build Standard-Sized Previews ({scope})": "建立標準尺寸預覽({scope})", "Click {}:1": "點擊時 {}:1", + "Color space: Rec. 2020 {curve} (HDR AVIF)": "色彩空間:Rec. 2020 {curve}(HDR AVIF)", "Create {n} Virtual Copies": "建立 {n} 張虛擬副本", "Decoder: {reason}": "解碼器:{reason}", "Delete \"{label}\"": "刪除「{label}」", @@ -53,7 +55,10 @@ "LightCraft could not open its window: {e}\n\nThe log file has the details: {path}": "LightCraft 無法開啟視窗:{e}\n\n詳細資訊請見記錄檔:{path}", "Matches: {}": "符合條件:{}", "Merging… {stage} {:.0}%": "正在合併… {stage} {:.0}%", + "Move {n} photo{} to “{}”": "將 {n} 張照片移到「{1}」{0:.0}", + "Move {n} photo{} to “{}”?": "將 {n} 張照片移到「{1}」?{0:.0}", "Move {what} to Recently Deleted?": "要將{what}移至「最近刪除」嗎?", + "Moved {n} photo{} to “{}”. Edit → Undo moves them back.": "已將 {n} 張照片移到「{1}」{0:.0}。編輯 → 復原 可移回。", "Named ({named_n})": "已命名({named_n})", "Paste into {n} photo{} — only settings that were copied are pasted": "貼上至 {n} 張照片{:.0} — 只貼上已複製的設定", "Preset: {name}": "預設集:{name}", @@ -83,6 +88,7 @@ "Show {hidden} hidden location{}": "顯示 {hidden} 個隱藏的位置{:.0}", "Showing {n} of {}": "顯示 {n}/{} 張", "Smart album: {}": "智慧型相簿:{}", + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.": "無法寫入 {n} 張照片的 XMP 附屬檔案(其資料夾是否離線?)。LightCraft 會持續重試。", "Undo {}": "復原 {}", "Updated “{name}”": "已更新「{name}」", "Version {} — a clean-room, pure-Rust photo library and raw developer.": "版本 {} — 以純 Rust 獨立實作的照片管理與 RAW 顯像程式。", @@ -113,6 +119,7 @@ "{n} photo{}": "{n} 張照片{:.0}", "{n} photo{} match · updates automatically as photos change": "{n} 張照片符合{:.0} · 隨照片變更自動更新", "{n} photo{} now · updates automatically as photos change": "目前 {n} 張照片{:.0} · 隨照片變更自動更新", + "{n} selected photos · {pages} pages": "已選取 {n} 張照片 · {pages} 頁", "{sel_n} selected · {counted}": "已選取 {sel_n} 張 · {counted}", "{stage}… {:.0}%": "{stage}… {:.0}%", "{verb} {n} Photo{}": "{verb} {n} 張照片{:.0}", @@ -235,17 +242,58 @@ "{done} of {total} MB": "{done}/{total} MB", "{pct} %": "{pct}%", "+{n} more": "還有 {n} 項", + "Create the keyword “{name}”": "建立關鍵字「{name}」", + "new, inside {parent}": "新關鍵字,位於 {parent} 內", + "Keywords of {n} selected photos": "選取的 {n} 張照片的關鍵字", + "Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.": "已從 {n} 張照片移除「{name}」。編輯 → 復原可恢復。", "1 task is still running: {names}. Quitting stops it.": "仍有 1 個工作在執行:{names}。結束將停止它。", "{n} tasks are still running: {names}. Quitting stops them.": "仍有 {n} 個工作在執行:{names}。結束將停止它們。", "{what} merge cancelled": "已取消{what}合併", - "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄圖庫中的照片(請先加入我的照片)", + "{n} Local photo{} skipped: albums hold library photos (Add to My Photos first)": "已略過 {n} 張本機照片{0:.0}:相簿只能收錄照片庫中的照片(請先加入我的照片)", "New Folder in “{name}”": "在「{name}」中新增資料夾", "Moved {} photo(s)": "已移動 {} 張照片", "not added to Apple Photos: {e}": "未加入 Apple 照片:{e}", "{n} added to Apple Photos album “{album}”": "已將 {n} 個加入 Apple 照片相簿「{album}」", "{n} added to Apple Photos": "已將 {n} 個加入 Apple 照片", "{n} photos opened for editing; each is stacked with its original": "已開啟 {n} 張相片進行編輯;每張都與其原始相片堆疊在一起", + " in {g} group{}": "({g} 個群組){:.0}", + " · kept, not applied: {}": " · 已保留,未套用:{}", + " — kept but not applied: {}": " — 已保留但未套用:{}", + "Apply to “{mask}” on Selected Photos": "套用到所選照片的「{mask}」", + "Delete the effect preset “{name}”?": "刪除效果預設集「{name}」?", + "Delete these {n} effect presets?": "刪除這 {n} 個效果預設集?", + "Delete {n} effect preset{}? This can't be undone.": "刪除 {n} 個效果預設集?{:.0}此動作無法復原。", + "Effect: {name}": "效果:{name}", + "Exported {} effect preset{}": "已匯出 {} 個效果預設集{:.0}", + "Imported {e} mask effect{} (Masking ▸ Effect)": "已匯入 {e} 個遮色片效果{:.0}(遮色片 → 效果)", + "Update “{name}” with Current Settings": "以目前設定更新「{name}」", + "{n} preset{}": "{n} 個預設集{:.0}", "{name} opened in the other editor": "已在其他編輯器中開啟 {name}", "{n} originals opened in the other editor": "已在其他編輯器中開啟 {n} 個原始檔案", - "· {in_source} identical copies": "· {in_source} 個相同副本" + "· {in_source} identical copies": "· {in_source} 個相同副本", + "{count} more warnings.": "另有 {count} 則警告。", + "{photos} catalog records processed; {collections} collections.": "已處理 {photos} 筆目錄記錄;{collections} 個收藏集。", + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.": "有 {missing} 個原始檔案遺失。其目錄記錄可用;請使用「尋找遺失相片」重新連結檔案。", + "Applied {name}": "已套用 {name}", + "Delete “{name}”?": "刪除「{name}」?", + "Updated {name}": "已更新 {name}", + "Exported to {path}": "已匯出到 {path}", + "Saved template {name}": "已儲存範本 {name}", + "Imported {name}": "已匯入 {name}", + "Skipped {problem}": "已略過:{problem}", + "…and {more} more": "…還有 {more} 個", + "Template preview: {name}": "範本預覽:{name}", + "{n} photos in view · {slides} slides · {time}": "檢視中 {n} 張照片 · {slides} 張幻燈片 · {time}", + "{n} pages": "{n} 頁", + "{n} JPEG files": "{n} 個 JPEG 檔案", + "{s} s of AV1 video": "{s} 秒 AV1 視訊", + "Slideshow exported: {what}": "幻燈片播放已匯出:{what}", + " · {n} skipped (could not be rendered)": " · 已略過 {n} 張(無法算繪)", + "{n} raws can now be developed from their raw data": "{n} 個 RAW 現在可以從 RAW 資料沖印", + "LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}": "LightCraft 意外停止:{e}\n\n詳細資訊請見記錄檔:{path}", + "LightCraft stopped unexpectedly: {e}": "LightCraft 意外停止:{e}", + "Renames {n} face{} across the library. The files are not changed.": "在整個照片庫中重新命名 {n} 張面孔。不會改動檔案。{:.0}", + "“{to}” is already a person. Combining them makes one person; Undo separates them again.": "「{to}」已經是另一個人。合併後成為一個人;還原會再把他們分開。", + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has": "已新增{what}合併照片({mp}):完整尺寸({full})所需的記憶體超過這台電腦的記憶體", + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})": "已新增{what}合併照片({mp}),這是全景圖的最大尺寸(完整尺寸:{full})" } diff --git a/crates/ui-egui/locales/zh-hant.json b/crates/ui-egui/locales/zh-hant.json index 647d153d0..a1723fc22 100644 --- a/crates/ui-egui/locales/zh-hant.json +++ b/crates/ui-egui/locales/zh-hant.json @@ -10,11 +10,13 @@ ", rejected": ",排除", "0: nothing is rejected.": "0:不排除任何照片。", "1 day": "1 天", + "150 dpi · sRGB · current edits · fit without cropping": "150 dpi · sRGB · 目前的編輯 · 完整符合,不裁切", "2026-04 or 2026-04-12": "2026-04 或 2026-04-12", "= exactly": "= 等於", "A blank template keeps the original names.": "範本留空時保留原始名稱。", "A filter is hiding this view's photos: change it, or clear it (View → Clear Filters)": "篩選條件隱藏了此檢視的照片:請變更條件,或選擇「檢視 → 清除篩選條件」", "A one-time download of about": "一次性下載,大小約", + "A pen's pressure sets the size and flow of the brush": "筆的壓力決定筆刷的大小和流量", "AI Mask Model Status": "AI 遮色片模型狀態", "About": "關於", "About LightCraft": "關於 LightCraft", @@ -40,6 +42,9 @@ "Add to Favorites": "加入喜好項目", "Add to Local": "加入本機", "Add to My Photos": "加入我的照片", + "Can't be moved here": "無法移到這裡", + "This row covers more than one disk: drop on a folder inside it": "此列涵蓋多個磁碟:請拖放到其中的資料夾", + "Local photos aren't in the library yet: Add to My Photos first, then move them": "本機照片尚未在照片庫中:請先「加入我的照片」,再移動", "Add to Selected Photos": "加入選取的照片", "Add to Target Album": "加入目標相簿", "Add to this mask": "加入此遮色片", @@ -203,6 +208,7 @@ "Choose Edit Settings to Copy": "選擇要複製的編輯設定", "Choose Edit Settings to Copy…": "選擇要複製的編輯設定…", "Choose Folder…": "選擇資料夾…", + "Choose a PDF destination after confirming. Print the PDF from your viewer.": "確認後選擇 PDF 的儲存位置。請在檢視器中列印 PDF。", "Choose a photo in the grid or filmstrip": "請在格狀檢視或底片顯示窗格中選擇照片", "Choose…": "選擇…", "Cinematic": "電影風格", @@ -252,6 +258,9 @@ "Color Priority": "色彩優先", "Color Range": "色彩範圍", "Color space": "色彩空間", + "Color space: Rec. 2020 PQ (HDR AVIF)": "色彩空間:Rec. 2020 PQ(HDR AVIF)", + "Color space: Rec. 2020 PQ (HDR JPEG XL)": "色彩空間:Rec. 2020 PQ(HDR JPEG XL)", + "Color space: Rec. 2020 PQ, 16-bit (HDR PNG)": "色彩空間:Rec. 2020 PQ,16 位元(HDR PNG)", "Color space: sRGB (AVIF)": "色彩空間:sRGB(AVIF)", "Colors": "色彩", "Combine files…": "合併檔案…", @@ -268,7 +277,14 @@ "Concrete Cool": "冷調混凝土", "Confirm before deleting": "刪除前確認", "Confirm before moving photos to Recently Deleted": "將照片移至「最近刪除」前確認", + "Move Photos": "移動照片", + "Their files and XMP sidecars move on disk. Edit → Undo moves them back.": "它們的檔案和 XMP 附屬檔案會在磁碟上移動。編輯 → 復原 可移回。", + "Don't ask again": "不再詢問", + "Another library was opened since the photos were dropped: nothing was moved": "拖放照片後開啟了另一個照片庫:未移動任何內容", + "Settings → Confirm before moving photos to another folder on disk turns it back on": "設定 → 「將照片移至磁碟上其他資料夾前確認」可重新開啟", + "Confirm before moving photos to another folder on disk": "將照片移至磁碟上其他資料夾前確認", "Constrain Crop": "限制裁切範圍", + "Contact Sheet PDF": "縮圖目錄 PDF", "Continue": "繼續", "Continue Without Saving": "繼續使用但不儲存", "Contrast": "對比", @@ -491,6 +507,7 @@ "File Type": "檔案類型", "File types:": "檔案類型:", "Filename": "檔名", + "Filename captions": "檔名說明", "Fill": "填滿", "First": "最早", "First commit": "首次提交", @@ -511,10 +528,13 @@ "HDR Merge Preview": "HDR 合併預覽", "HDR Panorama Merge Preview": "HDR 全景合併預覽", "HDR Panoramas": "HDR 全景", + "HDR TIFF needs 32-bit float.": "HDR TIFF 需要 32 位元浮點。", + "HDR output": "HDR 輸出", "HDR panorama": "HDR 全景", "HDR panoramas": "HDR 全景", "Has Edits": "有編輯", "Has GPS": "有 GPS", + "Hold View Until Refreshed": "重新整理前保持檢視", "Home": "個人資料夾", "Horizontal inset (% of short edge)": "水平邊距(短邊百分比)", "Hours": "小時", @@ -541,6 +561,7 @@ "Last commit": "最近一次提交", "Last loupe render": "最近一次放大檢視算繪", "Lens Blur": "鏡頭模糊", + "Letter": "Letter", "Library saved": "照片庫已儲存", "Licence:": "授權:", "LightCraft could not start": "LightCraft 無法啟動", @@ -592,8 +613,10 @@ "Panoramas": "全景", "Paste Settings": "貼上設定", "Paste from Previous": "貼上前一張照片的設定", + "Pen Pressure": "筆壓", "People": "人物", "Person": "人物", + "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float": "以 HDR 編輯的照片:JPEG 附 HDR 增益圖(在任何地方都顯示正確,在 HDR 顯示器上亮部更亮),AVIF 為 10 位元,PNG 與 JPEG XL 為 16 位元 Rec. 2020 PQ;TIFF 需要 32 位元浮點", "Photos per bracket (0 = auto)": "每組包圍曝光的照片數(0 = 自動)", "Pick Flag": "精選旗標", "Pictures": "圖片", @@ -631,6 +654,7 @@ "Refine Edges": "調整邊緣", "Refine Saturation": "調整飽和度", "Refining the mask's detail…": "正在調整遮色片細節…", + "Refresh View": "重新整理檢視", "Reliability": "可靠度", "Relink Photo": "重新連結照片", "Reload Changed Files": "重新載入已變更的檔案", @@ -738,6 +762,7 @@ "HDR Panorama with Last Settings": "使用上次設定合併 HDR 全景", "HDR Panorama…": "HDR 全景…", "HDR with Last Settings": "使用上次設定合併 HDR", + "HDR for Each Bracket Set": "為每組包圍曝光合併 HDR", "HDR…": "HDR…", "Heal": "修復", "Heal the small, soft dark spots sensor dust leaves on smooth areas": "修復感光元件灰塵在平滑區域留下的小型柔邊暗點", @@ -971,13 +996,18 @@ "No edits yet.": "尚未編輯。", "No exposure information": "沒有曝光資訊", "No filter presets yet": "尚無篩選預設集", - "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "No lens data embedded in this file.": "此檔案未內嵌鏡頭資料。", "No masks yet. Choose a mask type above.": "尚無遮色片。請在上方選擇遮色片類型。", "No matching photos": "沒有符合的照片", "No metadata found": "找不到中繼資料", "No new presets": "沒有新預設集", "No photo selected": "尚未選取照片", "No photos": "沒有照片", + "Your library is empty": "照片庫是空的", + "Photos you import show up here. Nothing is copied or moved unless you choose to.": "匯入的照片會顯示在這裡。除非你選擇,否則不會複製或移動任何檔案。", + "or drop photos and folders into this window": "或將照片和資料夾拖放到此視窗", + "Import photos to get started": "匯入照片以開始使用", + "or drop photos into this window": "或將照片拖放到此視窗", "No photos found": "找不到照片", "No photos in this folder": "此資料夾沒有照片", "No photos match the active filters": "沒有照片符合目前的篩選條件", @@ -1188,7 +1218,7 @@ "Reset Section": "重設區段", "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)", + "Reset all edits": "重設所有編輯", "Resize Face Box": "調整臉部框大小", "Resize panel window": "調整面板視窗大小", "Resize panels": "調整面板大小", @@ -1346,6 +1376,7 @@ "Show overlay (O)": "顯示疊加(O)", "Show ratings, flags and edit badges": "顯示評分、旗標與編輯標記", "Show the Navigator while zoomed in": "放大時顯示導覽器", + "Show the import review when a camera or card is connected": "連接相機或記憶卡時顯示匯入視窗", "Show the template tags; click one to insert it": "顯示範本標籤;點擊即可插入", "Show/Hide Mask": "顯示/隱藏遮色片", "Show/hide My Photos panel": "顯示/隱藏我的照片面板", @@ -1373,6 +1404,7 @@ "Soft Pastel": "柔和粉彩", "Soft Proofing": "軟式校樣", "Soft Skin": "柔和膚質", + "Solo Mode": "單獨模式", "Sort": "排序", "Sort by": "排序依據", "Source": "來源", @@ -1515,6 +1547,7 @@ "Vibrance": "自然飽和度", "Videos": "影片", "View": "檢視", + "Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).": "不讀取 PNG cICP 區塊的檢視器會把 PQ 值當作 sRGB 顯示:平淡且泛白。要在任何地方都顯示正確,請使用 JPEG(增益圖)。", "Viewing and sharing": "檢視與分享", "Vignette": "暗角", "Vignetting": "暗角", @@ -1547,6 +1580,7 @@ "Will be restored": "將還原", "Window": "視窗", "Winter Blue": "冬日藍調", + "Wrote the XMP sidecars that were waiting": "已寫入等待中的 XMP 附屬檔案", "XMP Sidecar Preferences": "XMP 附屬檔案偏好設定", "XMP sidecars": "XMP 附屬檔案", "Year": "年", @@ -1755,7 +1789,7 @@ "commercial use subject to GPL conditions": "commercial use subject to GPL conditions", "Decrease Rating": "降低評分", "Increase Rating": "提高評分", - "Click to type a value": "點擊以輸入數值", + "Click to type a value, drag to change it": "點擊以輸入數值,拖曳以變更", "Angle": "角度", "Video": "影片", "Copyrighted": "受著作權保護", @@ -1819,6 +1853,15 @@ "Keyword List": "關鍵字清單", "Filter Keywords": "篩選關鍵字", "No keywords yet": "尚無關鍵字", + "Show fewer": "顯示較少", + "Whole names": "完整名稱", + "Grouped by parent": "依上層關鍵字分組", + "Keywords of the selection": "選取照片的關鍵字", + "Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened.": "分組:同一關鍵字內的關鍵字共用一行並標出該關鍵字的名稱;較長的清單和建議會縮短。", + "Show every keyword of the selected photos": "顯示選取照片的全部關鍵字", + "new keyword": "新關鍵字", + "Return creates this keyword and gives it to the selected photos": "按 Return 鍵建立此關鍵字並加到選取的照片", + "Separate several keywords with commas. Return gives them to the selected photos.": "以逗號分隔多個關鍵字。按 Return 鍵將它們加到選取的照片。", "No keywords match": "沒有符合的關鍵字", "Keyword Name": "關鍵字名稱", "Synonyms": "同義詞", @@ -1895,7 +1938,7 @@ "Paper": "紙白", "Show less": "收合", "Quit while tasks are running?": "要在工作執行時結束嗎?", - "Exporting contact sheet": "正在匯出聯絡表", + "Exporting contact sheet": "正在匯出縮圖目錄", "Reading folder": "正在讀取檔案夾", "Scanning folder": "正在掃描檔案夾", "Building previews": "正在建立預覽", @@ -1910,7 +1953,7 @@ "Looking for changes": "正在尋找變更", "This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?": "這是 Lightroom 目錄,不是 LightCraft 照片庫。要改為將其匯入 LightCraft 照片庫嗎?", "The import reads the catalog without changing it and adds its photos, edits, keywords and collections to the library open now.": "匯入只讀取目錄、不做任何變更,並將其中的照片、編輯、關鍵字和收藏夾加入目前開啟的照片庫。", - "Albums hold library photos: Add to My Photos first": "相簿只能收錄圖庫中的照片:請先加入我的照片", + "Albums hold library photos: Add to My Photos first": "相簿只能收錄照片庫中的照片:請先加入我的照片", "Add to My Photos First": "先加入我的照片", "Fill Location from GPS": "根據 GPS 填寫位置", "Download Place Names (GeoNames)": "下載地名資料 (GeoNames)", @@ -1935,14 +1978,14 @@ "No imported folders yet": "還沒有匯入的資料夾", "Show Sidebar Section": "顯示側邊欄區段", "Move Sidebar Section": "移動側邊欄區段", - "New Library…": "新增圖庫…", - "Open Recent Library": "開啟最近的圖庫", - "Recent Libraries": "最近的圖庫", - "Clear Recent Libraries": "清除最近的圖庫", - "No Recent Libraries": "沒有最近的圖庫", - "New Library: Choose Where to Put It": "新增圖庫:選擇存放位置", - "New Library": "新增圖庫", - "Library name": "圖庫名稱", + "New Library…": "新增照片庫…", + "Open Recent Library": "開啟最近的照片庫", + "Recent Libraries": "最近的照片庫", + "Clear Recent Libraries": "清除最近的照片庫", + "No Recent Libraries": "沒有最近的照片庫", + "New Library: Choose Where to Put It": "新增照片庫:選擇存放位置", + "New Library": "新增照片庫", + "Library name": "照片庫名稱", "New Folder on Disk": "在磁碟上新增資料夾", "New Folder on Disk…": "在磁碟上新增資料夾…", "Move to Folder": "移至資料夾", @@ -1959,5 +2002,144 @@ "Adding to Apple Photos": "正在加入 Apple 照片", "No graphics processor found: LightCraft renders on the CPU, which is much slower. Details in Help → System Info.": "找不到圖形處理器:LightCraft 改用 CPU 算繪,速度會慢很多。詳情請見「說明 → 系統資訊」。", "On NixOS, start it from `nix develop`.": "在 NixOS 上,請從 `nix develop` 啟動。", - "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。" + "Files with the same content as another file in this import: only the first is imported.": "與本次匯入中另一個檔案內容相同的檔案:只匯入第一個。", + "Lightroom Catalog Import": "Lightroom 目錄匯入", + "These photos are only in memory. Open a library on disk and import the catalog again to save them.": "這些相片僅保存在記憶體中。請開啟磁碟上的照片庫並再次匯入目錄以儲存相片。", + "Show in Library": "在照片庫中顯示", + "Lightroom catalogs import into the open library; no separate catalog folder is created.": "Lightroom 目錄會匯入目前開啟的照片庫,不會建立獨立的目錄資料夾。", + "Destination library": "目標照片庫", + "Playback": "播放", + "Layout": "版面", + "Link all margins": "連結所有邊界", + "Margins": "邊界", + "Left": "左", + "Right": "右", + "Top": "上", + "Bottom": "下", + "Margins are a share of the slide's shorter side, so slides look the same on any screen.": "邊界以投影片短邊的比例設定,因此投影片在任何螢幕上看起來都一樣。", + "Show guides": "顯示參考線", + "Aspect preview": "長寬比預覽", + "Zoom to fill frame": "縮放以填滿框架", + "Stroke border": "描邊", + "Colour": "顏色", + "Cast shadow": "投射陰影", + "Offset": "偏移", + "Backdrop": "背景", + "Background colour": "背景顏色", + "Colour wash": "顏色漸層", + "Background image": "背景影像", + "(not in the library)": "(不在照片庫中)", + "Use selected photo": "使用所選照片", + "Select a photo, then turn this on to fill the slide behind every photo with it.": "選取一張照片,然後開啟此項,以它填滿每張照片後方的投影片背景。", + "Reset layout": "重設版面", + "Select a photo to preview its slide": "選取一張照片以預覽其投影片", + "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview.": "將所選照片顯示為投影片。播放時會填滿螢幕;長寬比預覽只會改變此預覽。", + "Overlays": "疊加", + "Identity plate": "身分標識", + "Your name or studio": "你的名字或工作室", + "Anchored to": "錨定到", + "Slide": "幻燈片", + "Render behind image": "在影像後方繪製", + "Rating stars": "評等星號", + "Text overlays": "文字疊加", + "Add text overlay": "新增文字疊加", + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.": "標記:{file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}。其他文字按原樣顯示。", + "Use export watermark": "使用匯出浮水印", + "The export watermark, drawn on the photo and sized relative to it.": "匯出浮水印,繪製在照片上,大小相對於照片。", + "Titles": "標題", + "Intro screen": "片頭畫面", + "Ending screen": "片尾畫面", + "Shows": "顯示", + "Colour only": "僅顏色", + "Override colour": "覆寫顏色", + "Own duration (off: the slide duration)": "個別時長(關閉:使用幻燈片時長)", + "Duration": "時長", + "Title text": "標題文字", + "Intro": "片頭", + "Ending": "片尾", + "End": "結束", + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.": "片頭在第一張照片之前播放一次;關閉「重複」時,片尾在最後一張照片之後播放。身分標識即「疊加」中設定的那個。標題與幻燈片一樣淡入淡出,隨機順序不會移動它們。", + "This screen is off: turn it on under Titles to play it.": "此畫面已關閉:在「標題」中開啟即可播放。", + "The intro screen, before the first photo.": "片頭畫面,在第一張照片之前。", + "The ending screen, after the last photo.": "片尾畫面,在最後一張照片之後。", + "Templates": "範本", + "Built-in": "內建", + "Your templates": "你的範本", + "None yet: save the current settings as a template below.": "還沒有:在下方將目前設定儲存為範本。", + "Before the last template": "套用上一個範本之前", + "Previous settings": "之前的設定", + "New template name": "新範本名稱", + "Save as Template": "儲存為範本", + "Import…": "匯入…", + "Your own templates need a settings folder, which this build doesn't have; the built-in ones work.": "你自己的範本需要設定資料夾,而此版本沒有;內建範本可以使用。", + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.": "按一下範本即可套用;指向範本可在所選照片上預覽。在你自己的範本上按右鍵可更新、重新命名、匯出或刪除。", + "Import Slideshow Templates": "匯入幻燈片秀範本", + "Export Slideshow Template": "匯出幻燈片秀範本", + "Slideshow Templates": "幻燈片秀範本", + "Plain Black": "純黑", + "Gallery Wall": "畫廊牆", + "Edge to Edge": "滿版", + "Cinema Wide": "寬銀幕", + "Shooting Data": "拍攝資料", + "Each photo as large as it fits, on black, with a short crossfade. Nothing drawn over it.": "每張照片盡可能大地顯示在黑色背景上,帶短暫交叉淡化。上面不繪製任何內容。", + "A light grey wall with a white mat and a soft shadow; the caption and the rating under the photo.": "淺灰色牆面、白色卡紙和柔和陰影;照片下方顯示說明和星級。", + "Every photo cropped to fill the whole screen, with slow crossfades.": "每張照片裁切以填滿整個螢幕,帶緩慢交叉淡化。", + "A 16:9 frame with black bars above and below, the title in the corner, fades through black and a closing card.": "16:9 畫面,上下黑邊,標題在角落,經黑色淡入淡出,並有結尾卡。", + "A dark slate backdrop with the exposure, ISO and focal length, then the camera and lens, under each photo.": "深石板色背景,每張照片下方顯示曝光、ISO 和焦距,再下面是相機和鏡頭。", + "Exporting slideshow": "正在匯出幻燈片播放", + "Export Slideshow…": "匯出幻燈片播放…", + "Export Slideshow": "匯出幻燈片播放", + "Export Slideshow: folder for the JPEG files": "匯出幻燈片播放:JPEG 檔案所在的資料夾", + "Frame rate": "影格速率", + "Container": "容器格式", + "Export PDF…": "匯出 PDF…", + "Export JPEG…": "匯出 JPEG…", + "Export Video…": "匯出視訊…", + "Automatically show full screen": "自動全螢幕顯示", + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode.": "每張幻燈片一頁(長邊像素數 = 點數),並附有幻燈片時長與淡化效果,供檢視器的簡報模式使用。", + "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.": "編號的 JPEG 檔案(name-001.jpg…),放在下一步命名的新資料夾中。", + "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.": "AV1 視訊,每張幻燈片依其時長顯示並附有淡化效果。無聲音:尚未匯出音樂。", + "Slides use the slideshow's layout, overlays and intro and ending screens, in view order.": "幻燈片使用幻燈片播放的版面、疊加以及片頭與片尾畫面,依檢視順序排列。", + "The photos in view as slides, with the settings above. Progress shows in the activity stack.": "將檢視中的照片依上方設定製作為幻燈片。進度顯示在活動清單中。", + "Checking raw files": "正在檢查 RAW 檔案", + "1 raw can now be developed from its raw data": "1 個 RAW 現在可以從 RAW 資料沖印", + "This raw is now developed from its raw data": "此 RAW 現在從 RAW 資料沖印", + "This raw variant still can't be decoded": "這種 RAW 變體仍無法解碼", + "No lens data embedded in this file (DNG lens opcodes only).": "此檔案未內嵌鏡頭資料(僅支援 DNG 鏡頭操作碼)。", + "Export to": "輸出至", + "Specific folder": "指定資料夾", + "Same folder as original photo": "與原始照片相同的資料夾", + "Tidying the thumbnail cache": "正在整理縮圖快取", + "Reset all edits (Cmd+Shift+R)": "重設所有編輯(Cmd+Shift+R)", + "Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo": "智慧型相簿會自動更新:請變更其規則,或使用「照片 → 刪除照片」", + "LightCraft stopped": "LightCraft 已停止", + "Show Focus Point": "顯示對焦點", + "Zoom to Focus Point": "縮放至對焦點", + "Focus": "對焦", + "Focus point": "對焦點", + "Wide": "廣域", + "Center": "中間", + "Flexible Spot": "自由點", + "Zone": "區", + "Expanded Flexible Spot": "擴充自由點", + "Custom AF Area": "自訂AF區域", + "Face tracking": "臉部追蹤", + "Lock On AF": "鎖定AF", + "Multi": "多重", + "Spot": "點", + "Tracking": "追蹤", + "Face Tracking": "臉部追蹤", + "Animal Eye Tracking": "動物眼部追蹤", + "Human Eye Tracking": "人眼追蹤", + "Read Focus Points": "讀取對焦點", + "Reading focus points": "正在讀取對焦點", + "Click to type a value": "點擊以輸入數值", + "Run face models on the CPU only": "僅在 CPU 上執行臉孔模型", + "Detection": "偵測", + "Recognition": "辨識", + "Not loaded yet": "尚未載入", + "Checking the GPU…": "正在檢查 GPU…", + "CPU": "CPU", + "A face model uses the GPU only when a check shows it gives the same results faster.": "只有在檢查顯示 GPU 能更快得出相同結果時,臉孔模型才會使用 GPU。", + "Checking the GPU for faces": "正在檢查用於臉孔的 GPU" } diff --git a/crates/ui-egui/src/album_picker.rs b/crates/ui-egui/src/album_picker.rs index 72ea3f226..914827e5d 100644 --- a/crates/ui-egui/src/album_picker.rs +++ b/crates/ui-egui/src/album_picker.rs @@ -11,7 +11,7 @@ //! `albumPicker:<id>` (the button), `albumPickerSearch:<id>`, `albumPickerFolder:<folder>:<id>` //! and `albumPickerItem:<album>:<id>`. -use egui::{Response, RichText, Ui, vec2}; +use egui::{Rect, Response, RichText, Ui, vec2}; use crate::icons::Icon; use crate::widgets::register; @@ -146,14 +146,14 @@ pub fn search(entries: &[AlbumEntry], query: &str) -> Vec<usize> { first.into_iter().chain(rest).collect() } -/// A small filled triangle centred on `c`: pointing down (`down`), else right. -fn triangle(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { - let pts = if down { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; - p.add(egui::Shape::convex_polygon(pts, color, egui::Stroke::NONE)); +/// A disclosure chevron centred on `c`: pointing down (`down`), else right. +fn disclosure(p: &egui::Painter, c: egui::Pos2, down: bool, color: egui::Color32) { + crate::icons::paint( + p, + Rect::from_center_size(c, vec2(16.0, 16.0)), + if down { crate::icons::Icon::ChevronDown } else { crate::icons::Icon::ChevronRight }, + color, + ); } /// What the open popup remembers between frames. @@ -195,7 +195,7 @@ impl<'a> AlbumPicker<'a> { // the name, and a drop-down arrow painted at the right (the UI font has no arrow glyph) let mut button = ui.add_sized(vec2(width, ui.spacing().interact_size.y), egui::Button::new(text).truncate().right_text(" ")); let arrow_color = crate::theme::Tokens::get(ui.ctx()).icon; - triangle(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); + disclosure(ui.painter(), egui::pos2(button.rect.right() - 10.0, button.rect.center().y), true, arrow_color); register(ui.ctx(), format!("albumPicker:{id}"), button.rect); let key = egui::Id::new(("albumPicker", id.as_str())); if button.clicked() { @@ -255,7 +255,7 @@ impl<'a> AlbumPicker<'a> { ui.painter().rect_filled(rect, 3.0, t.hover); } let x = rect.left() + indent; - triangle(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); + disclosure(ui.painter(), egui::pos2(x + 7.0, rect.center().y), open, if r.hovered() { t.text } else { t.text_dim }); let icon = egui::Rect::from_center_size(egui::pos2(x + 23.0, rect.center().y), vec2(14.0, 14.0)); crate::icons::paint(ui.painter(), icon, Icon::Folder, t.icon); ui.painter().text( diff --git a/crates/ui-egui/src/brush_live.rs b/crates/ui-egui/src/brush_live.rs index 614b5bed3..83f6e186b 100644 --- a/crates/ui-egui/src/brush_live.rs +++ b/crates/ui-egui/src/brush_live.rs @@ -9,7 +9,7 @@ use egui::{Color32, ColorImage, Pos2, Rect, TextureHandle, TextureOptions, pos2}; use lightcraft_geom::Point; -use lightcraft_pipeline::masks::{accumulate, dab_alpha, push_dabs}; +use lightcraft_pipeline::masks::{accumulate, dab_alpha, pressure_flow, pressure_radius, push_dabs, push_pressures}; /// The largest live-stroke buffer edge (pixels); a bigger canvas is painted at a lower resolution. const MAX_SIDE: usize = 4096; @@ -42,6 +42,8 @@ pub struct LiveStroke { /// Samples stamped so far, and the last one (buffer pixels). placed: usize, last: Option<Point>, + /// The pen pressure of the last stamped sample. + last_pressure: f32, /// Buffer pixels painted since the last upload: (x0, y0, x1, y1), exclusive ends. dirty: Option<(usize, usize, usize, usize)>, texture: Option<TextureHandle>, @@ -52,7 +54,7 @@ impl LiveStroke { let scale = buffer_scale(params.clip, params.scale); let params = LiveParams { scale, ..params }; let (w, h) = buffer_size(params.clip, scale); - LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, dirty: None, texture: None } + LiveStroke { params, w, h, alpha: vec![0.0; w * h], placed: 0, last: None, last_pressure: 1.0, dirty: None, texture: None } } /// Stroke coverage (0..1) at buffer pixel `(x, y)`; 0 outside. @@ -69,8 +71,9 @@ impl LiveStroke { } /// Stamp the dabs of `points` (screen positions of the stroke's samples so far) not yet - /// painted. Returns `true` when anything new was painted. - pub fn stamp(&mut self, points: &[Pos2]) -> bool { + /// painted, at the pen pressures `pressure` (one per point; empty: full pressure), as the + /// pipeline scales them. Returns `true` when anything new was painted. + pub fn stamp(&mut self, points: &[Pos2], pressure: &[f32]) -> bool { let Some(new) = points.get(self.placed..) else { return false }; if new.is_empty() { return false; @@ -78,17 +81,28 @@ impl LiveStroke { let LiveParams { clip, scale, r, hard, flow, density, .. } = self.params; let (r, hard) = ((r * scale) as f64, (hard * scale) as f64); let mut dabs = Vec::new(); - for q in new { + let mut pressures = Vec::new(); + let first = self.placed; + for (k, q) in new.iter().enumerate() { let q = Point::new(((q.x - clip.left()) * scale) as f64, ((q.y - clip.top()) * scale) as f64); + let before = dabs.len(); if push_dabs(&mut dabs, self.last, q, r) { + let pq = pressure.get(first + k).or(pressure.last()).copied().unwrap_or(1.0); + let from = if self.last.is_some() { self.last_pressure } else { pq }; + push_pressures(&mut pressures, dabs.len() - before, from, pq); self.last = Some(q); + self.last_pressure = pq; } } self.placed = points.len(); - for d in dabs { + let base = (r, hard); + for (d, p) in dabs.into_iter().zip(pressures) { if !(d.x.is_finite() && d.y.is_finite()) { continue; } + let r = pressure_radius(base.0, p); + let hard = if base.0 > 0.0 { base.1 * r / base.0 } else { base.1 }; + let flow = pressure_flow(flow, p); let x0 = (d.x - r).floor().clamp(0.0, self.w as f64) as usize; let x1 = (d.x + r).ceil().clamp(0.0, self.w as f64) as usize; let y0 = (d.y - r).floor().clamp(0.0, self.h as f64) as usize; @@ -168,7 +182,7 @@ fn buffer_size(clip: Rect, scale: f32) -> (usize, usize) { /// Paint the live stroke whose samples so far are `points` (screen positions) into `painter`, /// keeping its buffer in `slot` (a new stroke, or new parameters, start it afresh). -pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2]) { +pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui::Painter, params: LiveParams, points: &[Pos2], pressure: &[f32]) { let fresh = match slot { Some(s) => { // the same stroke when painted with the same parameters (at the scale it settled on) @@ -181,7 +195,7 @@ pub fn paint(slot: &mut Option<LiveStroke>, ctx: &egui::Context, painter: &egui: *slot = Some(LiveStroke::new(params)); } let Some(s) = slot else { return }; - s.stamp(points); + s.stamp(points, pressure); let clip = s.params.clip; // the buffer's pixels may overhang the canvas by a fraction of a pixel: map them exactly let (w, h, scale) = (s.w as f32, s.h as f32, s.params.scale); @@ -214,8 +228,8 @@ mod tests { #[test] fn a_fast_segment_is_painted_continuously() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(20.0, 100.0)]); - s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)]); + s.stamp(&[pos2(20.0, 100.0)], &[]); + s.stamp(&[pos2(20.0, 100.0), pos2(380.0, 100.0)], &[]); for x in 20..380 { assert!(s.coverage(x, 100) > 0.99, "a gap at x = {x}: {}", s.coverage(x, 100)); } @@ -231,10 +245,10 @@ mod tests { fn incremental_stamping_matches_painting_the_whole_path() { let pts = [pos2(30.0, 40.0), pos2(200.0, 150.0), pos2(210.0, 152.0), pos2(390.0, 20.0)]; let mut once = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); - once.stamp(&pts); + once.stamp(&pts, &[]); let mut steps = LiveStroke::new(LiveParams { flow: 0.4, ..params() }); for n in 1..=pts.len() { - steps.stamp(&pts[..n]); + steps.stamp(&pts[..n], &[]); } assert_eq!(once.alpha, steps.alpha); } @@ -242,10 +256,27 @@ mod tests { #[test] fn hostile_input_paints_nothing_and_does_not_panic() { let mut s = LiveStroke::new(params()); - s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)]); + s.stamp(&[pos2(f32::NAN, 3.0), pos2(f32::INFINITY, -1e30), pos2(-5000.0, 9000.0)], &[]); assert!(s.alpha.iter().all(|a| *a == 0.0)); // an enormous canvas is painted at a capped resolution let big = LiveStroke::new(LiveParams { clip: Rect::from_min_size(Pos2::ZERO, vec2(1e6, 10.0)), scale: 2.0, ..params() }); assert!(big.w <= MAX_SIDE && big.h >= 1); } + + /// The live stroke scales its dabs by pen pressure as the committed one does: a light touch paints a thinner, + /// fainter line, and a pressure list shorter than the points (or hostile values) is handled. + #[test] + fn pen_pressure_thins_and_fades_the_live_stroke() { + let pts = [pos2(20.0, 100.0), pos2(380.0, 100.0)]; + let mut full = LiveStroke::new(params()); + full.stamp(&pts, &[]); + let mut light = LiveStroke::new(params()); + light.stamp(&pts, &[0.2, 0.2]); + let rows = |s: &LiveStroke| (0..s.h).filter(|y| s.coverage(200, *y) > 0.01).count(); + assert!(rows(&light) < rows(&full), "{} vs {}", rows(&light), rows(&full)); + assert!(light.coverage(200, 100) < full.coverage(200, 100)); + let mut odd = LiveStroke::new(params()); + odd.stamp(&pts, &[f32::NAN]); + assert!(odd.alpha.iter().all(|v| v.is_finite())); + } } diff --git a/crates/ui-egui/src/button.rs b/crates/ui-egui/src/button.rs new file mode 100644 index 000000000..f46156ed9 --- /dev/null +++ b/crates/ui-egui/src/button.rs @@ -0,0 +1,389 @@ +//! One button for the whole interface: what it is for ([`Kind`]: Primary, Secondary, Danger, +//! Ghost), how big ([`Size`]: Small, Medium, Large), and what it shows (text, an icon and text, +//! or an icon alone, which then needs a name for screen readers). It looks the same everywhere, +//! answers hover, press and the keyboard the same way, and its text always reads at WCAG AA on +//! its fill. +//! +//! ```ignore +//! if Button::new("Import Photos…").primary().widget("button:emptyImportPhotos").show(ui).clicked() { … } +//! Button::icon_only(Icon::Trash, "Delete mask").ghost().small().show(ui); +//! Button::new("Remove").icon(Icon::Trash).danger().show(ui); +//! ``` + +use egui::{Color32, Rect, Response, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; + +use crate::icons::{Icon, paint}; +use crate::theme::Tokens; + +/// What a button is for, which decides how loud it is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Kind { + /// The step to take: filled with the accent. One per place (a dialog's confirm, the welcome's + /// Import Photos…). + Primary, + /// Any other action: a neutral fill with a border. + #[default] + Secondary, + /// A step that removes or erases something, when it is the one being asked for: filled red. + Danger, + /// A quiet action among content (a toolbar, a banner's Hide): no fill until hovered. + Ghost, +} + +/// How big a button is: its height, padding, text and icon size. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Size { + /// 24 points: in toolbars, banners and dense rows. + Small, + /// 30 points: dialogs and panels. + #[default] + Medium, + /// 36 points: a page's one main action (the welcome, About's Discord link). + Large, +} + +impl Size { + pub const fn height(self) -> f32 { + match self { + Size::Small => 24.0, + Size::Medium => 30.0, + Size::Large => 36.0, + } + } + fn padding(self) -> f32 { + match self { + Size::Small => 8.0, + Size::Medium => 12.0, + Size::Large => 16.0, + } + } + fn text(self) -> f32 { + match self { + Size::Small => 12.0, + Size::Medium => 13.0, + Size::Large => 14.0, + } + } + fn icon(self) -> f32 { + match self { + Size::Small => 14.0, + Size::Medium => 16.0, + Size::Large => 18.0, + } + } + fn gap(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 6.0, + Size::Large => 8.0, + } + } + fn radius(self) -> f32 { + match self { + Size::Small => 4.0, + Size::Medium => 5.0, + Size::Large => 6.0, + } + } +} + +/// Where the pointer and the keyboard are, for the colours. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum State { + Idle, + Hovered, + Pressed, + Disabled, +} + +/// The colours a button of `kind` takes in `state`: fill (if any), border (if any), and what is +/// drawn on it (text and icon). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Look { + pub fill: Option<Color32>, + pub border: Option<Color32>, + pub content: Color32, +} + +/// WCAG 2 contrast of two opaque colours. +pub fn contrast(a: Color32, b: Color32) -> f32 { + let lum = |c: Color32| { + let ch = |v: u8| { + let v = f32::from(v) / 255.0; + if v <= 0.03928 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) } + }; + 0.2126 * ch(c.r()) + 0.7152 * ch(c.g()) + 0.0722 * ch(c.b()) + }; + let (x, y) = (lum(a), lum(b)); + (x.max(y) + 0.05) / (x.min(y) + 0.05) +} + +/// `c` toward black by `amount` (0 to 1). +fn darker(c: Color32, amount: f32) -> Color32 { + crate::widgets::lerp(c, Color32::BLACK, amount) +} + +/// `fill`, darkened as little as needed for white to read on it at 4.5:1 (a light theme's or a +/// bright red's fill would otherwise be too pale for white text). +fn fill_for_white(fill: Color32) -> Color32 { + let mut f = fill; + for _ in 0..20 { + if contrast(Color32::WHITE, f) >= 4.5 { + break; + } + f = darker(f, 0.06); + } + f +} + +/// The colours of a `kind` button in `state` with the theme `t`. +pub fn look(t: &Tokens, kind: Kind, state: State) -> Look { + let filled = |base: Color32| { + let base = fill_for_white(base); + // hover and press go darker: white on them only reads better + let fill = match state { + State::Idle | State::Disabled => base, + State::Hovered => darker(base, 0.12), + State::Pressed => darker(base, 0.22), + }; + Look { fill: Some(fill), border: None, content: Color32::WHITE } + }; + let look = match kind { + Kind::Primary => filled(t.accent), + Kind::Danger => filled(t.reject), + Kind::Secondary => Look { + fill: Some(match state { + State::Idle | State::Disabled => t.button, + State::Hovered => t.hover, + State::Pressed => t.pressed, + }), + border: Some(t.button_border), + content: t.text, + }, + Kind::Ghost => Look { + fill: match state { + State::Idle | State::Disabled => None, + State::Hovered => Some(t.hover), + State::Pressed => Some(t.pressed), + }, + border: None, + content: t.text, + }, + }; + if state == State::Disabled { + let fade = |c: Color32| c.gamma_multiply(0.45); + Look { fill: look.fill.map(fade), border: look.border.map(fade), content: fade(look.content) } + } else { + look + } +} + +/// A button, built and then shown with [`Button::show`] (in the layout) or [`Button::show_at`] +/// (in a given rectangle). +#[derive(Clone, Debug)] +pub struct Button<'a> { + text: Option<&'a str>, + icon: Option<Icon>, + name: Option<&'a str>, + kind: Kind, + size: Size, + enabled: bool, + widget: Option<String>, + tooltip: Option<&'a str>, + min_width: f32, + id_salt: Option<egui::Id>, +} + +impl<'a> Button<'a> { + /// A button with `text` (an English string, translated when drawn). + pub fn new(text: &'a str) -> Self { + Button { + text: Some(text), + icon: None, + name: None, + kind: Kind::default(), + size: Size::default(), + enabled: true, + widget: None, + tooltip: None, + min_width: 0.0, + id_salt: None, + } + } + + /// A button showing `icon` alone; `name` is what a screen reader says and the tooltip shows. + pub fn icon_only(icon: Icon, name: &'a str) -> Self { + Button { text: None, icon: Some(icon), name: Some(name), ..Button::new("") } + } + + /// An icon before the text. + pub fn icon(mut self, icon: Icon) -> Self { + self.icon = Some(icon); + self + } + + pub fn kind(mut self, kind: Kind) -> Self { + self.kind = kind; + self + } + pub fn primary(self) -> Self { + self.kind(Kind::Primary) + } + pub fn secondary(self) -> Self { + self.kind(Kind::Secondary) + } + pub fn danger(self) -> Self { + self.kind(Kind::Danger) + } + pub fn ghost(self) -> Self { + self.kind(Kind::Ghost) + } + + pub fn size(mut self, size: Size) -> Self { + self.size = size; + self + } + pub fn small(self) -> Self { + self.size(Size::Small) + } + pub fn large(self) -> Self { + self.size(Size::Large) + } + + /// Off: drawn faded, and a click does nothing. + pub fn enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Registered under this id (`button:…`) for scripts, tests and `ui.clickWidget`; unique on + /// screen, as every registered id is. + pub fn widget(mut self, id: impl Into<String>) -> Self { + self.widget = Some(id.into()); + self + } + + /// Shown on hover (an icon-only button shows its name without one). + pub fn tooltip(mut self, text: &'a str) -> Self { + self.tooltip = Some(text); + self + } + + /// Told apart from buttons of the same widget id in other places (each dialog's confirm is + /// `button:dialogOk`: salted with the dialog, the keyboard's place doesn't carry from one + /// dialog to the next). + pub fn id_salt(mut self, salt: impl std::hash::Hash + std::fmt::Debug) -> Self { + self.id_salt = Some(egui::Id::NULL.with(salt)); + self + } + + /// At least this wide (a row of buttons of one width). + pub fn min_width(mut self, width: f32) -> Self { + self.min_width = width; + self + } + + fn font(&self, t: &Tokens) -> egui::FontId { + // the loud kinds speak a little bolder + match self.kind { + Kind::Primary | Kind::Danger => t.semibold(self.size.text()), + Kind::Secondary | Kind::Ghost => t.font(self.size.text()), + } + } + + fn galley(&self, ui: &Ui) -> Option<std::sync::Arc<egui::Galley>> { + let t = Tokens::get(ui.ctx()); + self.text.filter(|s| !s.is_empty()).map(|s| ui.painter().layout_no_wrap(crate::i18n::tr(s).to_string(), self.font(&t), Color32::WHITE)) + } + + /// How big it is drawn. + pub fn desired_size(&self, ui: &Ui) -> Vec2 { + let h = self.size.height(); + let text_w = self.galley(ui).map_or(0.0, |g| g.size().x); + let w = match (self.icon.is_some(), text_w > 0.0) { + // an icon alone: a square + (true, false) => h, + (true, true) => self.size.padding() * 2.0 + self.size.icon() + self.size.gap() + text_w, + (false, _) => self.size.padding() * 2.0 + text_w, + }; + vec2(w.max(self.min_width), h) + } + + /// Show it where the layout puts it. + pub fn show(self, ui: &mut Ui) -> Response { + let size = self.desired_size(ui); + let (rect, _) = ui.allocate_exact_size(size, Sense::hover()); + self.show_at(ui, rect) + } + + /// Show it in `rect` (sized by the caller, e.g. a row of equal buttons). + pub fn show_at(self, ui: &mut Ui, rect: Rect) -> Response { + let t = Tokens::get(ui.ctx()); + // stable across frames and moves: its widget id (with its salt) when it has one, else its + // place in the ui (moved on, so the next button in the same ui gets another) + let id = match &self.widget { + Some(w) => ui.id().with(("lc-button", w.as_str(), self.id_salt)), + None => { + let id = ui.auto_id_with(("lc-button", self.text.or(self.name), self.id_salt)); + ui.skip_ahead_auto_ids(1); + id + } + }; + // a button in a disabled part of the window is disabled too + let enabled = self.enabled && ui.is_enabled(); + let resp = ui.interact(rect, id, if enabled { Sense::click() } else { Sense::hover() }); + let words = crate::i18n::tr(self.name.or(self.text).unwrap_or_default()).to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, enabled, words.clone())); + if let Some(w) = &self.widget { + crate::widgets::register(ui.ctx(), w.clone(), rect); + } + let state = if !enabled { + State::Disabled + } else if resp.is_pointer_button_down_on() { + State::Pressed + } else if resp.hovered() { + State::Hovered + } else { + State::Idle + }; + let lk = look(&t, self.kind, state); + // (its own rectangle and its focus ring: a long text never runs over its neighbours) + let p = &ui.painter_at(rect.expand(5.0)); + let radius = self.size.radius(); + if let Some(fill) = lk.fill { + p.rect_filled(rect, radius, fill); + } + if let Some(border) = lk.border { + p.rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside); + } + // where the keyboard is: an accent ring around it + if resp.has_focus() { + p.rect_stroke(rect.expand(2.0), radius + 2.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + } + let galley = self.galley(ui); + let icon_s = self.size.icon(); + let content_w = match (&galley, self.icon) { + (Some(g), Some(_)) => icon_s + self.size.gap() + g.size().x, + (Some(g), None) => g.size().x, + (None, Some(_)) => icon_s, + (None, None) => 0.0, + }; + let mut x = rect.center().x - content_w / 2.0; + if let Some(icon) = self.icon { + paint(p, Rect::from_center_size(pos2(x + icon_s / 2.0, rect.center().y), vec2(icon_s, icon_s)), icon, lk.content); + x += icon_s + self.size.gap(); + } + if let Some(g) = galley { + let at = pos2(x, rect.center().y - g.size().y / 2.0); + p.galley_with_override_text_color(at, g, lk.content); + } + // the tooltip, or an icon's name, also while disabled (what the button would do). egui + // calls a response disabled only in a disabled ui: a button turned off on its own still + // answers hover, and takes the ordinary tooltip + match self.tooltip.or(if self.text.is_none() { self.name } else { None }) { + Some(tip) if resp.enabled() => resp.on_hover_text(crate::i18n::tr(tip)), + Some(tip) => resp.on_disabled_hover_text(crate::i18n::tr(tip)), + None => resp, + } + } +} diff --git a/crates/ui-egui/src/canvas_keys.rs b/crates/ui-egui/src/canvas_keys.rs new file mode 100644 index 000000000..dadbb1577 --- /dev/null +++ b/crates/ui-egui/src/canvas_keys.rs @@ -0,0 +1,99 @@ +//! Keyboard and screen-reader access to the handles drawn on the photo (crop corners and edges, +//! spot removals, gradient handles, mask pins). Each handle gets an invisible, focusable stand-in +//! at its place on the canvas: Tab reaches it, a screen reader announces its name and position, +//! and the arrow keys move it (Shift: ten times further). The pointer still grabs the handle as +//! before; the stand-in senses no clicks or drags. + +use egui::{Id, Key, Pos2, Rect, Ui, vec2}; + +/// One arrow press moves a handle this far, as a fraction of the photo's width or height. +pub const STEP: f64 = 0.005; +/// With Shift held. +pub const BIG_STEP: f64 = 0.05; + +fn focused_key() -> Id { + Id::new("lc-canvas-handle-focused") +} + +/// The egui id of the stand-in registered as `key` (to focus it, or find it in tests). +pub fn id(key: &str) -> Id { + Id::new("lc-canvas-handle").with(key) +} + +/// Whether a canvas handle holds the keyboard focus: it keeps the arrow keys and Return from the +/// app's shortcuts (next photo, and so on). +pub fn has_focus(ctx: &egui::Context) -> bool { + let focused = ctx.memory(|m| m.focused()); + focused.is_some() && focused == ctx.data(|d| d.get_temp::<Id>(focused_key())) +} + +/// What the keys did to a focused handle this frame. +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct HandleKeys { + /// The move asked for, in normalized photo units (x right, y down). + pub delta: Option<(f64, f64)>, + /// Return or Space was pressed. + pub activated: bool, +} + +/// `(x, y)` normalized, as a screen reader reads it: "x 30%, y 60%". +pub fn position_text(x: f64, y: f64) -> String { + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + format!("x {}%, y {}%", pct(x), pct(y)) +} + +/// The focusable stand-in for a handle drawn at `at`, named `name` with `value` (its position or +/// size), registered for the control channel as `canvasHandle:<key>`. +pub fn handle(ui: &mut Ui, key: &str, name: &str, value: String, at: Pos2) -> HandleKeys { + let id = id(key); + let rect = Rect::from_center_size(at, vec2(14.0, 14.0)); + let resp = ui.interact(rect, id, egui::Sense::focusable_noninteractive()); + let label = name.to_string(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label.clone())); + ui.ctx().accesskit_node_builder(id, |node| { + node.set_value(value); + }); + crate::widgets::register(ui.ctx(), format!("canvasHandle:{key}"), rect); + let mut out = HandleKeys::default(); + if !resp.has_focus() { + return out; + } + ui.data_mut(|d| d.insert_temp(focused_key(), id)); + // a ring shows where the focus is + ui.painter().circle_stroke(at, 9.0, egui::Stroke::new(2.0, ui.visuals().selection.stroke.color)); + ui.painter().circle_stroke(at, 11.0, egui::Stroke::new(1.0, egui::Color32::BLACK)); + ui.input_mut(|i| { + let shift = i.modifiers.shift; + let m = if shift { egui::Modifiers::SHIFT } else { egui::Modifiers::NONE }; + let step = if shift { BIG_STEP } else { STEP }; + let (mut dx, mut dy) = (0.0, 0.0); + if i.consume_key(m, Key::ArrowLeft) { + dx -= step; + } + if i.consume_key(m, Key::ArrowRight) { + dx += step; + } + if i.consume_key(m, Key::ArrowUp) { + dy -= step; + } + if i.consume_key(m, Key::ArrowDown) { + dy += step; + } + if dx != 0.0 || dy != 0.0 { + out.delta = Some((dx, dy)); + } + out.activated = i.consume_key(egui::Modifiers::NONE, Key::Enter) || i.consume_key(egui::Modifiers::NONE, Key::Space); + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn positions_read_as_percentages_and_survive_nan() { + assert_eq!(position_text(0.304, 0.6), "x 30%, y 60%"); + assert_eq!(position_text(f64::NAN, f64::INFINITY), "x 0%, y 0%"); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 891914d1d..097b13c4d 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -113,7 +113,7 @@ pub fn inspect(app: &LightcraftApp, ctx: &egui::Context) -> Value { "selection": app.session.selection.ids.iter().map(|p| p.0).collect::<Vec<_>>(), "activeMask": app.session.active_mask, "widgetCount": app.widgets.len(), - "perf": {"frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, + "perf": {"viewFollowed": app.session.view_followed, "viewWorkedOut": app.session.view_worked_out, "frameMs": app.perf.frame_ms, "logicMs": app.perf.logic_ms, "updateMs": app.perf.update_ms, "maxUpdateMs": app.perf.max_update_ms, "fps": app.perf.fps, "lastRenderMs": app.renderer.last_main_ms, "renderQueue": app.renderer.queued(), "rendersInFlight": app.renderer.in_flight(), "pendingSlots": app.renderer.pending_slots(), "mergeRunning": app.merge.busy(), "lastMerge": app.merge.last_result, "rendersDone": app.renderer.completed, "thumbTextures": app.renderer.thumb_textures(), "variantTextures": app.renderer.variant_textures(), "gpu": (lightcraft_engine::gpu::ready() && lightcraft_engine::gpu::available()).then(lightcraft_engine::gpu::adapter_name).flatten(), "gpuReason": lightcraft_engine::gpu::unavailable_reason(), "gpuFallback": lightcraft_engine::gpu::last_fallback()}, "loupe": app.loupe_shown.map(|(p, src)| json!({ "photo": p.0, "source": src, @@ -388,7 +388,10 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context, req: &ControlRequest if (r.is_err() && matches!( d, - crate::state::Dialog::Import { .. } | crate::state::Dialog::SamModel { .. } | crate::state::Dialog::SmartRules { .. } + crate::state::Dialog::Import { .. } + | crate::state::Dialog::SamModel { .. } + | crate::state::Dialog::SmartRules { .. } + | crate::state::Dialog::SaveEffect { .. } )) || (r.is_ok() && crate::panels::dialogs::keeps_open(app, &d)) { @@ -504,7 +507,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String // An exact output file needs no folder. A folder given but left blank (the Export dialog's Folder // field cleared) is an error, like Lightroom refusing to export to an unspecified folder, rather // than a silent write into the working directory. No folder at all (e.g. File → Export with - // Preset): the last export's, else the default. + // Preset): the last export's, else the default. "Same folder as original photo" needs none: + // each photo goes to its own folder. let exact = p.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).map(str::to_string); let dir = match p.get("dir").and_then(Value::as_str) { Some(d) => d.trim().to_string(), @@ -514,7 +518,8 @@ pub fn export_active(app: &mut LightcraftApp, p: &Value) -> Result<Value, String last_dir.map(str::to_string).unwrap_or_else(default_export_dir) } }; - if dir.is_empty() && exact.is_none() { + let own_folders = opts.destination_mode == lightcraft_engine::export::DestinationMode::SameAsOriginal; + if dir.is_empty() && exact.is_none() && !own_folders { return Err(NO_EXPORT_FOLDER.into()); } let to = Destination { dir: dir.clone(), exact }; diff --git a/crates/ui-egui/src/credits.rs b/crates/ui-egui/src/credits.rs index 964396850..d451aa79d 100644 --- a/crates/ui-egui/src/credits.rs +++ b/crates/ui-egui/src/credits.rs @@ -240,7 +240,7 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.separator(); ui.label(crate::i18n::tr("Sort")); - egui::ComboBox::from_id_salt("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { + crate::widgets::combo_box("credits_sort").selected_text(crate::i18n::tr(v.key.label().0)).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, crate::i18n::tr(k.label().0)).clicked() { v.key = k; @@ -248,7 +248,10 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } }); - if ui.button(if v.ascending { "▲" } else { "▼" }).on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { + let reverse = + crate::widgets::arrow_button(ui, "", if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, false); + reverse.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Reverse the order"))); + if reverse.on_hover_text(crate::i18n::tr("Reverse the order")).clicked() { v.ascending = !v.ascending; } ui.separator(); @@ -289,12 +292,18 @@ pub fn contributors_ui(app: &mut LightcraftApp, ui: &mut egui::Ui) { fn table(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &[&Contributor], v: &mut View) { egui::Grid::new("credits_table").striped(true).num_columns(SortKey::ALL.len()).show(ui, |ui| { for k in SortKey::ALL { - let arrow = if v.key == k { if v.ascending { " ▲" } else { " ▼" } } else { "" }; - if ui - .button(RichText::new(format!("{}{arrow}", crate::i18n::tr(k.label().1))).strong()) - .on_hover_text(crate::i18n::tr(k.label().0)) - .clicked() - { + let text = RichText::new(crate::i18n::tr(k.label().1)).strong(); + let header = if v.key == k { + crate::widgets::arrow_button( + ui, + text, + if v.ascending { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + false, + ) + } else { + ui.button(text) + }; + if header.on_hover_text(crate::i18n::tr(k.label().0)).clicked() { if v.key == k { v.ascending = !v.ascending; } else { diff --git a/crates/ui-egui/src/date_picker.rs b/crates/ui-egui/src/date_picker.rs index c04c309af..c84df98c4 100644 --- a/crates/ui-egui/src/date_picker.rs +++ b/crates/ui-egui/src/date_picker.rs @@ -266,7 +266,8 @@ impl<'a> DatePicker<'a> { ui.separator(); } ui.horizontal(|ui| { - let prev = ui.small_button("‹"); + let prev = crate::widgets::arrow_button(ui, "", Icon::ChevronLeft, true); + prev.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Previous page"))); register(ui.ctx(), format!("datePickerPrev:{id}"), prev.rect); let title = match view.precision { Precision::Day => crate::i18n::date_group_label(&format!("{:04}-{:02}", view.year, view.month), false), @@ -278,7 +279,8 @@ impl<'a> DatePicker<'a> { }; ui.label(RichText::new(title).strong()); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let next = ui.small_button("›"); + let next = crate::widgets::arrow_button(ui, "", Icon::ChevronRight, false); + next.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, ui.is_enabled(), crate::i18n::tr("Next page"))); register(ui.ctx(), format!("datePickerNext:{id}"), next.rect); if next.clicked() { view = step(view, true); diff --git a/crates/ui-egui/src/export_task.rs b/crates/ui-egui/src/export_task.rs index 97ed3b78d..1aec84820 100644 --- a/crates/ui-egui/src/export_task.rs +++ b/crates/ui-egui/src/export_task.rs @@ -132,6 +132,59 @@ pub fn start_contact_sheet(app: &mut LightcraftApp, params: &Value) -> Result<Va Ok(json!({"background": true, "total": total})) } +/// Print the session's current print (`print.get`) to a PDF in the background, with the same +/// activity row and cancellation as the other exports. +pub fn start_print(app: &mut LightcraftApp, params: &Value) -> Result<Value, String> { + use lightcraft_engine::print::{compose, output}; + if app.export.is_some() { + return Err("an export is already running".into()); + } + let path = params.get("path").and_then(Value::as_str).filter(|s| !s.trim().is_empty()).ok_or("missing path")?.to_string(); + app.session.check_write_target(&path)?; + let guard = app.session.original_guard(); + let settings = app.session.print.clone(); + let ids = app.session.targets(params).map_err(|e| e.to_string())?; + let prepared = compose::prepare(&mut app.session, &settings, &ids)?; + let total = prepared.len(); + let title = std::path::Path::new(&path).file_stem().and_then(|n| n.to_str()).unwrap_or("Print").to_string(); + let write = app.services.write_shared.clone(); + let progress = Arc::new(Mutex::new((0, String::new()))); + let cancel = Arc::new(AtomicBool::new(false)); + let (p, c) = (progress.clone(), cancel.clone()); + let (tx, rx) = channel(); + let photos = ids.len(); + let work = move || { + let result = prepared + .run(&mut |done, name| { + if let Ok(mut g) = p.lock() { + *g = (done, name.to_string()); + } + !c.load(Ordering::Relaxed) + }) + .and_then(|pages| { + if c.load(Ordering::Relaxed) { + return Err("Print cancelled".into()); + } + let bytes = output::write(&pages, &settings.job, &title)?; + guard.check(std::path::Path::new(&path))?; + match write { + Some(write) => write(&path, &bytes)?, + None => lightcraft_engine::export::write_file(&path, &bytes)?, + } + Ok(vec![json!({"path": path, "pages": pages.len(), "photos": photos, "bytes": bytes.len(), "print": true})]) + }); + let _ = tx.send(result.map(|files| (files, None, c.load(Ordering::Relaxed)))); + }; + #[cfg(not(target_arch = "wasm32"))] + std::thread::Builder::new().name("print".into()).spawn(work).map_err(|e| e.to_string())?; + #[cfg(target_arch = "wasm32")] + work(); + let row = app.session.activity.start("export", "Printing to PDF", Cancel::Flag(cancel.clone())); + row.progress(0, total as u64); + app.export = Some(ExportTask { total, progress, cancel, rx, guard: row }); + Ok(json!({"background": true, "total": total})) +} + /// The toast's account of [`AfterExport::run`]'s Apple Photos outcome. pub fn apple_photos_note(outcome: &Value) -> String { if let Some(e) = outcome.get("error").and_then(Value::as_str) { @@ -200,6 +253,9 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sheet) = sheet { m = format!("Exported contact sheet: {} photos, {} pages", sheet["photos"], sheet["pages"]); } + if let Some(pdf) = files.first().filter(|f| f["print"] == true) { + m = format!("Printed to PDF: {} photos, {} pages", pdf["photos"], pdf["pages"]); + } if skipped > 0 { m += &crate::i18n::tr_format!(" · {skipped} skipped (file exists)", skipped = skipped); } @@ -263,25 +319,41 @@ mod contact_sheet_tests { })), ..Default::default() }; - let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + // Exercise the menu/picker/background PDF workflow with a small procedural original. + // A full-size demo makes this test's deadline depend on unrelated concurrent renders. + let demo = lightcraft_engine::Session::with_demo(); + let id = demo.active().unwrap(); + let mut photo = (**demo.catalog.photo(id).unwrap()).clone(); + photo.width = 96; + photo.height = 64; + let mut session = lightcraft_engine::Session::new(); + session.catalog.apply(lightcraft_catalog::Op::AddPhoto { photo: Box::new(photo) }).unwrap(); + session.execute("library.select", &json!({"ids": [id.0], "active": id.0})).unwrap(); + let mut app = LightcraftApp::new(session, services); app.run("dialog.contactSheet", json!({})).unwrap(); let Some(Dialog::ContactSheet { options }) = app.ui.dialog.as_mut() else { panic!("no contact sheet settings") }; options.paper = "letter".into(); options.landscape = true; - let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); - let dialog = h.app.ui.dialog.take().unwrap(); - crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); - assert_eq!(h.app.pending_picks.len(), 1); - let deadline = Instant::now() + Duration::from_secs(20); + let dialog = app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut app, &dialog).unwrap(); + assert_eq!(app.pending_picks.len(), 1); + let ctx = egui::Context::default(); + crate::pick::poll(&mut app, &ctx); + assert!(app.pending_picks.is_empty()); + assert!(app.export.is_some(), "picker did not start export: {:?}", app.ui.toast); + // a hang guard, not a speed check: the render queues behind other tests' demo scenes (generated one at a + // time process-wide), which on a loaded machine takes minutes + let deadline = Instant::now() + Duration::from_secs(300); loop { - // The activity stack lays out text, which egui only allows inside a frame. - h.step(); - if h.app.last_export_result.is_some() { + // Poll the real worker without starting unrelated gallery renders each frame. + poll(&mut app, &ctx); + if app.export.is_none() { break; } assert!(Instant::now() < deadline, "export did not finish"); std::thread::sleep(Duration::from_millis(10)); } + let result = app.last_export_result.as_ref().unwrap_or_else(|| panic!("export failed: {:?}", app.ui.toast)); let req = asked.lock().unwrap(); assert_eq!(req[0].kind, PickKind::Save); assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); @@ -289,7 +361,7 @@ mod contact_sheet_tests { let b = bytes.lock().unwrap(); assert!(b.starts_with(b"%PDF-1.4")); assert!(String::from_utf8_lossy(&b).contains("/MediaBox [0 0 792.00 612.00]")); - assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["photos"], 1); + assert_eq!(result["files"][0]["photos"], 1); } #[test] diff --git a/crates/ui-egui/src/focus_scan.rs b/crates/ui-egui/src/focus_scan.rs new file mode 100644 index 000000000..de6e292dc --- /dev/null +++ b/crates/ui-egui/src/focus_scan.rs @@ -0,0 +1,24 @@ +//! Keeps the engine's focus-point reader going (`focus.pump`): it reads the camera's focus point for photos imported +//! before it was read, a few KB of each file, at the pace the face scan uses (nothing new while the user drags, types or +//! scrolls). Its progress is its row in the activity stack. + +use serde_json::json; + +use crate::LightcraftApp; + +/// How often a running reader is asked for what it has read (its batches commit from here). +const BUSY_SECS: f64 = 0.25; + +/// Called every frame. Idle, it costs a comparison and wakes nothing; reading, it asks again every [`BUSY_SECS`]. +pub fn pump(app: &mut LightcraftApp, ctx: &egui::Context) { + let now = ctx.input(|i| i.time); + if now < app.caches.focus_next_pump { + return; + } + let pace = crate::panels::faces::background_pace(app, ctx, now); + let Ok(v) = app.session.execute("focus.pump", &json!({"pace": pace})) else { return }; + if v["active"] == true { + app.caches.focus_next_pump = now + BUSY_SECS; + ctx.request_repaint_after(std::time::Duration::from_secs_f64(BUSY_SECS)); + } +} diff --git a/crates/ui-egui/src/headless.rs b/crates/ui-egui/src/headless.rs index 0507ab31f..1d8b58401 100644 --- a/crates/ui-egui/src/headless.rs +++ b/crates/ui-egui/src/headless.rs @@ -292,6 +292,11 @@ impl Headless { self.frames } + /// The simulated clock (seconds) the next frame runs at. + pub fn time(&self) -> f64 { + self.time + } + /// Every piece of text the last frame painted ([`HeadlessView::painted_text`]). pub fn painted_text(&self) -> Vec<String> { self.view.painted_text() @@ -1387,6 +1392,122 @@ mod tests { assert_eq!(h.request("engine.execute", json!({"command": "view.person", "params": {"name": " "}}), t)["ok"], false); } + /// A person's More grid confirms a score-bounded batch through either ↑ or the focused tile's A key. Both + /// routes use one undo step and leave hidden, lower-score and already named faces alone. + #[test] + fn person_more_confirms_all_above_with_one_undo_step() { + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(7).collect(); + assert_eq!(ids.len(), 7); + let region = |name: Option<&str>| lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.25, y0: 0.2, x1: 0.55, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: name.map(str::to_string), + description: None, + }; + for (id, name) in ids.iter().zip([Some("Jane Doe"), None, None, None, None, None, Some("John Roe")]) { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![region(name)]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), t); + h.settle(SETTLE); + let page = crate::panels::person::PersonPage { + name: "Jane Doe".into(), + total: 1, + confirmed: vec![crate::panels::person::Face { photo: ids[0].0, index: 0, rect: region(None).rect, score: 0.0 }], + more: ids[1..6] + .iter() + .zip([0.95, 0.80, 0.80, 0.80, 0.60]) + .map(|(id, score)| crate::panels::person::Face { photo: id.0, index: 0, rect: region(None).rect, score }) + .collect(), + ready: true, + pending: 0, + }; + let set_page = |h: &mut Headless| { + h.app.caches.person_page = + Some(("Jane Doe".into(), h.app.session.catalog.revision, h.app.caches.faces_indexed, 0.0, Arc::new(page.clone()))); + h.step(); + }; + h.app.ui.dismissed_faces.insert((ids[4].0, 0)); + set_page(&mut h); + let bulk_id = format!("more-face-above:{}:0", ids[2].0); + assert!(h.request("ui.widgets", json!({"filter": "more-face-above:"}), t).to_string().contains(&bulk_id)); + let undo = h.app.session.undo.len(); + let r = h.request("ui.clickWidget", json!({"id": bulk_id}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let names = |h: &Headless| ids.iter().map(|id| h.app.session.catalog.photo(*id).unwrap().meta.regions[0].name.clone()).collect::<Vec<_>>(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + assert_eq!(names(&h), vec![Some("Jane Doe".into()), None, None, None, None, None, Some("John Roe".into())]); + // With no target A cannot name a face, even on this page. + h.request("ui.move", json!({"x": 1, "y": 1}), t); + h.view.ctx.memory_mut(|m| m.surrender_focus(egui::Id::new(("more-face-above", ids[2].0, 0_usize)))); + set_page(&mut h); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + // Text editing owns A even if a face was the last pointer target. + let search = h.request("ui.clickWidget", json!({"id": "field:search"}), t); + assert_eq!(search["ok"], true, "{search}"); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1], None); + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + // Hovering a tile is enough for the A key, without clicking or moving keyboard focus there. + let tile_id = format!("more-face:{}:0", ids[2].0); + let rect = h.app.widgets.iter().find(|(id, _)| id == &tile_id).unwrap().1; + h.request("ui.move", json!({"x": rect.center().x, "y": rect.center().y}), t); + h.step(); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(names(&h)[1..4], [Some("Jane Doe".into()), Some("Jane Doe".into()), Some("Jane Doe".into())]); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "edit.undo"}), t); + h.step(); + h.request("ui.move", json!({"x": 1, "y": 1}), t); + set_page(&mut h); + // Keyboard focus on the candidate makes the same batch action available without hovering it. + h.view.ctx.memory_mut(|m| m.request_focus(egui::Id::new(("more-face", ids[2].0, 0_usize)))); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!( + names(&h), + vec![ + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + Some("Jane Doe".into()), + None, + None, + Some("John Roe".into()) + ] + ); + assert_eq!(h.app.session.undo.len(), undo + 1); + h.request("engine.execute", json!({"command": "view.photoGrid"}), t); + h.request("ui.key", json!({"key": "a"}), t); + h.step(); + assert_eq!(h.app.session.undo.len(), undo + 1, "A outside the person page cannot name more faces"); + } + /// The People view lists the unnamed faces below the named people: select some (a click each, or all), type a name, /// press Enter, and they are all named at once, in one undo step; the new person appears among the named. #[test] @@ -1463,6 +1584,56 @@ mod tests { assert_eq!(named(&h, ids[1], 0), None); } + /// A double click on an unnamed face opens its photo in the loupe, as in the grid (#1664); a single click still only + /// selects it, and Escape from that photo goes back to the People view. + #[test] + fn double_clicking_an_unnamed_face_opens_its_photo() { + use crate::state::ViewMode; + use lightcraft_catalog::Op; + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + for id in &ids { + let mut meta = h.app.session.catalog.photo(*id).unwrap().meta.clone(); + meta.regions = vec![lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: 0.3, y0: 0.2, x1: 0.5, y1: 0.55 }, + kind: lightcraft_meta::RegionKind::Face, + name: None, + description: None, + }]; + h.app.session.commit("setup", Op::SetMeta { id: *id, meta: Box::new(meta) }).unwrap(); + } + h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0].0]}}), t); + h.request("engine.execute", json!({"command": "view.people"}), t); + h.settle(SETTLE); + h.step(); + h.step(); + let tile = format!("unnamed-face:{}:0", ids[1].0); + // one click selects the face and stays in People + let r = h.request("ui.clickWidget", json!({"id": tile}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::People, Some(ids[0]))); + assert_eq!(h.app.ui.unnamed_selected.len(), 1); + h.request("ui.clickWidget", json!({"id": "unnamed:clear"}), t); + // half a second of frames, so egui does not count the next two clicks as the third and fourth of one burst + for _ in 0..30 { + h.step(); + } + // two open the photo the face is in + let r = h.request("ui.clickWidget", json!({"id": tile, "count": 2}), t); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!((h.app.ui.view, h.app.session.active()), (ViewMode::Detail, Some(ids[1]))); + assert_eq!(h.app.session.catalog.photo(ids[1]).unwrap().meta.regions[0].name, None, "nothing is named"); + // Escape goes back to the faces, not to the grid + h.request("ui.key", json!({"key": "escape"}), t); + h.step(); + assert_eq!((h.app.ui.view, h.app.ui.person_page.clone()), (ViewMode::People, None)); + } + /// The name field under the unnamed faces is a shared text field: right-click ▸ Paste puts the /// clipboard's name in, and Esc leaves the field and clears the selection, as Clear does. #[test] @@ -1532,7 +1703,7 @@ mod tests { // the pictures are made and kept, well beyond the old budget (a few demo photos share a scene, so two faces can share a // picture: the count is a little under the number of tiles) let started = Instant::now(); - while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(20) { + while h.app.renderer.variant_textures() < drawn - 20 && started.elapsed() < Duration::from_secs(60) { h.step(); std::thread::sleep(Duration::from_millis(20)); } @@ -1601,6 +1772,23 @@ mod tests { h.settle(SETTLE); } + /// Hovering the applied profile shows the applied look (no preview), not the last other profile hovered. + #[test] + fn hovering_the_applied_profile_drops_the_last_hover() { + let mut h = demo([1400.0, 900.0]); + let t = Duration::from_secs(10); + h.request("ui.set", json!({"view": "detail"}), t); + h.request("engine.execute", json!({"command": "panel.profiles"}), t); + h.settle(SETTLE); + h.request("ui.clickWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.portrait"}), t); + h.step(); + assert!(h.app.hover_preview.is_some(), "another profile previews"); + h.request("ui.hoverWidget", json!({"id": "profileCell:lc.vivid"}), t); + h.step(); + assert!(h.app.hover_preview.is_none(), "the applied profile is shown, not the last hovered one"); + } + /// Presets column: resting on a preset previews it in the loupe without a history entry; /// thumbnails are variant renders; Create Preset includes only the checked groups. #[test] @@ -2365,6 +2553,7 @@ mod tests { file_size: ph.file_size, content_hash: ph.content_hash.clone(), preview_only: Some("Nikon Huffman-compressed NEF (no clean-room description available)".into()), + preview_checked: lightcraft_catalog::APP_VERSION.into(), }) .unwrap(); h.settle(SETTLE); @@ -2381,10 +2570,18 @@ mod tests { h.settle(SETTLE); let n = ids(&mut h, "notice:previewOnly:"); assert!(n.contains(&"notice:previewOnly:loupe".to_string()) && n.contains(&"notice:previewOnly:edit".to_string()), "{n:?}"); + // with a Reload from Disk button (issue #662) + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:edit".to_string()]); // Info h.app.ui.right = crate::state::RightPanel::Info; h.settle(SETTLE); assert!(ids(&mut h, "notice:previewOnly:").contains(&"notice:previewOnly:info".to_string())); + assert_eq!(ids(&mut h, "button:reloadPreviewOnly:"), vec!["button:reloadPreviewOnly:info".to_string()]); + // …which re-reads the file and says what came of it (a demo photo has none to re-read) + let r = h.request("ui.clickWidget", json!({"id": "button:reloadPreviewOnly:info"}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert_eq!(h.app.ui.toast.clone().map(|x| x.0).as_deref(), Some("This raw variant still can't be decoded")); // a regular photo: no notice h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [other.0]}}), t); h.app.ui.right = crate::state::RightPanel::Edit; @@ -2948,6 +3145,71 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Switching Settings tabs keeps the window's top edge (and its tab row) where it is and never makes it + /// narrower, though the tabs are of different heights and widths. + #[test] + fn settings_tabs_keep_the_window_in_place() { + let mut h = demo([1300.0, 820.0]); + let t = Duration::from_secs(10); + let window = |h: &Headless| h.app.widgets.iter().find(|(w, _)| w == "dialog:window").map(|(_, r)| *r).expect("the dialog"); + let r = h.request("engine.execute", json!({"command": "app.settings", "params": {"tab": "general"}}), t); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let first = window(&h); + let mut heights = Vec::new(); + for tab in ["display", "import", "performance", "general", "interface", "display"] { + h.request("ui.clickWidget", json!({"id": format!("button:settingsTab-{tab}")}), t); + h.settle(SETTLE); + let now = window(&h); + assert!((now.top() - first.top()).abs() < 1.0, "{tab}: top {} → {}", first.top(), now.top()); + assert!(now.width() >= first.width() - 1.0, "{tab}: width {} → {}", first.width(), now.width()); + heights.push(now.height()); + } + assert!(heights.iter().any(|h| (h - first.height()).abs() > 20.0), "the tabs differ in height: {heights:?}"); + } + + /// Settings → Import → "Show the import review when a camera or card is connected": a card that + /// was already in doesn't ask, a new one opens Import from Device (copying), and a busy window + /// asks once it's free. + #[test] + fn a_connected_card_opens_the_import_review() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let root = std::env::temp_dir().join(format!("lc-card-{}", std::process::id())); + let dcim = root.join("DCIM/100NIKON"); + std::fs::create_dir_all(&dcim).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + std::fs::write(dcim.join("DSC_0001.png"), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let card = lightcraft_engine::devices::Device { + name: "NIKON Z 8".into(), + path: root.join("DCIM").to_string_lossy().to_string(), + root: root.to_string_lossy().to_string(), + }; + h.app.ui.dialog = None; + // first look: the card was already in + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none() && h.app.ui.dialog.is_none(), "a card that was already in doesn't ask"); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none()); + // taken out, then put back while another dialog is open: it waits + crate::import::cards_seen(&mut h.app, Vec::new()); + h.app.ui.dialog = Some(crate::state::Dialog::About); + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_none(), "a busy window isn't interrupted"); + h.app.ui.dialog = None; + crate::import::cards_seen(&mut h.app, vec![card.clone()]); + assert!(h.app.scan.is_some(), "the card is scanned once the window is free"); + assert!(h.step_until(t, |h| matches!(h.app.ui.dialog, Some(crate::state::Dialog::Import { .. })))); + let Some(crate::state::Dialog::Import { opts }) = &h.app.ui.dialog else { panic!("no import review") }; + assert!(opts.copy, "a card is copied into the library"); + // with the option off nothing is remembered, so turning it on again starts with a first look + assert!(!h.app.session.import_defaults.show_on_card); + h.step(); + assert!(h.app.ui.known_cards.is_none()); + let _ = std::fs::remove_dir_all(&root); + } + /// The folder scan runs in the background: the request returns at once and the review opens /// when the scan finishes (a folder on a network share must not freeze the window). /// Clicking the folder being read again keeps the running read (and its progress). @@ -2983,6 +3245,43 @@ mod tests { let _ = std::fs::remove_dir_all(&dir); } + /// Browsing a folder again from the sidebar forgets the untouched Local records of files deleted from it by + /// another app, as `library.browse` does (issue #1392). + #[test] + fn browsing_again_from_the_ui_forgets_deleted_files() { + let mut h = demo([1200.0, 900.0]); + let t = Duration::from_secs(10); + let dir = std::env::temp_dir().join(format!("lc-browse-gone-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(8, 8, |x, y| [(x * 9) as u8, (y * 12) as u8, 80, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let png = lightcraft_engine::export::encode_image(&img, &o).unwrap(); + std::fs::write(dir.join("a.png"), &png).unwrap(); + std::fs::write(dir.join("b.png"), &png).unwrap(); + let path = dir.to_string_lossy().to_string(); + let browse = |h: &mut Headless| { + let r = h.request("engine.execute", json!({"command": "library.browse", "params": {"path": path}}), t); + assert_eq!(r["result"]["scanning"], true, "{r}"); + h.settle(SETTLE); + }; + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 2); + // another app deletes the photo that isn't selected (a selected or open one stays, shown as missing) + let held: Vec<lightcraft_catalog::PhotoId> = + h.app.session.selection.ids.iter().copied().chain(h.app.session.selection.active).chain(h.app.session.previous_active).collect(); + let name = |id: &lightcraft_catalog::PhotoId| h.app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default(); + let gone = if held.iter().any(|id| name(id) == "b.png") { "a.png" } else { "b.png" }; + std::fs::remove_file(dir.join(gone)).unwrap(); + // a later session: the browse's "Add 2 Photos" step is no longer in the undo history (photos it refers to + // are kept, like selected ones) + h.app.session.undo.clear(); + h.app.session.redo.clear(); + // the folder clicked again in the sidebar + browse(&mut h); + assert_eq!(h.app.session.visible_cloned().len(), 1, "the deleted file's record is forgotten"); + let _ = std::fs::remove_dir_all(&dir); + } + #[test] fn add_folder_scans_in_the_background() { let mut h = demo([1200.0, 900.0]); @@ -4010,6 +4309,40 @@ mod tests { h.settle(SETTLE); } + /// #911 (toast labels only): cycling the overlay announces each mode in the UI language. + #[test] + fn info_overlay_toasts_are_painted_in_the_users_language() { + use crate::i18n::Locale; + + struct RestoreLanguage(Locale); + impl Drop for RestoreLanguage { + fn drop(&mut self) { + crate::i18n::set_language(self.0); + } + } + let _restore = RestoreLanguage(crate::i18n::language()); + let mut h = demo([1000.0, 700.0]); + let t = Duration::from_secs(10); + assert_eq!(h.request("ui.set", json!({"view": "detail", "infoOverlay": "off"}), t)["ok"], true); + for (command, locale) in [("app.language.german", Locale::De), ("app.language.french", Locale::Fr), ("app.language.english", Locale::En)] { + assert_eq!(h.request("engine.execute", json!({"command": command}), t)["ok"], true); + assert_eq!(h.app.ui.language, locale); + assert_eq!(crate::i18n::language(), locale); + for (mode, key) in [("basic", "Info Overlay: File"), ("exposure", "Info Overlay: Exposure"), ("off", "Info Overlay Off")] { + let expected = locale.tr(key); + if locale != Locale::En { + assert_ne!(expected, key, "this locale must translate the tested message"); + } + assert_eq!(h.request("ui.key", json!({"key": "i", "cmd": true}), t)["ok"], true); + assert_eq!(serde_json::to_value(h.app.ui.info_overlay).unwrap(), json!(mode)); + assert_eq!(h.app.ui.toast.as_ref().expect("overlay toast").0, expected); + h.step(); + let painted = h.painted_text(); + assert!(painted.iter().any(|text| text == expected), "{painted:?}"); + } + } + } + /// A click on the image zooms to the chosen click-zoom ratio (1:1 by default), animating the /// loupe while rendering once at the final size; Z uses the same ratio; a second click returns to Fit. #[test] diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 109accfa9..ed7ad602e 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -126,6 +126,10 @@ language_table! { Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Fr, "fr", "Français", "Latn", include_str!("../locales/fr.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Pl, "pl", "Polski", "Latn", include_str!("../locales/pl.json"); + Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); + Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); } // The settings file stores the BCP-47 code (`"zh-hans"`), never the Rust variant name, so a @@ -212,6 +216,33 @@ pub fn startup_failed_message(error: &str, log_file: Option<&str>) -> (String, S (tr("LightCraft could not start").to_string(), text) } +/// The title and text of the message box shown when LightCraft stopped while its window was up +/// (issue #1337: the window's renderer after the graphics driver reset the GPU), in the UI +/// language: what happened and, when there is one, the log file to look in. +pub fn stopped_message(error: &str, log_file: Option<&str>) -> (String, String) { + let text = match log_file { + Some(path) => tr_format!("LightCraft stopped unexpectedly: {e}\n\nThe log file has the details: {path}", e = error, path = path), + None => tr_format!("LightCraft stopped unexpectedly: {e}", e = error), + }; + (tr("LightCraft stopped").to_string(), text) +} + +/// Whether sentence case may upper-case `c`. Georgian has no case: upper-casing Mkhedruli would +/// turn it into Mtavruli, a different (all-caps) script, so Georgian letters stay as written. +pub fn capitalizes(c: char) -> bool { + !matches!(c, '\u{10A0}'..='\u{10FF}' | '\u{1C90}'..='\u{1CBF}' | '\u{2D00}'..='\u{2D2F}') +} + +/// `text` with its first letter upper-cased ("its input" → "Its input"), unless [`capitalizes`] +/// says that letter has no capital to take. +pub fn upper_first(text: &str) -> String { + let mut chars = text.chars(); + match chars.next() { + Some(first) if capitalizes(first) => first.to_uppercase().chain(chars).collect(), + _ => text.to_string(), + } +} + pub fn builtin_label(source: &str, builtin: bool) -> &str { if builtin { tr(source) } else { source } } @@ -432,6 +463,86 @@ pub fn rules_label(rules: &lightcraft_catalog::RuleSet, catalog: &lightcraft_cat mod tests { use super::*; + #[test] + fn czech_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::Cs.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("cs lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + labels.extend(lightcraft_catalog::rules::FIELD_GROUPS.iter().map(|group| group.0)); + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("cs lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn czech_switches_persists_and_formats_without_changing_user_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.czech", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Cs); + assert_eq!(Locale::Cs.name(), "Čeština"); + assert_eq!(Locale::Cs.script(), "Latn"); + assert_eq!(crate::menubar::checked(&app, "app.language.czech"), Some(true)); + assert_eq!(tr("File"), "Soubor"); + assert_eq!(tr("Develop"), "Vyvolat"); + assert_eq!(date_group_label("2026-09-20", false), "Neděle, 20. 9. 2026"); + assert_eq!(date_group_label("2026-09-20", true), "Neděle, 20."); + assert_eq!(date_group_label("2026-09", false), "9. 2026"); + assert_eq!(date_group_label("2026", false), "2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Neděle, 20. 9. 2026 16:04:05"); + for n in [0, 1, 2, 4, 5, 11, 21, 22, 25] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Importováno fotografií: {n}")); + assert_eq!( + tr_format!("{n} {noun}", n = n, noun = tr(if n == 1 { "photo waiting" } else { "photos waiting" })), + format!("Čekající fotografie: {n}") + ); + } + let name = String::from("Color 1234.56 {title} 日本語"); + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Do „{name}“ přidáno fotografií: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::Cs); + let ctx = egui::Context::default(); + for tag in ["cs", "cs-CZ", "cs_CZ.UTF-8", "cs-Latn-CZ"] { + assert_eq!(Locale::parse_tag(tag), Some(Locale::Cs)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": tag})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::Cs); + } + let text = painted_text(&ctx, &mut app, Locale::Cs); + assert!(text.contains("Soubor") && text.contains("Expozice"), "{text}"); + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "áčďéěíňóřšťúůýžÁČĎÉĚÍŇÓŘŠŤÚŮÝŽ„“".chars() { + assert!(fonts.has_glyph(&font, ch), "missing Czech glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + + #[test] + fn sentence_case_keeps_georgian_lowercase() { + assert_eq!(upper_first("its input is not an image"), "Its input is not an image"); + assert_eq!(upper_first("ფაილი ვერ იკითხება"), "ფაილი ვერ იკითხება"); + assert_eq!(upper_first("ᲥᲐᲠᲗᲣᲚᲘ"), "ᲥᲐᲠᲗᲣᲚᲘ"); + assert_eq!(upper_first(""), ""); + assert!(capitalizes('a') && capitalizes('ж') && !capitalizes('ქ') && !capitalizes('Ⴀ') && !capitalizes('ⴀ')); + } + #[test] fn every_language_has_a_catalog_covering_the_source() { assert!(crate::i18n::Locale::En.catalog().is_empty(), "the source language needs no catalog"); @@ -597,7 +708,7 @@ mod tests { lightcraft_engine::CRAFT_FONTS.iter().filter(|font| font.serves(language.script())).map(|font| font.family).collect(); faces.dedup(); if faces.is_empty() { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } ctx.fonts_mut(|fonts| { @@ -638,6 +749,12 @@ mod tests { }); } + /// Symbols a translation must not carry, because the craft-fonts faces of at least one shipped + /// script have no glyph for them and egui's bundled fonts do not either: the menu triangles, + /// and the heavy multiplication X (U+2715) that Noto Sans Georgian lacks — `×` (U+00D7) reads + /// the same and every face has it. + const UNPAINTABLE: [char; 3] = ['▸', '▾', '✕']; + #[test] fn translated_catalogs_do_not_use_missing_menu_triangles() { let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); @@ -648,7 +765,9 @@ mod tests { } let messages: BTreeMap<String, String> = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap(); for (source, text) in messages { - assert!(!text.contains(['▸', '▾']), "{}: {source:?} uses an unpaintable menu triangle", path.display()); + for bad in UNPAINTABLE { + assert!(!text.contains(bad), "{}: {source:?} uses the unpaintable {bad:?}", path.display()); + } } } } @@ -667,6 +786,10 @@ mod tests { ("app.language.german", Locale::De), ("app.language.russian", Locale::Ru), ("app.language.ukrainian", Locale::Uk), + ("app.language.polish", Locale::Pl), + ("app.language.czech", Locale::Cs), + ("app.language.italian", Locale::It), + ("app.language.georgian", Locale::Ka), ]; // One command per language, and every command reachable from the menu table. assert_eq!(commands.len(), Locale::ALL.len()); @@ -893,6 +1016,31 @@ mod tests { set_language(Locale::En); } + #[test] + fn georgian_switches_formats_counts_and_dates() { + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.georgian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::Ka); + assert_eq!(crate::menubar::checked(&app, "app.language.georgian"), Some(true)); + assert_eq!(tr("Light"), "სინათლე"); + assert_eq!(date_group_label("2026-09-20", false), "კვირა, 20.09.2026"); + assert_eq!(date_group_label("2026-09", false), "09.2026"); + for n in [0, 1, 2, 5, 21] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("იმპორტირებულია ფოტო: {n}")); + } + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", "Trip", n = 3), "„Trip“-ს დაემატა ფოტო: 3"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"ka""#)); + for code in ["ka", "ka-GE", "ka_GE.UTF-8"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::Ka)); + } + // no Mtavruli: Georgian UI text is written in lowercase Mkhedruli + for text in Locale::Ka.catalog().values() { + assert!(!text.chars().any(|c| ('\u{1C90}'..='\u{1CBF}').contains(&c)), "{text}"); + } + set_language(Locale::En); + } + #[test] fn ukrainian_panels_dialogs_and_letters_paint_with_bundled_fonts() { let ctx = fonts_ctx(&[]); @@ -921,6 +1069,109 @@ mod tests { set_language(Locale::En); } + /// Italian's coverage of every catalog message and display label, reported like Ukrainian's. + #[test] + fn italian_coverage_of_catalogs_commands_controls_and_rules_is_reported() { + let catalog = Locale::It.catalog(); + let keys: std::collections::BTreeSet<&String> = Locale::ALL.iter().flat_map(|language| language.catalog().keys()).collect(); + let lacking: Vec<_> = keys.into_iter().filter(|key| !catalog.contains_key(key.as_str())).collect(); + if !lacking.is_empty() { + eprintln!("it lacks {} catalog message(s) (shown in English): {lacking:?}", lacking.len()); + } + let mut labels: Vec<&str> = lightcraft_engine::command_specs().iter().map(|spec| spec.label).collect(); + labels.extend(crate::menus::ui_commands().map(|command| command.1).filter(|label| !Locale::ALL.iter().any(|locale| locale.name() == *label))); + labels.extend(lightcraft_develop::CONTROLS.iter().map(|control| control.label)); + labels.extend(crate::panels::settings::TABS.iter().map(|(_, label)| *label)); + for (_, label, kind) in lightcraft_catalog::rules::FIELDS { + labels.push(label); + labels.extend(lightcraft_catalog::rules::ops_for(*kind).iter().map(|(_, label)| *label)); + } + labels.extend(lightcraft_engine::rename::TOKENS.iter().map(|token| token.meaning)); + labels.extend(lightcraft_engine::rename::TEMPLATE_NOTES); + let missing: Vec<_> = labels.into_iter().filter(|label| !catalog.contains_key(*label)).collect(); + if !missing.is_empty() { + eprintln!("it lacks {} display label(s) (shown in English): {missing:?}", missing.len()); + } + } + + #[test] + fn italian_switches_persists_formats_dates_and_preserves_values() { + use crate::control::{ControlRequest, Outcome}; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.run("app.language.italian", serde_json::json!({})).unwrap(); + assert_eq!(app.ui.language, Locale::It); + assert_eq!(crate::menubar::checked(&app, "app.language.italian"), Some(true)); + assert_eq!(tr("Light"), "Luce"); + assert_eq!(tr("Landscape"), "Paesaggio"); + assert_eq!(tr("Home"), "Cartella personale"); + assert_eq!(date_group_label("2026-09-20", false), "Domenica 20/09/2026"); + assert_eq!(date_group_label("2026-09", false), "09/2026"); + assert_eq!(display_time("2026-09-20T16:04:05"), "Domenica 20/09/2026 16:04:05"); + // Italian agrees adjectives with the count ("1 foto importata", "2 foto importate"), so the + // count follows a label instead, which reads right for every number; the English plural + // suffix is consumed and never shows. + for n in [0, 1, 2, 5, 11, 21, 22, 25, 111] { + assert_eq!(tr_format!("Imported {} photo{}", n, if n == 1 { "" } else { "s" }), format!("Foto importate: {n}")); + assert_eq!(tr_format!("{} files and {f} folder{}", n, "s", f = n), format!("{n} file, cartelle: {n}")); + let noun = tr(if n == 1 { "cached picture" } else { "cached pictures" }); + let expected = if n == 1 { "1 immagine in cache".to_string() } else { format!("{n} immagini in cache") }; + assert_eq!(tr_format!("{n} {noun}", n = n, noun = noun), expected); + } + // This trailing argument is queue progress, not an English plural suffix. + let queued = tr_format!(" · {queued} waiting", queued = 5); + assert_eq!( + tr_format!("Denoising {name}: {done} of {total} tiles{}", queued, name = "IMG.CR3", done = 1, total = 4), + "Riduzione disturbo di IMG.CR3: 1 di 4 riquadri · in attesa: 5" + ); + let name = "Color {title} 日本語"; + assert_eq!(tr_format!("Added {n} photo{} to “{}”", "s", name, n = 22), format!("Foto aggiunte a “{name}”: 22")); + assert_eq!(builtin_label("Warm Glow", false), "Warm Glow"); + assert_eq!(builtin_label("Warm Glow", true), "Bagliore caldo"); + assert_eq!(tr("photo-{title}.jpg"), "photo-{title}.jpg"); + assert_eq!(tr("develop.set"), "develop.set"); + let saved = serde_json::to_string(&app.ui).unwrap(); + assert!(saved.contains(r#""language":"it""#)); + assert_eq!(serde_json::from_str::<crate::state::UiState>(&saved).unwrap().language, Locale::It); + let ctx = egui::Context::default(); + for code in ["it", "it-IT", "it_IT.UTF-8", "it-Latn-IT", "it-CH"] { + assert_eq!(Locale::parse_tag(code), Some(Locale::It)); + let (request, _) = ControlRequest::new("ui.set", serde_json::json!({"language": code})); + let Outcome::Done(reply) = crate::control::handle(&mut app, &ctx, &request) else { panic!("expected reply") }; + assert_eq!(reply["ok"], true); + assert_eq!(language(), Locale::It); + } + set_language(Locale::En); + } + + #[test] + fn italian_panels_dialogs_and_letters_paint_with_bundled_fonts() { + let ctx = fonts_ctx(&[]); + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), Default::default()); + app.ui.left_panel = true; + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains("Le mie foto") && text.contains("Tutte le foto"), "{text}"); + for (command, title) in + [("app.about", "Informazioni su LightCraft"), ("app.shortcuts", "Scorciatoie da tastiera"), ("app.settings", "Impostazioni")] + { + app.ui.dialog = None; + app.run(command, serde_json::json!({})).unwrap(); + let text = painted_text(&ctx, &mut app, Locale::It); + assert!(text.contains(title), "{command}: {text}"); + } + ctx.fonts_mut(|fonts| { + for family in [egui::FontFamily::Proportional, egui::FontFamily::Name(crate::theme::FONT_SEMIBOLD.into())] { + let font = egui::FontId::new(13.0, family); + for ch in "àèéìòùÀÈÉÌÒÙ’“”«»" + .chars() + .chain(Locale::It.catalog().values().flat_map(|text| text.chars()).filter(|ch| ('\u{00c0}'..='\u{017f}').contains(ch))) + { + assert!(fonts.has_glyph(&font, ch), "missing Italian glyph {ch}"); + } + } + }); + set_language(Locale::En); + } + /// Every command, control, rule and rename label, and every line of What's New, is a message a /// catalog can translate. What a language lacks is reported, not failed (like /// `catalogs_agree_on_placeholders_and_report_gaps`): a feature PR doesn't have to ship every @@ -1062,7 +1313,7 @@ mod tests { } #[test] - fn german_and_ukrainian_buttons_fit_the_bottom_bar_and_export_dialog() { + fn german_ukrainian_and_italian_buttons_fit_the_bottom_bar_and_export_dialog() { fn collect(shape: &egui::epaint::Shape, bounds: &mut Vec<(String, egui::Rect)>) { match shape { egui::epaint::Shape::Text(shape) => { @@ -1072,7 +1323,7 @@ mod tests { _ => {} } } - for language in [Locale::De, Locale::Uk] { + for language in [Locale::De, Locale::Uk, Locale::It] { let ctx = fonts_ctx(&[]); let services = crate::Services { pick_folder: Some(Box::new(|| None)), ..Default::default() }; let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); @@ -1200,7 +1451,7 @@ mod tests { let samples = [(Locale::Ja, "日本語の文字"), (Locale::ZhHans, "简体中文字"), (Locale::ZhHant, "繁體中文字")]; for (language, sample) in samples { if !lightcraft_engine::CRAFT_FONTS.iter().any(|font| font.serves(language.script())) { - eprintln!("skipped {}: built without a craft-fonts face for {}", language.code(), language.script()); + lightcraft_testkit::skip!(Fonts, "{}: built without a craft-fonts face for {}", language.code(), language.script()); continue; } set_language(language); diff --git a/crates/ui-egui/src/icons.rs b/crates/ui-egui/src/icons.rs index b82194bbb..15434237a 100644 --- a/crates/ui-egui/src/icons.rs +++ b/crates/ui-egui/src/icons.rs @@ -44,7 +44,9 @@ pub enum Icon { ChevronDown, ChevronRight, ChevronLeft, - TriangleLeft, + ChevronUp, + ChevronDoubleLeft, + ChevronDoubleRight, Plus, Minus, Album, @@ -83,6 +85,10 @@ pub enum Icon { Chat, /// Face boxes on/off: four corner brackets round a small face. FaceBox, + /// Play (a triangle pointing right). + Play, + /// Pause (two bars). + Pause, /// Colour grading, all three wheels: three small circles. GradeAll, /// Colour grading, shadows wheel: a filled circle. @@ -175,6 +181,22 @@ pub fn paint_turned(p: &Painter, rect: Rect, icon: Icon, color: Color32, radians fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option<(f32, f32)>) { let side = rect.width().min(rect.height()); + // All disclosure and navigation chevrons share a 16-point size and stroke. + let side = if matches!( + icon, + Icon::Back + | Icon::Forward + | Icon::ChevronDown + | Icon::ChevronUp + | Icon::ChevronLeft + | Icon::ChevronRight + | Icon::ChevronDoubleLeft + | Icon::ChevronDoubleRight + ) { + side.min(16.0) + } else { + side + }; let r = Rect::from_center_size(rect.center(), vec2(side, side)); let k = side / 20.0; let pen = Pen { p, r, s: Stroke::new((1.5 * k).max(1.0), color), k, turn }; @@ -184,14 +206,6 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect(3.0, 4.0, 17.0, 16.0, 1.5); pen.line(&[(8.0, 4.0), (8.0, 16.0)]); } - Back => { - pen.line(&[(16.0, 10.0), (4.0, 10.0)]); - pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); - } - Forward => { - pen.line(&[(4.0, 10.0), (16.0, 10.0)]); - pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); - } Monitor => { pen.rect(2.5, 3.5, 17.5, 13.5, 1.5); pen.line(&[(10.0, 13.5), (10.0, 16.5)]); @@ -399,9 +413,17 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.rect_fill(10.0, 4.0, 17.0, 16.0, 0.5); } ChevronDown => pen.line(&[(5.0, 7.5), (10.0, 12.5), (15.0, 7.5)]), - ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), - ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), - TriangleLeft => pen.fill(&[(13.0, 6.0), (13.0, 14.0), (7.0, 10.0)], color), + Forward | ChevronRight => pen.line(&[(7.5, 5.0), (12.5, 10.0), (7.5, 15.0)]), + Back | ChevronLeft => pen.line(&[(12.5, 5.0), (7.5, 10.0), (12.5, 15.0)]), + ChevronDoubleLeft => { + pen.line(&[(9.0, 5.0), (4.0, 10.0), (9.0, 15.0)]); + pen.line(&[(16.0, 5.0), (11.0, 10.0), (16.0, 15.0)]); + } + ChevronDoubleRight => { + pen.line(&[(4.0, 5.0), (9.0, 10.0), (4.0, 15.0)]); + pen.line(&[(11.0, 5.0), (16.0, 10.0), (11.0, 15.0)]); + } + ChevronUp => pen.line(&[(5.0, 12.5), (10.0, 7.5), (15.0, 12.5)]), Plus => { pen.line(&[(10.0, 4.0), (10.0, 16.0)]); pen.line(&[(4.0, 10.0), (16.0, 10.0)]); @@ -497,6 +519,11 @@ fn paint_with(p: &Painter, rect: Rect, icon: Icon, color: Color32, turn: Option< pen.arc(10.0, 18.0, 6.5, 200.0, 340.0); pen.rect(2.5, 2.5, 17.5, 17.5, 2.0); } + Play => pen.fill(&[(6.0, 4.0), (6.0, 16.0), (16.0, 10.0)], color), + Pause => { + pen.rect_fill(5.0, 4.0, 8.5, 16.0, 0.5); + pen.rect_fill(11.5, 4.0, 15.0, 16.0, 0.5); + } FaceBox => { pen.line(&[(2.5, 7.0), (2.5, 2.5), (7.0, 2.5)]); pen.line(&[(13.0, 2.5), (17.5, 2.5), (17.5, 7.0)]); diff --git a/crates/ui-egui/src/import.rs b/crates/ui-egui/src/import.rs index a1a720a8e..e851463d1 100644 --- a/crates/ui-egui/src/import.rs +++ b/crates/ui-egui/src/import.rs @@ -444,6 +444,28 @@ pub(crate) fn busy_synchronizing(app: &LightcraftApp) -> Result<(), String> { if app.sync_run.is_some() { Err(crate::i18n::tr("A folder is being synchronized").to_string()) } else { Ok(()) } } +/// Settings → Import → "Show the import review when a camera or card is connected", called with +/// each device scan while it's on. The first look only notes the cards already in. A card that +/// shows up after that opens Import from Device once nothing else is open, and a card that is +/// taken out and put back in asks again. +#[cfg(not(target_arch = "wasm32"))] +pub(crate) fn cards_seen(app: &mut LightcraftApp, now: Vec<lightcraft_engine::devices::Device>) { + let Some(known) = app.ui.known_cards.as_mut() else { + app.ui.known_cards = Some(now.into_iter().map(|d| d.root).collect()); + return; + }; + known.retain(|root| now.iter().any(|d| d.root == *root)); + let Some(card) = now.into_iter().find(|d| !known.contains(&d.root)) else { return }; + // busy: ask when the window is free (the card isn't noted until then) + if app.import.is_some() || app.scan.is_some() || app.sync_run.is_some() || app.lightroom.is_some() || app.ui.dialog.is_some() { + return; + } + known.push(card.root); + if let Err(e) = app.run("file.addFromDevice", json!({"path": card.path})) { + log::warn!("import from {}: {e}", card.name); + } +} + /// Scan `paths` in the background, then open the review dialog (see [`poll_scan`]). pub fn open(app: &mut LightcraftApp, paths: Vec<String>) -> Result<Value, String> { if app.scan.is_some() { @@ -484,6 +506,7 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> if running && app.session.browse.as_ref().is_some_and(|b| b.path == dir_s && b.subfolders == subfolders) { // already reading this folder: clicking it again must not restart the progress app.session.source = lightcraft_engine::LibrarySource::Folder; + app.session.keep_active_in_view(); return Ok(json!({"path": dir_s, "subfolders": subfolders, "scanning": true})); } if let Some(t) = app.scan.take() { @@ -492,6 +515,8 @@ pub fn browse(app: &mut LightcraftApp, path: &str, subfolders: Option<bool>) -> app.import = None; app.session.browse = Some(lightcraft_engine::Browse { path: dir_s.clone(), subfolders }); app.session.source = lightcraft_engine::LibrarySource::Folder; + // the photos the library already knows from this folder show at once: keep or pick the active one + app.session.keep_active_in_view(); let (input, _) = ScanInput::new(&mut app.session, std::slice::from_ref(&dir_s)); let progress = std::sync::Arc::new(ScanProgress::default()); let (tx, rx) = std::sync::mpsc::channel(); @@ -559,6 +584,12 @@ pub fn poll_scan(app: &mut LightcraftApp, ctx: &egui::Context) { } app.session.import_probes = out.probes; if task.browse { + // records of files gone from the folder (deleted or moved by another app) that nothing was changed on go, + // as `library.browse` forgets them (issue #1392) + if let Some(b) = app.session.browse.clone() { + let listed: Vec<String> = out.candidates.iter().map(|c| c.path.clone()).collect(); + app.session.forget_gone_from(&b.path, b.subfolders, &listed); + } // what the library doesn't know yet joins the Local view let queue: Vec<String> = out.candidates.iter().filter(|c| c.duplicate != Some("path".into()) && c.error.is_none()).map(|c| c.path.clone()).collect(); @@ -703,6 +734,10 @@ pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { if let Some(sel) = &task.keep_selection { app.session.selection = sel.clone(); } + // a Local folder still on screen: its first photo becomes active once there is one (#1447) + if task.browse && app.session.source == lightcraft_engine::LibrarySource::Folder { + app.session.keep_active_in_view(); + } } task.left = Some((app.session.selection.clone(), app.session.source)); if let Some(run) = &task.run { @@ -1102,7 +1137,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { ]; let key = if d.organize.is_empty() { "date".to_string() } else { d.organize.clone() }; let cur = opts.iter().find(|o| o.0 == key).map_or(opts[0].1, |o| o.1); - let combo = egui::ComboBox::from_id_salt("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let combo = crate::widgets::combo_box("import-organize").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (k, label) in opts { let r = ui.selectable_label(key == k, crate::i18n::tr(label)); register(ui.ctx(), format!("button:importOrganize-{k}"), r.rect); @@ -1185,7 +1220,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { _ => "Leave them", }; let mut pick = None; - egui::ComboBox::from_id_salt("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + crate::widgets::combo_box("import-on-deleted").selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (v, label) in [("restore", "Restore them"), ("fresh", "Import as new"), ("", "Leave them")] { if ui.selectable_label(d.on_deleted == v, crate::i18n::tr(label)).clicked() { pick = Some(v); @@ -1215,7 +1250,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { None if !d.new_album.is_empty() => crate::i18n::tr("New album").into(), None => crate::i18n::tr("None").into(), }; - egui::ComboBox::from_id_salt("import-album").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-album").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.album.is_none() && d.new_album.is_empty(), crate::i18n::tr("None")).clicked() { d.album = None; d.new_album.clear(); @@ -1246,7 +1281,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { .find(|p| p.id == d.preset) .map(|p| crate::i18n::builtin_label(&p.name, p.builtin).to_string()) .unwrap_or_else(|| crate::i18n::tr("None").into()); - egui::ComboBox::from_id_salt("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { + crate::widgets::combo_box("import-preset").selected_text(cur).height(300.0).show_ui(ui, |ui| { if ui.selectable_label(d.preset.is_empty(), crate::i18n::tr("None")).clicked() { d.preset.clear(); } @@ -1259,7 +1294,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDialog) { if !app.session.metadata_presets.is_empty() { field(ui, "Metadata", |ui| { let cur = if d.metadata_preset.is_empty() { crate::i18n::tr("None").to_string() } else { d.metadata_preset.clone() }; - egui::ComboBox::from_id_salt("import-metadata").selected_text(cur).show_ui(ui, |ui| { + crate::widgets::combo_box("import-metadata").selected_text(cur).show_ui(ui, |ui| { if ui.selectable_label(d.metadata_preset.is_empty(), crate::i18n::tr("None")).clicked() { d.metadata_preset.clear(); } @@ -1283,6 +1318,7 @@ fn candidate_cell(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &mut ImportDial let ok = d.importable(i); let img = Rect::from_min_size(rect.min, vec2(rect.width(), rect.width())); let resp = ui.interact(img, egui::Id::new(("import-cell", i)), Sense::click()); + crate::widgets::announce(&resp, c.name.clone()); register(ui.ctx(), format!("import:{i}"), img); let p = ui.painter(); p.rect_filled(img, 3.0, Color32::from_gray(30)); @@ -1425,7 +1461,7 @@ pub(crate) fn tag_help(ui: &mut egui::Ui, key: &str, text: &mut String, edit_id: for (i, tok) in TOKENS.iter().enumerate() { let r = ui.add(egui::Button::new(egui::RichText::new(tok.tag).monospace().color(t.text)).small()).on_hover_text( if tok.aliases.is_empty() { - "Insert at the cursor".to_string() + crate::i18n::tr("Insert at the cursor").to_string() } else { crate::i18n::tr_format!("Insert at the cursor (also written {})", tok.aliases.join(", ")) }, diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 37fb86df3..bb290bd86 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -8,12 +8,15 @@ pub mod album_picker; pub mod brush_live; +pub mod button; +pub mod canvas_keys; mod command_error; pub mod control; pub mod credits; pub mod date_picker; pub mod docking; pub mod export_task; +mod focus_scan; pub mod headless; pub mod i18n; pub mod icons; @@ -27,10 +30,13 @@ pub mod merge; mod model_setup; pub mod panels; pub mod pick; +pub mod print_view; +pub mod recheck; pub mod region; pub mod render; pub mod shortcuts; pub mod sidebar; +pub mod slideshow_export; pub mod softpaint; pub mod state; pub mod sync; @@ -39,16 +45,25 @@ pub mod text_field; pub mod theme; pub mod titlebar; pub mod widgets; +pub mod window_place; #[cfg(test)] mod tests_activity; #[cfg(test)] mod tests_album_picker; #[cfg(test)] +mod tests_album_remove; +#[cfg(test)] mod tests_appearance; #[cfg(test)] mod tests_before_after; #[cfg(test)] +mod tests_browse_active; +#[cfg(test)] +mod tests_button; +#[cfg(test)] +mod tests_canvas_keys; +#[cfg(test)] mod tests_compare; #[cfg(test)] mod tests_copy_paste_dialogs; @@ -63,12 +78,22 @@ mod tests_date_picker; #[cfg(test)] mod tests_dialog_fit; #[cfg(test)] +mod tests_dialog_primary; +#[cfg(test)] mod tests_dialogs; #[cfg(test)] +mod tests_edit_layout; +#[cfg(test)] +mod tests_empty_library; +#[cfg(test)] mod tests_error_kinds; #[cfg(test)] mod tests_filmstrip; #[cfg(test)] +mod tests_focus_point; +#[cfg(test)] +mod tests_folder_drop; +#[cfg(test)] mod tests_grading; #[cfg(test)] mod tests_grid; @@ -89,10 +114,14 @@ mod tests_keywording; #[cfg(test)] mod tests_labels; #[cfg(test)] +mod tests_layout_image; +#[cfg(test)] mod tests_library_problem; #[cfg(test)] mod tests_lights_out; #[cfg(test)] +mod tests_mask_effects; +#[cfg(test)] mod tests_masking; #[cfg(test)] mod tests_masking_layout; @@ -109,22 +138,42 @@ mod tests_preset_import; #[cfg(test)] mod tests_preview_limit; #[cfg(test)] +mod tests_print; +#[cfg(test)] +mod tests_profiles_layout; +#[cfg(test)] mod tests_quit_unsaved; #[cfg(test)] +mod tests_recheck; +#[cfg(test)] +mod tests_rename_person; +#[cfg(test)] mod tests_sam_setup; #[cfg(test)] mod tests_scroll; #[cfg(test)] +mod tests_slider_reset; +#[cfg(test)] +mod tests_slideshow; +#[cfg(test)] +mod tests_solo_sections; +#[cfg(test)] mod tests_switch_library; #[cfg(test)] mod tests_sync; #[cfg(test)] +mod tests_tallies; +#[cfg(test)] mod tests_temp_slider; #[cfg(test)] mod tests_titlebar; #[cfg(test)] +mod tests_tool_overlay; +#[cfg(test)] mod tests_unsaved; #[cfg(test)] +mod tests_view_cost; +#[cfg(test)] mod tests_wb_selector; #[cfg(test)] mod tests_zoom; @@ -211,6 +260,10 @@ pub struct Services { pub pick_curve_preset_files: Option<PickFiles>, /// Save dialog for an exported `.lccurve` file. pub save_curve_preset_file: Option<SaveFile>, + /// Open dialog for slideshow template files (`.json`; Settings ▸ Slideshow ▸ Templates ▸ Import…). + pub pick_slideshow_templates: Option<PickFiles>, + /// Save dialog for an exported slideshow template (`.json`). + pub save_slideshow_template: Option<SaveFile>, pub write: Option<WriteFn>, /// Thread-safe writer: with it, UI-started exports run in the background (desktop only). pub write_shared: Option<SharedWrite>, @@ -259,6 +312,9 @@ pub struct Perf { pub update_ms: f64, /// The slowest whole update since start. pub max_update_ms: f64, + /// The previous frame's whole time on this thread, painting (texture uploads) included, as + /// the host reports it (eframe's `cpu_usage`); `None` without a host report (headless). + pub host_frame_ms: Option<f64>, pub fps: f64, } @@ -357,6 +413,8 @@ pub struct LightcraftApp { pub region_before_view: Option<region::RegionView>, /// The loupe's render sizes while a pinch or two-finger scroll runs. pub(crate) size_hold: region::SizeHold, + /// The size of a slider drag's whole-frame drafts (full size where they keep up, issue #330). + pub(crate) drag_scale: region::DragScale, /// (photo, look, window frame size) a window was refused for: it reads more than one render holds. pub(crate) window_refused: Option<(lightcraft_catalog::PhotoId, u64, usize)>, pub(crate) region_tiles: std::collections::HashMap<(bool, u64), region::RegionView>, @@ -383,6 +441,8 @@ pub struct LightcraftApp { pub lightroom: Option<lightroom_import::LightroomTask>, /// Last terminal Lightroom result, exposed by the command's status/wait response. pub lightroom_last: Option<Value>, + /// A completed import result waiting for another modal dialog to close. + pub(crate) lightroom_result: Option<state::Dialog>, /// The library generation the window title and the recent list were last updated for. pub shown_library: Option<u64>, /// A background export in progress. @@ -391,6 +451,8 @@ pub struct LightcraftApp { pub tasks: tasks::Tasks, /// The files of the last finished background export (`ui.inspect` → `export.last`). pub last_export_result: Option<Value>, + /// The last slideshow export's end has been told (a toast). + pub slideshow_export_reported: bool, /// The look the loupe shows while the pointer rests on a preset or profile (set by the /// panels each frame; nothing is committed, no history entry). pub hover_preview: Option<HoverPreview>, @@ -463,6 +525,7 @@ impl LightcraftApp { region_view: None, region_before_view: None, size_hold: Default::default(), + drag_scale: Default::default(), window_refused: None, region_tiles: Default::default(), compare_windows: [None; 2], @@ -476,10 +539,12 @@ impl LightcraftApp { sync_run: None, lightroom: None, lightroom_last: None, + lightroom_result: None, shown_library: None, export: None, tasks: Default::default(), last_export_result: None, + slideshow_export_reported: true, hover_preview: None, window_is_fullscreen: false, gpu_applied: None, @@ -510,6 +575,22 @@ impl LightcraftApp { self } + /// The open person page, the way back to it, and the photo it was opened from follow a rename. + fn follow_renamed_person(&mut self, from: &str, to: &str) { + let same = |n: &str| n.trim().to_lowercase() == from.trim().to_lowercase(); + if self.ui.person_page.as_deref().is_some_and(same) { + self.ui.person_page = Some(to.to_string()); + } + if self.ui.last_person.as_deref().is_some_and(same) { + self.ui.last_person = Some(to.to_string()); + } + if let Some((Some(name), photo)) = self.ui.person_from.clone() + && same(&name) + { + self.ui.person_from = Some((Some(to.to_string()), photo)); + } + } + /// Run a UI or engine command by id. The single entry point for every frontend path. pub fn run(&mut self, id: &str, params: Value) -> Result<Value, String> { self.run_typed(id, params).map_err(|e| e.message) @@ -596,6 +677,13 @@ impl LightcraftApp { } } Ok(v) if id == "faces.detect" => self.ui.status = detect_summary(v), + Ok(v) if id == "faces.renamePerson" => { + let to = v["to"].as_str().unwrap_or("").to_string(); + let from = params.get("from").and_then(Value::as_str).unwrap_or(""); + if !to.is_empty() { + self.follow_renamed_person(from, &to); + } + } Ok(_) => {} } r @@ -642,28 +730,9 @@ impl LightcraftApp { } /// Show a transient toast at the bottom of the canvas (like the reference app's HUD). - /// Advance a running slideshow (wrapping around at the end). + /// Advance a running slideshow. fn slideshow_tick(&mut self, ctx: &egui::Context) { - let Some((interval, due, paused)) = self.ui.slideshow else { return }; - if !self.ui.fullscreen { - self.ui.slideshow = None; - return; - } - if paused { - return; - } - let now = ctx.input(|i| i.time); - if now >= due { - let vis = self.session.visible_cloned(); - if let Some(cur) = self.session.active() - && !vis.is_empty() - { - let i = vis.iter().position(|x| *x == cur).map_or(0, |i| (i + 1) % vis.len()); - let _ = self.run("library.select", serde_json::json!({"ids": [vis[i].0]})); - } - self.ui.slideshow = Some((interval, now + interval, false)); - } - ctx.request_repaint_after(std::time::Duration::from_secs_f64((due - now).clamp(0.05, interval))); + panels::slideshow::tick(self, ctx); } /// Announce the end of a Build Previews run. @@ -715,7 +784,7 @@ impl LightcraftApp { } (None, true) => { self.ui.unsaved_seen = false; - self.toast(ctx, "Library saved"); + self.toast(ctx, crate::i18n::tr("Library saved")); } (Some(_), true) => ctx.request_repaint_after(std::time::Duration::from_secs(2)), // retry (None, false) => {} @@ -1021,13 +1090,17 @@ impl LightcraftApp { if ctx.input(|i| i.viewport().close_requested()) && !panels::notices::may_close(self) { ctx.send_viewport_cmd(egui::ViewportCommand::CancelClose); } - let now = ctx.input(|i| i.time); - let dt = now - self.last_time; - if dt > 0.0 { - self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + // (once a frame: a pass drawn again after a discard has no input of its own, and still the + // widgets of the pass thrown away, where a request would find them out of place) + if ctx.current_pass_index() == 0 { + let now = ctx.input(|i| i.time); + let dt = now - self.last_time; + if dt > 0.0 { + self.perf.fps = self.perf.fps * 0.9 + (1.0 / dt).min(240.0) * 0.1; + } + self.last_time = now; + self.drain_control(ctx); } - self.last_time = now; - self.drain_control(ctx); self.apply_settings(ctx); if !self.synthetic.is_empty() { ctx.request_repaint(); @@ -1039,6 +1112,7 @@ impl LightcraftApp { sync::poll_run(self, ctx); import::tick(self, ctx); lightroom_import::tick(self, ctx); + recheck::tick(self); tasks::poll(self, ctx); self.preview_build_status(ctx); self.save_status(ctx); @@ -1051,7 +1125,7 @@ impl LightcraftApp { if let Ok(r) = self.session.execute("photo.reload", &serde_json::json!({"ids": ids})) && r["reloaded"].as_array().is_some_and(|a| !a.is_empty()) { - self.toast(ctx, "Updated the edits saved in the external editor"); + self.toast(ctx, crate::i18n::tr("Updated the edits saved in the external editor")); } } self.ui.was_focused = focused; @@ -1092,10 +1166,48 @@ impl LightcraftApp { } ctx.request_repaint_after(std::time::Duration::from_secs(3)); } + // automatic XMP writes that failed (a network share offline): tried again every few seconds once their files + // are reachable, and said once when they start waiting and once when they're written + #[cfg(not(target_arch = "wasm32"))] + { + if self.session.unsaved_sidecars() > 0 { + let now = ctx.input(|i| i.time); + if now - self.ui.sidecar_retry_at >= 10.0 { + self.ui.sidecar_retry_at = now; + self.session.retry_unsaved_sidecars(); + } + ctx.request_repaint_after(std::time::Duration::from_secs(10)); + } + let waiting = self.session.unsaved_sidecars(); + if waiting > 0 && self.ui.sidecars_waiting == 0 { + self.toast_for( + ctx, + crate::i18n::tr_format!( + "The XMP sidecars of {n} photos couldn't be written (is their folder offline?). LightCraft keeps trying.", + n = waiting + ), + 8.0, + ); + } else if waiting == 0 && self.ui.sidecars_waiting > 0 { + self.toast(ctx, crate::i18n::tr("Wrote the XMP sidecars that were waiting")); + } + self.ui.sidecars_waiting = waiting; + } + // Settings → Import: open the import review when a camera or card is connected + #[cfg(not(target_arch = "wasm32"))] + if self.session.import_defaults.show_on_card { + if let Some(now) = lightcraft_engine::devices::devices_scanned() { + import::cards_seen(self, now); + } + ctx.request_repaint_after(lightcraft_engine::devices::MAX_AGE); + } else { + self.ui.known_cards = None; + } self.session.persist_if_dirty(); panels::denoise::pump(self, ctx); model_setup::pump(self, ctx); panels::faces::pump(self, ctx); + focus_scan::pump(self, ctx); self.collect_screenshots(ctx); self.issue_screenshots(ctx); if self.fonts_ready { @@ -1326,7 +1438,9 @@ impl LightcraftApp { self.place_names_outcome(&ctx); if self.ui.fullscreen { // full-screen preview: the photo alone on black - egui::CentralPanel::default().frame(egui::Frame::NONE.fill(egui::Color32::BLACK)).show(ui, |ui| panels::detail::show(self, ui)); + egui::CentralPanel::default() + .frame(egui::Frame::NONE.fill(egui::Color32::BLACK)) + .show(ui, |ui| if self.ui.slideshow.is_some() { panels::slideshow::show(self, ui) } else { panels::detail::show(self, ui) }); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); @@ -1341,13 +1455,16 @@ impl LightcraftApp { panels::topbar::show(self, ui); panels::library_problem::banner(self, ui); panels::strip::show(self, ui); - egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| docking::show(self, ui)); + // filled: the gaps the dock leaves between its panes show chrome, not the dark clear colour + let chrome = theme::Tokens::get(&ctx).chrome; + egui::CentralPanel::default().frame(egui::Frame::NONE.fill(chrome)).show(ui, |ui| docking::show(self, ui)); self.lights_out(&ctx); panels::second::show(self, &ctx); panels::notices::show(self, &ctx); panels::dialogs::show(self, &ctx); panels::library_problem::show(self, &ctx); export_task::poll(self, &ctx); + slideshow_export::poll(self, &ctx); panels::activity::show(self, &ctx); export_task::poll_photos(self, &ctx); pick::poll(self, &ctx); @@ -1435,7 +1552,7 @@ pub fn is_model_file(path: &str) -> bool { /// Files dropped on the window that are presets rather than photos. pub fn is_preset_file(path: &str) -> bool { let ext = std::path::Path::new(path).extension().map(|e| e.to_string_lossy().to_ascii_lowercase()).unwrap_or_default(); - ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack"].contains(&ext.as_str()) + ["lcpreset", "lrtemplate", "xmp", "zip", "cube", "lmp", "mplumpack", "lceffect"].contains(&ext.as_str()) } /// Splits dropped files into presets and everything else. An `.xmp` dropped next to a photo with the same @@ -1513,20 +1630,35 @@ pub struct Caches { keyword_names: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, /// The Keywording box's chips, by library revision and selection. keyword_chips: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Chip>>)>, + /// How many photos of the library the selection is, by library revision and selection. + selected_photos: Option<(u64, u64, usize)>, + /// The Keywording box's chips grouped by the keyword they are inside, by library revision and + /// selection. + keyword_groups: Option<(u64, u64, std::sync::Arc<Vec<panels::keywording::Group>>)>, /// The Keyword List's tick boxes, by library revision and selection. keyword_ticks: Option<(u64, u64, std::sync::Arc<panels::keyword_list::Ticks>)>, folder_tree: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::FolderNode>>)>, people: Option<(u64, lightcraft_catalog::Filter, std::sync::Arc<Vec<lightcraft_catalog::Person>>)>, suggestions: Option<(u64, std::sync::Arc<Vec<String>>)>, + /// What the Add keyword box offers for the text typed in it, by library revision, the + /// selection's keywords and that text. + completions: Option<(u64, std::sync::Arc<panels::keywording::Offers>)>, counts: Option<(u64, LibraryCounts)>, date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>, filter_values: Option<(u64, std::sync::Arc<FilterValues>)>, - album_counts: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>, - /// How often the album counts were recomputed (tests check that unchanged frames don't). + /// The albums' counts: the minute they are of (while a smart album asks about "the last N days"), the tally + /// that follows edits, and the counts as the panels take them. + album_counts: + Option<(String, lightcraft_catalog::tally::AlbumTally, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>>)>, + /// How often the album counts were made from every photo (tests check that unchanged frames, and edits to a + /// photo or a few, don't). pub album_count_scans: usize, /// What the open smart-album rule dialog shows besides the rules ([`Caches::rules_view`]). rules_view: Option<(u64, std::sync::Arc<RulesView>)>, pub rules_view_scans: usize, + /// A count an open dialog shows ([`Caches::dialog_count`]), by what it counts. + dialog_count: Option<(u64, usize)>, + pub dialog_count_scans: usize, /// The problems of every saved smart album that has some ([`Caches::smart_album_problems`]). smart_problems: Option<(u64, std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, Vec<lightcraft_catalog::rules::Problem>>>)>, pub smart_problem_scans: usize, @@ -1542,6 +1674,8 @@ pub struct Caches { pub faces_indexed: u64, pub faces_pending: u64, pub faces_next_pump: f64, + /// When to ask the focus-point reader again (`focus_scan::pump`). + pub focus_next_pump: f64, /// When the user last dragged, typed or scrolled, and when they last moved the pointer (egui time). pub last_input: f64, /// The pace the face scan was last given, and whether the window was in front then (for `ui.inspect`). @@ -1592,28 +1726,35 @@ pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 { BuildHasherDefault::<DefaultHasher>::default().hash_one(parts) } +/// Whether a tally of the library made when the catalog was at `revision` is still right, given that it reads only +/// `reads` of a photo: nothing changed since, or only other things of some photos did (a rating changes no folder's +/// count, a slider changes none at all). Each of these tallies asks every photo, so one kept is a pass over the +/// library saved on every edit. +fn still_right(cat: &lightcraft_catalog::Catalog, revision: u64, reads: lightcraft_catalog::Aspects) -> bool { + revision == cat.revision || cat.aspects_since(revision).is_some_and(|changed| !changed.any_of(reads)) +} + +/// What the tallies below read of a photo, besides whether it is in the library. +const READS_FILE: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FILE); +const READS_META: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::META); +const READS_PEOPLE: lightcraft_catalog::Aspects = READS_META.with(lightcraft_catalog::Aspects::ANALYSIS); +const READS_CAPTURED: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::CAPTURED); +const READS_FLAG: lightcraft_catalog::Aspects = lightcraft_catalog::Aspects::PRESENCE.with(lightcraft_catalog::Aspects::FLAG); + impl Caches { /// The folders the library's photos were imported from. pub fn folder_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::FolderNode>> { - match &self.folder_tree { - Some((r, t)) if *r == cat.revision => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.folder_tree()); - self.folder_tree = Some((cat.revision, t.clone())); - t - } - } + let kept = self.folder_tree.take().filter(|(r, _)| still_right(cat, *r, READS_FILE)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.folder_tree())); + self.folder_tree = Some((cat.revision, t.clone())); + t } /// The library's keyword tree. pub fn keyword_tree(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::KeywordNode>> { - match &self.keyword_tree { - Some((r, t)) if *r == cat.revision => t.clone(), - _ => { - let t = std::sync::Arc::new(cat.keyword_tree()); - self.keyword_tree = Some((cat.revision, t.clone())); - t - } - } + let kept = self.keyword_tree.take().filter(|(r, _)| still_right(cat, *r, READS_META)).map(|(_, t)| t); + let t = kept.unwrap_or_else(|| std::sync::Arc::new(cat.keyword_tree())); + self.keyword_tree = Some((cat.revision, t.clone())); + t } /// The Keywording box's read-only names for the selection: what exported files carry /// (`export`), or the keywords with those containing them. @@ -1653,6 +1794,36 @@ impl Caches { } } } + /// How many photos the selection is: each once, those the library has (what the Keywording + /// box's "N of M" counts). + pub(crate) fn selected_photos(&mut self, cat: &lightcraft_catalog::Catalog, selection: &[lightcraft_catalog::PhotoId]) -> usize { + let key = key_of(selection); + match self.selected_photos { + Some((r, k, n)) if r == cat.revision && k == key => n, + _ => { + let n = panels::keywording::distinct(cat, selection).len(); + self.selected_photos = Some((cat.revision, key, n)); + n + } + } + } + /// The selection's keywords grouped by the keyword they are inside (the grouped Keywording box). + pub(crate) fn keyword_groups( + &mut self, + cat: &lightcraft_catalog::Catalog, + selection: &[lightcraft_catalog::PhotoId], + ) -> std::sync::Arc<Vec<panels::keywording::Group>> { + let key = key_of(selection); + match &self.keyword_groups { + Some((r, k, g)) if *r == cat.revision && *k == key => g.clone(), + _ => { + let chips = self.keyword_chips(cat, selection); + let g = std::sync::Arc::new(panels::keywording::grouped(&chips)); + self.keyword_groups = Some((cat.revision, key, g.clone())); + g + } + } + } /// How many selected photos have each keyword (the Keyword List's tick boxes). pub(crate) fn keyword_ticks( &mut self, @@ -1689,8 +1860,9 @@ impl Caches { /// All Photos / Picks / Recently Deleted counts. pub fn counts(&mut self, cat: &lightcraft_catalog::Catalog) -> LibraryCounts { if let Some((r, c)) = self.counts - && r == cat.revision + && still_right(cat, r, READS_FLAG) { + self.counts = Some((cat.revision, c)); return c; } let mut c = LibraryCounts::default(); @@ -1709,25 +1881,17 @@ impl Caches { } /// Everyone named on a face in the library (for completing a name as it is typed). pub fn person_names(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<String>> { - match &self.person_names { - Some((r, v)) if *r == cat.revision => v.clone(), - _ => { - let v = std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>()); - self.person_names = Some((cat.revision, v.clone())); - v - } - } + let kept = self.person_names.take().filter(|(r, _)| still_right(cat, *r, READS_PEOPLE)).map(|(_, v)| v); + let v = kept.unwrap_or_else(|| std::sync::Arc::new(cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>())); + self.person_names = Some((cat.revision, v.clone())); + v } /// The By Date tree. pub fn date_groups(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<Vec<lightcraft_catalog::DateGroup>> { - match &self.date_groups { - Some((r, g)) if *r == cat.revision => g.clone(), - _ => { - let g = std::sync::Arc::new(cat.date_groups()); - self.date_groups = Some((cat.revision, g.clone())); - g - } - } + let kept = self.date_groups.take().filter(|(r, _)| still_right(cat, *r, READS_CAPTURED)).map(|(_, g)| g); + let g = kept.unwrap_or_else(|| std::sync::Arc::new(cat.date_groups())); + self.date_groups = Some((cat.revision, g.clone())); + g } /// Cameras, lenses and keywords in the library (filter bar pickers). pub fn filter_values(&mut self, cat: &lightcraft_catalog::Catalog) -> std::sync::Arc<FilterValues> { @@ -1751,6 +1915,25 @@ impl Caches { } } } + /// What the Add keyword box offers for `typed` (see [`panels::keywording::offers`]). Kept apart + /// from [`Self::suggestions`]: both are asked for on every frame of typing, and each looks + /// through the library's keywords. + pub(crate) fn completions( + &mut self, + cat: &lightcraft_catalog::Catalog, + current: &[String], + typed: &str, + ) -> std::sync::Arc<panels::keywording::Offers> { + let k = key_of((cat.revision, current, typed)); + match &self.completions { + Some((h, v)) if *h == k => v.clone(), + _ => { + let v = std::sync::Arc::new(panels::keywording::offers(cat, current, typed)); + self.completions = Some((k, v.clone())); + v + } + } + } /// Keyword suggestions for a photo with `current` keywords and the typed `prefix`. pub fn suggestions(&mut self, cat: &lightcraft_catalog::Catalog, current: &[String], prefix: &str, n: usize) -> std::sync::Arc<Vec<String>> { let k = key_of((cat.revision, current, prefix, n)); @@ -1799,6 +1982,21 @@ impl Caches { v } + /// A count an open dialog shows (photos a filter matches, photos with a keyword…): `count` runs + /// again only when the catalog or `what` (what is counted) changes, not every frame. + pub(crate) fn dialog_count(&mut self, cat: &lightcraft_catalog::Catalog, what: impl std::hash::Hash, count: impl FnOnce() -> usize) -> usize { + let k = key_of((cat.revision, what)); + if let Some((key, n)) = self.dialog_count + && key == k + { + return n; + } + let n = count(); + self.dialog_count_scans += 1; + self.dialog_count = Some((k, n)); + n + } + /// The problems of every saved smart album that has some (`Catalog::smart_album_problems`), /// for the sidebar's ⚠ marks: worked out again only when the catalog changes. pub fn smart_album_problems( @@ -1825,21 +2023,37 @@ impl Caches { ) -> std::sync::Arc<std::collections::HashMap<lightcraft_catalog::AlbumId, usize>> { let relative = cat.albums().any(|a| a.smart.as_ref().is_some_and(|f| f.depends_on_now())); let minute = if relative { now.get(..16).unwrap_or(now) } else { "" }; - let k = key_of((cat.revision, minute)); - match &self.album_counts { - Some((h, v)) if *h == k => v.clone(), - _ => { - if relative { - // "in the last N days" counts back from the session clock, as in the grid - lightcraft_catalog::rules::set_now(Some(now.to_string())); - } - let v: std::sync::Arc<std::collections::HashMap<_, _>> = - std::sync::Arc::new(cat.albums().map(|a| (a.id, cat.album_count(a.id))).collect()); - self.album_count_scans += 1; - self.album_counts = Some((k, v.clone())); - v - } + // the counts of this minute, if there are any: an edit to a photo or a few is followed from them, a smart + // album asking only those photos again; anything more, or a new minute, is counted from every photo + let kept = self.album_counts.take().filter(|(of, _, _)| of == minute); + if let Some((_, tally, shown)) = &kept + && tally.revision() == cat.revision + { + let shown = shown.clone(); + self.album_counts = kept; + return shown; } + if relative { + // "in the last N days" counts back from the session clock, as in the grid. (Within a minute an edit asks + // only the photos it touched, at this second; the others were asked earlier in the minute. That is as + // far out as the counts already were between edits, and the next minute counts them all again.) + lightcraft_catalog::rules::set_now(Some(now.to_string())); + } + let tally = match kept { + Some((_, mut tally, _)) => { + if !tally.update(cat) { + self.album_count_scans += 1; + } + tally + } + None => { + self.album_count_scans += 1; + lightcraft_catalog::tally::AlbumTally::of(cat) + } + }; + let shown = std::sync::Arc::new(tally.counts().clone()); + self.album_counts = Some((minute.to_string(), tally, shown.clone())); + shown } } @@ -1880,16 +2094,17 @@ mod cache_tests { c.album_counts(&cat, "2026-09-30T12:00:40"); } assert_eq!(c.album_count_scans, 1, "unchanged frames in the same minute don't rescan"); - // metadata change + // a photo rated: followed from that photo, not counted from every one cat.apply(Op::SetRating { id: PhotoId(2), rating: 4 }).unwrap(); assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 2); + assert_eq!(c.album_count_scans, 1); // rule change let rs: RuleSet = serde_json::from_value(serde_json::json!({"rules": [{"field": "rating", "op": "gte", "value": 5}]})).unwrap(); cat.apply(Op::SetAlbumRules { id: AlbumId(10), rules: Box::new(Filter { rule_set: Some(rs), ..Default::default() }) }).unwrap(); assert_eq!(c.album_counts(&cat, now)[&AlbumId(10)], 1); // an hour later the 11:59:30 photo has left "in the last hour", with no catalog change assert_eq!(c.album_counts(&cat, "2026-09-30T13:00:10")[&AlbumId(11)], 0); - assert_eq!(c.album_count_scans, 4); + assert_eq!(c.album_count_scans, 3, "new rules and a new minute are counted from every photo; the rating was not"); lightcraft_catalog::rules::set_now(None); } diff --git a/crates/ui-egui/src/lightroom_import.rs b/crates/ui-egui/src/lightroom_import.rs index 195e63e2a..241a23d6b 100644 --- a/crates/ui-egui/src/lightroom_import.rs +++ b/crates/ui-egui/src/lightroom_import.rs @@ -233,7 +233,7 @@ fn inspect_report(data: lightcraft_engine::lightroom_catalog::CatalogImport) -> json!({ "photos": data.photos.len(), "collections": data.collections.iter().filter(|r| r.get("systemOnly").and_then(Value::as_f64).unwrap_or(0.0) == 0.0).count(), - "missing": data.photos.iter().filter(|p| !std::path::Path::new(&p.path).is_file()).map(|p| &p.path).collect::<Vec<_>>(), + "missing": data.photos.iter().filter(|p| !p.original_is_file()).map(|p| &p.path).collect::<Vec<_>>(), "warnings": data.warnings, }) } @@ -243,6 +243,9 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, let kind = task.kind; if let Some(o) = value.as_object_mut() { o.entry("path").or_insert_with(|| json!(task.path)); + if let Some(library) = app.session.library.as_ref().filter(|l| l.on_disk) { + o.entry("library").or_insert_with(|| json!(library.dir.to_string_lossy())); + } } let message = if let Some(e) = value.get("error").and_then(Value::as_str) { let warning = value.get("indexWarning").and_then(Value::as_str).map_or(String::new(), |w| format!("; index warning: {w}")); @@ -280,11 +283,61 @@ fn terminal(app: &mut LightcraftApp, ctx: &egui::Context, task: &LightroomTask, crate::i18n::tr_format!("Lightroom import complete: {imported} photos added, {failed} failed", imported = imported, failed = failed) } }; + if matches!(kind, Kind::Import) && value.get("cancelled") != Some(&Value::Bool(true)) { + app.lightroom_result = Some(result_dialog(&value, &message)); + show_result(app); + } app.lightroom_last = Some(value); app.lightroom = None; app.toast_for(ctx, message, 6.0); } +fn show_result(app: &mut LightcraftApp) { + if app.ui.dialog.is_none() { + app.ui.dialog = app.lightroom_result.take(); + } +} + +fn result_dialog(value: &Value, summary: &str) -> crate::state::Dialog { + let report = value.get("report").unwrap_or(value); + let mut details = Vec::new(); + if let Some(photos) = report["photos"].as_u64() { + details.push(crate::i18n::tr_format!( + "{photos} catalog records processed; {collections} collections.", + photos = photos, + collections = report["collections"].as_u64().unwrap_or(0) + )); + } + if let Some(failures) = report["failed"].as_array() { + details + .extend(failures.iter().take(4).map(|failure| format!("{}: {}", failure[0].as_str().unwrap_or(""), failure[1].as_str().unwrap_or("")))); + } + let missing = report["missing"].as_array().map_or(0, Vec::len); + if missing > 0 { + details.push(crate::i18n::tr_format!( + "{missing} originals are missing. Their catalog records are available; use Find Missing Photos to relink them.", + missing = missing + )); + } + if let Some(warning) = value.get("indexWarning").and_then(Value::as_str) { + details.push(warning.to_string()); + } + if let Some(warnings) = report["warnings"].as_array() { + // Results can contain one warning per image; keep the dialog bounded. + details.extend(warnings.iter().take(8).filter_map(Value::as_str).map(str::to_string)); + if warnings.len() > 8 { + details.push(crate::i18n::tr_format!("{count} more warnings.", count = warnings.len() - 8)); + } + } + let first = value.get("error").is_none().then(|| report["mapping"].as_object()?.values().filter_map(Value::as_u64).min()).flatten(); + crate::state::Dialog::LightroomResult { + summary: summary.to_string(), + library: report["library"].as_str().or_else(|| value["library"].as_str()).map(str::to_string), + details, + first, + } +} + #[cfg(not(target_arch = "wasm32"))] fn start_inspect(app: &mut LightcraftApp, path: PathBuf, ctx: &egui::Context) -> Result<Value, String> { busy(app)?; @@ -348,6 +401,7 @@ pub fn command(app: &mut LightcraftApp, id: &str, p: &Value, ctx: &egui::Context /// Poll owner-thread work, commit a prepared import, and finish its archive index. pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + show_result(app); let Some(mut task) = app.lightroom.take() else { return }; let message = match task.rx.try_recv() { Ok(m) => m, @@ -553,6 +607,68 @@ mod tests { } } + #[test] + fn lightroom_result_stays_readable_and_shows_photos_hidden_by_a_filter() { + let mut h = demo(); + let id = h.app.session.catalog.photos().next().unwrap().id.0; + h.app.session.source = lightcraft_engine::LibrarySource::Picks; + h.app.session.filter.text = "no-photo-has-this-name".into(); + h.app.session.filter.only = vec![lightcraft_catalog::PhotoId(u64::MAX)]; + h.app.session.filter.labels = vec![lightcraft_catalog::ColorLabel::Red]; + h.app.session.filter.rule_set = Some(lightcraft_catalog::RuleSet { + mode: lightcraft_catalog::rules::Match::All, + rules: vec![lightcraft_catalog::rules::Rule::Field { field: "rating".into(), op: "is".into(), value: json!(5) }], + }); + let (task, _tx, _, _) = fake_import(&h); + let value = json!({"photos": 1, "imported": 1, "collections": 0, "missing": ["D:/Photos/a.NEF"], "mapping": {"1": id}, "library": "C:/Pictures/LightCraft Library"}); + terminal(&mut h.app, &h.view.ctx, &task, value); + assert!(h.app.ui.toast.as_ref().unwrap().0.contains("1 photos added")); + h.step(); + h.step(); + let text = h.painted_text().join(" "); + assert!(text.contains("Destination library"), "{text}"); + assert!(text.contains("C:/Pictures/LightCraft Library"), "{text}"); + assert!(text.contains("1 originals are missing"), "{text}"); + assert!(text.contains("Show in Library"), "{text}"); + // The activity and toast can go away; the modal result remains until dismissed. + h.app.ui.toast = None; + h.step(); + assert!(h.app.ui.dialog.is_some()); + let dialog = h.app.ui.dialog.clone().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + assert_eq!(h.app.session.source, lightcraft_engine::LibrarySource::All); + assert_eq!(h.app.session.filter, lightcraft_catalog::Filter::default()); + assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(id)); + assert_eq!(h.app.ui.view, crate::state::ViewMode::PhotoGrid); + assert!(!h.app.session.visible().is_empty()); + } + + #[test] + fn lightroom_failure_stays_readable_without_offering_a_success_action() { + let mut h = demo(); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog or WAL exceeds 1 GiB import limit"})); + h.step(); + h.step(); + assert!(h.painted_text().join(" ").contains("exceeds 1 GiB import limit")); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { first: None, .. }))); + assert!(!h.app.widgets.iter().any(|(id, _)| id == "button:dialogCancel")); + } + + #[test] + fn lightroom_result_waits_for_an_existing_dialog() { + let mut h = demo(); + h.app.ui.dialog = Some(crate::state::Dialog::About); + let (task, _tx, _, _) = fake_import(&h); + terminal(&mut h.app, &h.view.ctx, &task, json!({"error": "catalog could not be read"})); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::About))); + assert!(h.app.lightroom_result.is_some()); + h.app.ui.dialog = None; + tick(&mut h.app, &h.view.ctx); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::LightroomResult { .. }))); + assert!(h.app.lightroom_result.is_none()); + } + #[test] fn inspect_report_keeps_empty_catalog_shape() { let report = inspect_report(CatalogImport { diff --git a/crates/ui-egui/src/menu_level.rs b/crates/ui-egui/src/menu_level.rs index 379a2dd0b..96ff422f4 100644 --- a/crates/ui-egui/src/menu_level.rs +++ b/crates/ui-egui/src/menu_level.rs @@ -6,7 +6,7 @@ //! it: wheel, dragging, and ▲ / ▼ strips that scroll while the pointer rests on them, like //! native menus on a small display. The same approach as PhotoCraft's menu bar (its #319). -use egui::{Id, Rect, Sense, Ui, pos2, vec2}; +use egui::{Id, Rect, Sense, Ui, vec2}; /// Height of a scroll-arrow strip. pub const ARROW: f32 = 16.0; @@ -109,13 +109,12 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn for (strip, dir, live) in [(up, -1.0, offset > 0.5), (down, 1.0, offset < max - 0.5)] { let strip = Rect::from_x_y_ranges(x, strip.y_range()); let c = strip.center(); - let (h, w) = (4.5, 6.0); - let tri = if dir < 0.0 { - vec![pos2(c.x - w, c.y + h * 0.5), pos2(c.x + w, c.y + h * 0.5), pos2(c.x, c.y - h * 0.5)] - } else { - vec![pos2(c.x - w, c.y - h * 0.5), pos2(c.x + w, c.y - h * 0.5), pos2(c.x, c.y + h * 0.5)] - }; - ui.painter().add(egui::Shape::convex_polygon(tri, if live { t.text } else { t.text_disabled }, egui::Stroke::NONE)); + crate::icons::paint( + ui.painter(), + Rect::from_center_size(c, vec2(16.0, 16.0)), + if dir < 0.0 { crate::icons::Icon::ChevronUp } else { crate::icons::Icon::ChevronDown }, + if live { t.text } else { t.text_disabled }, + ); let id = key.with(dir < 0.0); let r = ui.interact(strip, id, Sense::hover()); r.widget_info(|| { @@ -143,6 +142,7 @@ pub fn level(ui: &mut Ui, depth: usize, bar_bottom: Option<f32>, rows: impl FnOn #[cfg(test)] mod tests { use super::*; + use egui::pos2; #[test] fn level_room_keeps_every_level_below_the_bar() { diff --git a/crates/ui-egui/src/menubar.rs b/crates/ui-egui/src/menubar.rs index fe6905cad..c5e4e45c7 100644 --- a/crates/ui-egui/src/menubar.rs +++ b/crates/ui-egui/src/menubar.rs @@ -123,6 +123,8 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.photoGrid", "view.squareGrid", "@Grid Info", + "view.thumbSizeUp", + "view.thumbSizeDown", "view.detail", "view.compare", "view.survey", @@ -132,10 +134,13 @@ const LAYOUT: &[(&str, &[&str])] = &[ "@Sidebar Sections", "view.photoCounts", "view.faceBoxes", + "view.focusPoint", + "view.zoomToFocus", "view.filmstrip", "view.histogram", "view.navigator", "view.infoOverlay", + "@Loupe Overlay", "@Appearance", "---", "view.fullScreenPreview", @@ -161,12 +166,15 @@ const LAYOUT: &[(&str, &[&str])] = &[ "view.maskOverlayMode", "view.maskOverlayColor", "view.maskPins", + "@Tool Overlay", "view.cropOverlay", "---", "@Sort", "@Stacks", "view.filterBar", "library.clearFilter", + "library.refresh", + "library.holdView", "---", "compare.swap", "compare.makeSelect", @@ -202,11 +210,13 @@ const LAYOUT: &[(&str, &[&str])] = &[ "photo.saveMetadataToFile", "photo.readMetadataFromFile", "photo.reload", + "photo.readFocusPoints", "app.showInFinder", "dialog.rename", "dialog.captureTime", "photo.tagFromTracklog", "---", + "album.removePhotos", "photo.delete", "photo.restore", "photo.deletePermanently", @@ -286,12 +296,22 @@ fn host_supports(app: &LightcraftApp, id: &str) -> bool { "file.restoreLibrary" => app.services.restore_library.is_some(), "photo.restore" | "photo.deletePermanently" => selection_deleted(app), "photo.delete" => !selection_deleted(app), + // only in an album that holds its photos (not a smart one) + "album.removePhotos" => in_normal_album(app) && !selection_deleted(app), // only where the trash is on screen "library.emptyRecentlyDeleted" => app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted, _ => true, } } +/// The library shows an album that holds its photos (not a smart album, not a folder of albums). +pub(crate) fn in_normal_album(app: &LightcraftApp) -> bool { + match app.session.source { + lightcraft_engine::LibrarySource::Album(a) => app.session.catalog.album(a).is_some_and(|a| !a.folder && !a.is_smart()), + _ => false, + } +} + fn item(id: &str, params: Value, label: impl Into<String>, shortcut: Option<&str>, enabled: bool, checked: Option<bool>) -> MenuNode { MenuNode::Item { id: id.into(), params, label: label.into(), shortcut: shortcut.map(str::to_string), enabled, checked } } @@ -315,6 +335,8 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.reference" => Some(u.view == ViewMode::Reference), "view.leftPanel" => Some(u.left_panel), "view.faceBoxes" => Some(u.face_boxes), + "view.focusPoint" => Some(u.settings.focus_point), + "view.zoomToFocus" => Some(u.settings.zoom_to_focus), "view.filmstrip" => Some(u.filmstrip), "view.histogram" => Some(u.histogram), "view.clipping" => Some(u.show_clipping), @@ -328,7 +350,10 @@ pub fn checked(app: &LightcraftApp, id: &str) -> Option<bool> { "view.beforeAfterSplit" => Some(u.before_after == crate::state::BeforeAfter::Split), "view.autoAdvance" => Some(u.auto_advance), "view.filterBar" => Some(u.filter_bar), + "view.soloSections" => Some(u.single_panel), "view.navigator" => Some(u.navigator), + "view.layoutImage" => Some(u.layout_image.show && !u.layout_image.path.trim().is_empty()), + "library.holdView" => Some(app.session.hold_view), "view.fullScreenPreview" => Some(u.fullscreen), "view.lightsOut" => Some(u.lights_out != crate::state::LightsOut::On), "view.enterFullScreen" => Some(app.window_is_fullscreen), @@ -367,7 +392,7 @@ fn live_label(app: &LightcraftApp, id: &str, label: &str) -> String { } /// The parameterized submenus. -fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { +pub(crate) fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { let active = app.session.active().and_then(|id| app.session.catalog.photo(id).cloned()); let has = active.is_some(); Some(match name { @@ -507,6 +532,48 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { } v } + "Loupe Overlay" => { + // Lightroom Classic's View ▸ Loupe Overlay ▸ Layout Image: choose the graphic, show it, + // and how opaque it is and how dark the photo is outside it + let li = &app.ui.layout_image; + let chosen = !li.path.trim().is_empty(); + let mut v = vec![ + item("view.chooseLayoutImage", Value::Null, "Choose Layout Image…", None, true, None), + item( + "view.layoutImage", + Value::Null, + "Show Layout Image", + crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.layoutImage"), + chosen, + Some(li.show && chosen), + ), + MenuNode::Separator, + ]; + let near = |a: f32, b: f32| (a - b).abs() < 0.005; + for step in crate::state::LayoutImage::OPACITY_STEPS { + let label = format!("{} {:.0}%", crate::i18n::tr("Opacity"), step * 100.0); + v.push(item("view.layoutImageOpacity", json!({"opacity": step}), label, None, chosen, Some(near(li.opacity, step)))); + } + v.push(MenuNode::Separator); + for step in crate::state::LayoutImage::MATTE_STEPS { + let label = + if step == 0.0 { crate::i18n::tr("No Matte").to_string() } else { format!("{} {:.0}%", crate::i18n::tr("Matte"), step * 100.0) }; + v.push(item("view.layoutImageMatte", json!({"matte": step}), label, None, chosen, Some(near(li.matte, step)))); + } + v + } + "Tool Overlay" => { + // when the spot and mask pins show; the last item steps to the next mode (Shift+H) + use crate::state::ToolOverlay; + let mut v: Vec<MenuNode> = ToolOverlay::ALL + .into_iter() + .map(|m| item("view.toolOverlay", json!({"mode": m.name()}), m.label(), None, true, Some(app.ui.tool_overlay == m))) + .collect(); + v.push(MenuNode::Separator); + let shortcut = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "view.toolOverlay"); + v.push(item("view.toolOverlay", Value::Null, "Cycle Tool Overlay", shortcut, true, None)); + v + } "Grid Info" => ["filename", "exposure", "date"] .into_iter() .zip(["File Name", "Exposure (shutter · aperture · ISO)", "Capture Date"]) @@ -644,7 +711,7 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { v } "Add to Album" => { - let targets = app.session.targets(&Value::Null); + let targets = app.session.selected(); let sel = !targets.is_empty(); // albums hold library photos (issue #684): a Local-only selection is told what to do first let local_only = crate::panels::grid::local_only(app, &targets); @@ -670,6 +737,13 @@ fn expanded(app: &LightcraftApp, name: &str) -> Option<Vec<MenuNode>> { fn node(app: &LightcraftApp, e: &MenuEntry) -> MenuNode { // Shift+P picks and advances in Library; don't advertise it on Presets in those views. let shortcut = if e.id == "panel.presets" && crate::shortcuts::library_grid(app) { None } else { e.shortcut.as_deref() }; + // in an album the Delete key removes from the album (issue #1805): the menu says so + let delete_key = crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "photo.delete"); + let shortcut = match e.id.as_str() { + "album.removePhotos" => delete_key, + "photo.delete" if matches!(app.session.source, lightcraft_engine::LibrarySource::Album(_)) => None, + _ => shortcut, + }; item(&e.id, Value::Null, live_label(app, &e.id, &e.label), shortcut, e.enabled, checked(app, &e.id)) } @@ -766,7 +840,18 @@ pub fn menu_bar(app: &LightcraftApp) -> Vec<(String, Vec<MenuNode>)> { /// Compare/Survey; Auto Advance moves on). pub fn run_item(app: &mut LightcraftApp, id: &str, params: Value) -> Result<Value, String> { let mut params = if params.is_null() { json!({}) } else { params }; - let culling_cmd = matches!(id, "photo.rate" | "photo.flag" | "photo.label" | "photo.pick" | "photo.reject" | "photo.unflag"); + let culling_cmd = matches!( + id, + "photo.rate" + | "photo.flag" + | "photo.label" + | "photo.pick" + | "photo.reject" + | "photo.unflag" + | "photo.cycleFlag" + | "photo.increaseFlag" + | "photo.decreaseFlag" + ); if culling_cmd { crate::panels::compare::target_active(app, &mut params); } @@ -799,6 +884,8 @@ pub fn shortcut_text(sc: &str, mac: bool) -> String { "Escape" => "⎋", "Left" => "←", "Right" => "→", + "Up" => "↑", + "Down" => "↓", k => k, }; format!("{mods}{key}") @@ -930,7 +1017,7 @@ fn switch_open_title(ctx: &egui::Context, titles: &[egui::Response]) -> Option<e /// bounded like every level. The row is registered as `menusub:<label>` (the untranslated /// label), and while its submenu is open it is the anchor the next frame's room is measured from. fn submenu(ui: &mut egui::Ui, label: &str, text: String, depth: usize, bar_bottom: Option<f32>, children: impl FnOnce(&mut egui::Ui)) { - let r = ui.menu_button(text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); + let r = crate::widgets::menu_button(ui, text, |ui| crate::menu_level::level(ui, depth + 1, bar_bottom, children)); crate::widgets::register(ui.ctx(), format!("menusub:{label}"), r.response.rect); if r.inner.is_some() { crate::menu_level::set_anchor(ui.ctx(), depth + 1, r.response.rect); @@ -987,6 +1074,17 @@ mod tests { }) } + #[test] + fn view_menu_offers_the_hold_and_the_refresh_and_checks_the_hold_while_it_is_on() { + let mut a = app(); + let view = |a: &LightcraftApp| menu_bar(a).into_iter().find(|(t, _)| t == "View").expect("View menu").1; + let items = view(&a); + assert!(matches!(find(&items, "library.refresh"), Some(MenuNode::Item { enabled: true, .. }))); + assert!(matches!(find(&items, "library.holdView"), Some(MenuNode::Item { checked: Some(false), .. })), "off unless asked for"); + a.session.execute("library.holdView", &json!({"on": true})).expect("hold the view"); + assert!(matches!(find(&view(&a), "library.holdView"), Some(MenuNode::Item { checked: Some(true), .. }))); + } + /// The Sort submenu is expanded by hand: Reshuffle appears once (not again from the registry), /// only while sorting at random, and the direction items are off for a shuffle. #[test] @@ -1188,6 +1286,12 @@ mod tests { let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " "})); assert_eq!(r.unwrap_err(), crate::control::NO_EXPORT_FOLDER); assert_eq!(written.lock().unwrap().len(), n, "nothing written without a folder"); + // "Same folder as original photo" needs no folder; a demo photo has none of its own, so it + // gets its own error rather than a file written elsewhere (issue #956) + let r = run_item(&mut app, "app.export", json!({"preset": "Tiny PNG", "dir": " ", "destinationMode": "sameAsOriginal"})); + let e = r.unwrap_err(); + assert!(e.contains("no folder on disk"), "{e}"); + assert_eq!(written.lock().unwrap().len(), n, "nothing written for a photo without a folder"); } #[test] diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ad31c57bf..743bd4600 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -24,6 +24,10 @@ pub const LANGUAGE_COMMANDS: &[UiCommand] = &[ ("app.language.german", crate::i18n::Locale::De.name(), None, "Edit>Language"), ("app.language.russian", crate::i18n::Locale::Ru.name(), None, "Edit>Language"), ("app.language.ukrainian", crate::i18n::Locale::Uk.name(), None, "Edit>Language"), + ("app.language.polish", crate::i18n::Locale::Pl.name(), None, "Edit>Language"), + ("app.language.czech", crate::i18n::Locale::Cs.name(), None, "Edit>Language"), + ("app.language.italian", crate::i18n::Locale::It.name(), None, "Edit>Language"), + ("app.language.georgian", crate::i18n::Locale::Ka.name(), None, "Edit>Language"), ]; /// Every UI command: the languages, then everything else. `xtask parity` reads both tables from @@ -46,6 +50,10 @@ pub fn language_from_command(id: &str) -> Option<crate::i18n::Locale> { "app.language.german" => Some(crate::i18n::Locale::De), "app.language.russian" => Some(crate::i18n::Locale::Ru), "app.language.ukrainian" => Some(crate::i18n::Locale::Uk), + "app.language.polish" => Some(crate::i18n::Locale::Pl), + "app.language.czech" => Some(crate::i18n::Locale::Cs), + "app.language.italian" => Some(crate::i18n::Locale::It), + "app.language.georgian" => Some(crate::i18n::Locale::Ka), _ => None, } } @@ -66,6 +74,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.people", "People", None, "View"), ("view.person", "Show Person", None, ""), ("view.faceBoxes", "Face Boxes", None, "View"), + ("view.focusPoint", "Show Focus Point", Some("Alt+F"), "View"), + ("view.zoomToFocus", "Zoom to Focus Point", None, "View"), ("view.reference", "Reference View", Some("Shift+R"), "View"), ("photo.setReference", "Set as Reference Photo", None, ""), ("compare.swap", "Swap Compare Photos", None, "View"), @@ -87,6 +97,9 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.navigate", "Set Image Zoom and Pan", None, ""), ("view.zoomIn", "Zoom In", Some("Cmd+="), "View"), ("view.zoomOut", "Zoom Out", Some("Cmd+-"), "View"), + // Library grids only (Photo Grid, Square Grid, People): the bottom-bar slider's step by key + ("view.thumbSizeUp", "Increase Grid Thumbnail Size", Some("="), "View"), + ("view.thumbSizeDown", "Decrease Grid Thumbnail Size", Some("-"), "View"), ("view.clipping", "Show Clipping", Some("J"), "View"), ("view.visualizeHdr", "Visualize HDR", None, "View"), // in grids S expands/collapses stacks (the engine command it shadows) @@ -99,6 +112,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.maskOverlayMode", "Cycle Mask Overlay Mode", None, "View"), ("view.maskOverlayColor", "Cycle Mask Overlay Color", None, "View"), ("view.maskPins", "Show Mask Pins", None, "View"), + // {mode?: auto | always | selected | never}: when spot and mask pins show (steps to the next + // mode when omitted; Shift+H, like Classic's H for pins, since H opens Remove). View ▸ Tool + // Overlay and the Remove panel list the modes. + ("view.toolOverlay", "Cycle Tool Overlay", Some("Shift+H"), ""), ("view.visualizeSpots", "Visualize Spots", Some("A"), "View"), ("view.cropOverlay", "Cycle Crop Overlay", Some("Shift+O"), "View"), ("view.cropOverlayOrientation", "Cycle Crop Overlay Orientation", None, "View"), @@ -112,6 +129,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.lightsOut", "Lights Out", Some("Shift+L"), "View"), ("view.enterFullScreen", "Enter Full Screen", Some("Cmd+Shift+F"), "View"), ("view.infoOverlay", "Cycle Info Overlay", Some("Cmd+I"), "View"), + ("view.chooseLayoutImage", "Choose Layout Image…", None, "View"), + ("view.layoutImage", "Show Layout Image", Some("Cmd+Alt+O"), "View"), + ("view.layoutImageOpacity", "Layout Image Opacity", None, ""), + ("view.layoutImageMatte", "Layout Image Matte", None, ""), ("view.navigator", "Navigator", None, "View"), ("panel.edit", "Edit", Some("E"), "Window"), ("panel.profiles", "Profile Browser", None, "Window"), @@ -125,11 +146,15 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("panel.versions", "Versions", Some("Shift+V"), "Window"), ("panel.activity", "History", None, "Window"), ("panel.close", "Close Panel", None, ""), + // {solo?}: true opens just that section (⌥-click on its header); otherwise opens or closes it ("section.light", "Light", Some("Cmd+1"), "Window>Edit Sections"), ("section.color", "Color", Some("Cmd+2"), "Window>Edit Sections"), ("section.effects", "Effects", Some("Cmd+3"), "Window>Edit Sections"), ("section.detail", "Detail", Some("Cmd+4"), "Window>Edit Sections"), ("section.optics", "Optics", Some("Cmd+5"), "Window>Edit Sections"), + ("section.calibration", "Calibration", None, ""), + // {on?}; no params toggles. Opening one Edit section closes the others. + ("view.soloSections", "Solo Mode", None, "Window>Edit Sections"), ("tool.brush", "Brush", Some("B"), "Window>Tools"), ("tool.linear", "Linear Gradient", Some("L"), "Window>Tools"), ("tool.radial", "Radial Gradient", Some("R"), "Window>Tools"), @@ -148,6 +173,14 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("view.sidebarMoveSection", "Move Sidebar Section", None, ""), ("view.sidebarReset", "Reset Sidebar Sections", None, ""), ("view.slideshow", "Slideshow", Some("Cmd+Alt+Enter"), "View"), + ("view.slideshowOptions", "Slideshow Options…", None, "View"), + ("dialog.slideshowExport", "Export Slideshow…", None, "View"), + ("app.slideshowExport", "Export Slideshow", None, ""), + // the slideshow's playback controls (Space, ←/→ and Esc while it plays) + ("slideshow.togglePause", "Pause / Resume Slideshow", None, ""), + ("slideshow.next", "Next Slide", None, ""), + ("slideshow.previous", "Previous Slide", None, ""), + ("slideshow.end", "End Slideshow", None, ""), ("view.secondWindow", "Second Window", Some("Cmd+F11"), "Window"), ("tool.keywordPainter", "Keyword Painter", None, ""), ("view.gridInfo", "Grid Info", None, ""), @@ -165,6 +198,8 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("dialog.pasteSettings", "Paste Selected Settings…", Some("Cmd+Shift+V"), "Edit"), ("view.focusSearch", "Find…", Some("Cmd+F"), "Edit"), ("dialog.export", "Export…", None, "File"), + ("dialog.print", "Print…", Some("Cmd+P"), "File"), + ("app.print", "Print to PDF", None, ""), ("dialog.contactSheet", "Contact Sheet PDF…", None, "File"), ("app.contactSheet", "Export Contact Sheet PDF", None, ""), ("photo.editInPhotoCraft", "Edit in PhotoCraft", Some("Cmd+Shift+E"), "Photo>Edit In"), @@ -190,6 +225,10 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("app.quit", "Quit LightCraft", Some("Cmd+Q"), "File"), ("file.importPresets", "Import Profiles & Presets…", None, "File"), ("file.exportPresets", "Export Presets…", None, "File"), + // Masking panel ▸ Effect menu + ("dialog.saveMaskEffect", "Save Current Settings as New Effect Preset…", None, ""), + ("dialog.manageMaskEffects", "Manage Effect Presets…", None, ""), + ("file.exportMaskEffects", "Export Effect Presets…", None, ""), ("file.importKeywords", "Import Keywords…", None, "File"), ("file.exportKeywords", "Export Keywords…", None, "File"), // Edit panel ▸ Curve ▸ Point Curve dropdown @@ -197,7 +236,7 @@ pub const UI_COMMANDS: &[UiCommand] = &[ ("file.exportCurvePresets", "Export Point Curve Presets…", None, ""), ("app.settings", "Settings…", Some("Cmd+,"), "Edit"), ("app.newLibrary", "New Library…", None, "File"), - ("app.openLibrary", "Open Library…", None, "File"), + ("app.openLibrary", "Open Library…", Some("Cmd+O"), "File"), ("app.recentLibraries", "Recent Libraries", None, ""), ("app.clearRecentLibraries", "Clear Recent Libraries", None, ""), // Settings ▸ Display @@ -321,6 +360,18 @@ fn bool_param(command: &str, p: &Value, key: &str) -> Result<Option<bool>, Strin } } +/// What Shift+H says it switched to (the pins themselves may be off screen). +fn tool_overlay_toast(mode: crate::state::ToolOverlay) -> String { + use crate::state::ToolOverlay; + crate::i18n::tr(match mode { + ToolOverlay::Auto => "Tool Overlay: Auto", + ToolOverlay::Always => "Tool Overlay: Always", + ToolOverlay::Selected => "Tool Overlay: Selected", + ToolOverlay::Never => "Tool Overlay: Never", + }) + .to_string() +} + fn appearance_json(app: &LightcraftApp) -> Value { let s = &app.ui.settings; json!({"mode": s.appearance_mode, "darkTheme": s.dark_theme, "lightTheme": s.light_theme}) @@ -492,6 +543,16 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "view.compare" => crate::panels::compare::enter_compare(app), + "view.focusPoint" => { + // {on?}; no params toggles. The camera's focus point (Sony, from the file) over the photo. + app.ui.settings.focus_point = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.focus_point); + Ok(json!({"on": app.ui.settings.focus_point})) + } + "view.zoomToFocus" => { + // {on?}; no params toggles. Toggle Zoom (Z) centres on the focus point when there is one. + app.ui.settings.zoom_to_focus = p.get("on").and_then(Value::as_bool).unwrap_or(!app.ui.settings.zoom_to_focus); + Ok(json!({"on": app.ui.settings.zoom_to_focus})) + } "view.faceBoxes" => { app.ui.face_boxes = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.face_boxes); Ok(json!({"show": app.ui.face_boxes})) @@ -551,7 +612,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re "compare.makeSelect" => crate::panels::compare::make_select(app), "view.autoAdvance" => { app.ui.auto_advance = !app.ui.auto_advance; - app.toast(&ctx, if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" }); + app.toast(&ctx, crate::i18n::tr(if app.ui.auto_advance { "Auto Advance On" } else { "Auto Advance Off" })); Ok(json!({"autoAdvance": app.ui.auto_advance})) } "view.back" => { @@ -574,11 +635,11 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } else if matches!(app.ui.view, ViewMode::Compare | ViewMode::Survey) { app.ui.view = ViewMode::Detail; } else if app.ui.view == ViewMode::Detail { - // a photo opened from a person's page goes back to that page, any other to the grid + // a photo opened from a person's page (or the unnamed faces) goes back there, any other to the grid match app.ui.person_from.take() { Some((name, photo)) if app.session.active().is_some_and(|a| a.0 == photo) => { app.ui.view = ViewMode::People; - app.ui.person_page = Some(name); + app.ui.person_page = name; } _ => app.ui.view = ViewMode::PhotoGrid, } @@ -586,25 +647,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "view.slideshow" => { - // {interval?: seconds (4)}: the photos in view, full screen, one after another - let interval = p.get("interval").and_then(Value::as_f64).unwrap_or(4.0).clamp(0.5, 120.0); - if app.session.active().is_none() { - let first = app.session.visible_cloned().first().copied(); - match first { - Some(f) => { - let _ = app.run("library.select", json!({"ids": [f.0]})); - } - None => return Some(Err("no photos to show".into())), - } - } + // the photos in view, full screen, with the playback settings (see panels::slideshow) let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, false)); - app.ui.fullscreen = true; - app.ui.zoom = Zoom::Fit; - app.ui.tool.clear(); - let _ = app.session.end_interaction(); - app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); - Ok(json!({"interval": interval})) + let r = crate::panels::slideshow::start(app, p, now); + if r.is_ok() { + app.toast(&ctx, crate::i18n::tr("Slideshow · Space pauses · Esc ends")); + } + r + } + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => { + let now = ctx.input(|i| i.time); + crate::panels::slideshow::command(app, id, now) + } + "view.slideshowOptions" => { + app.ui.dialog = Some(Dialog::Settings { tab: "slideshow".into() }); + Ok(Value::Null) } "view.fullScreenPreview" => { app.ui.fullscreen = !app.ui.fullscreen; @@ -648,9 +705,57 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re crate::state::InfoOverlay::Basic => "Info Overlay: File", crate::state::InfoOverlay::Exposure => "Info Overlay: Exposure", }; - app.toast(&ctx, label); + app.toast(&ctx, crate::i18n::tr(label)); Ok(json!({"infoOverlay": app.ui.info_overlay})) } + "view.chooseLayoutImage" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(path) => path.trim().to_string(), + None => { + let req = PickRequest::file(crate::i18n::tr("Choose Layout Image…"), "PNG", &["png"]); + match crate::pick::ask(app, id, p, "path", req, |s| s.pick_files.as_mut().map(|f| f())) { + Picked::Now(paths) => match paths.first() { + Some(path) => path.trim().to_string(), + None => return Some(Ok(Value::Null)), + }, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform; supply path".into())), + } + } + }; + if path.is_empty() { + return Some(Err("view.chooseLayoutImage: path is empty".into())); + } + let li = &mut app.ui.layout_image; + li.path = path; + li.show = true; + li.revision = li.revision.wrapping_add(1); + app.toast(&ctx, "Layout Image On"); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImage" => { + let show = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.layout_image.show); + // nothing chosen yet: showing it chooses one first + if show && app.ui.layout_image.path.trim().is_empty() { + return run_ui_command(app, "view.chooseLayoutImage", &json!({})); + } + app.ui.layout_image.show = show; + app.toast(&ctx, if show { "Layout Image On" } else { "Layout Image Off" }); + Ok(json!({"layoutImage": app.ui.layout_image})) + } + "view.layoutImageOpacity" | "view.layoutImageMatte" => { + let key = if id == "view.layoutImageOpacity" { "opacity" } else { "matte" }; + let Some(v) = p.get(key).and_then(Value::as_f64).filter(|v| v.is_finite()) else { + return Some(Err(format!("{id}: {key} (0..1) is required"))); + }; + let v = v.clamp(0.0, 1.0) as f32; + if key == "opacity" { + app.ui.layout_image.opacity = v; + } else { + app.ui.layout_image.matte = v; + } + Ok(json!({"layoutImage": app.ui.layout_image})) + } "view.navigator" => { app.ui.navigator = !app.ui.navigator; Ok(json!({"navigator": app.ui.navigator})) @@ -713,9 +818,18 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "view.zoomToggle" => { - // the same ratio a click on the photo zooms to - app.ui.zoom = if app.ui.zoom == Zoom::Fit { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; + // the same ratio a click on the photo zooms to; zooming in centres on the camera's focus + // point when the photo has one (View ▸ Zoom to Focus Point) + let zoom_in = app.ui.zoom == Zoom::Fit; + app.ui.zoom = if zoom_in { Zoom::Percent(app.ui.click_zoom as f32) } else { Zoom::Fit }; app.ui.zoom_anim = true; + if zoom_in + && app.ui.settings.zoom_to_focus + && let Some(pan) = app.session.active().and_then(|id| crate::panels::detail::focus_pan(app, id)) + { + app.ui.pan = pan; + return Some(Ok(json!({"focus": [pan.0, pan.1]}))); + } Ok(Value::Null) } "view.clickZoom" => { @@ -743,6 +857,15 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.zoom = if next == 0.0 { Zoom::Fit } else { Zoom::Percent(next) }; Ok(Value::Null) } + "view.thumbSizeUp" | "view.thumbSizeDown" => { + // the bottom-bar slider's range, one `THUMB_SIZE_STEP` per call; {} → {size} + if !thumb_size_view(app) { + return Some(Err(format!("{id}: only in Photo Grid, Square Grid or People"))); + } + let step = if id == "view.thumbSizeUp" { crate::state::THUMB_SIZE_STEP } else { -crate::state::THUMB_SIZE_STEP }; + app.ui.thumb_size = (app.ui.thumb_size + step).clamp(crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + Ok(json!({"size": app.ui.thumb_size})) + } "view.navigate" => { // {zoom?: "fit"|"fill"|{percent: number}, pan?: [x, y]} (normalized image centre). // Validate the complete request before changing either part of the viewport. @@ -900,6 +1023,19 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.mask_pins = p.get("show").and_then(Value::as_bool).unwrap_or(!app.ui.mask_pins); Ok(json!({"maskPins": app.ui.mask_pins})) } + "view.toolOverlay" => { + use crate::state::ToolOverlay; + let mode = match p.get("mode") { + None | Some(Value::Null) => app.ui.tool_overlay.next(), + Some(v) => match v.as_str().and_then(ToolOverlay::parse) { + Some(m) => m, + None => return Some(Err(format!("view.toolOverlay: unknown mode {v} (auto|always|selected|never)"))), + }, + }; + app.ui.tool_overlay = mode; + app.toast(&ctx, tool_overlay_toast(mode)); + Ok(json!({"mode": mode.name()})) + } "view.visualizeSpots" => { // like Lightroom's A: opens the Remove tool with the view on, or toggles it there if app.ui.right == RightPanel::Remove { @@ -959,6 +1095,10 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } Ok(json!({"hidden": app.ui.hidden_locations})) } + "view.soloSections" => bool_param(id, p, "on").map(|on| { + app.ui.set_single_panel(on.unwrap_or(!app.ui.single_panel)); + json!({"soloSections": app.ui.single_panel}) + }), "view.filterBar" => { app.ui.filter_bar = !app.ui.filter_bar; if app.ui.filter_bar && !matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid) { @@ -1039,14 +1179,22 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.presets = false; Ok(Value::Null) } - s if s.starts_with("section.") => { + s if s.starts_with("section.") => bool_param(s, p, "solo").and_then(|solo| { + // only the Edit sections listed above: an unknown one would close the real ones + if !UI_COMMANDS.iter().any(|c| c.0 == s) { + return Err(format!("{s}: no such Edit section")); + } let sec = &s["section.".len()..]; if app.ui.right != RightPanel::Edit { app.ui.right = RightPanel::Edit; } - app.ui.toggle_section(sec); + if solo == Some(true) { + app.ui.solo_section(sec); + } else { + app.ui.toggle_section(sec); + } Ok(Value::Null) - } + }), s if s.starts_with("tool.") => { let tool = &s["tool.".len()..]; match tool { @@ -1205,10 +1353,42 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re app.ui.dialog = Some(Dialog::CopySettings { groups }); Ok(Value::Null) } + "dialog.slideshowExport" => { + let format = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => lightcraft_engine::slideshow_export::Format::Jpeg, + Some("video") => lightcraft_engine::slideshow_export::Format::Video, + _ => lightcraft_engine::slideshow_export::Format::Pdf, + }; + app.ui.dialog = Some(Dialog::SlideshowExport { settings: lightcraft_engine::slideshow_export::ExportSettings::for_format(format) }); + Ok(Value::Null) + } + "app.slideshowExport" => crate::slideshow_export::start(app, id, p), "dialog.contactSheet" => { app.ui.dialog = Some(Dialog::ContactSheet { options: lightcraft_engine::contact_sheet::Options::default() }); Ok(Value::Null) } + "dialog.print" => { + app.ui.print = Default::default(); + app.ui.dialog = Some(Dialog::Print); + Ok(Value::Null) + } + "app.print" => { + if app.export.is_some() { + return Some(Err("an export is already running".into())); + } + let mut params = p.clone(); + if p.get("path").is_none() { + let req = PickRequest::save("Print to PDF", "PDF", &["pdf"], "Print.pdf"); + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no save dialog on this platform; supply path".into())), + }; + let Some(path) = paths.first() else { return Some(Ok(Value::Null)) }; + params["path"] = json!(path); + } + crate::export_task::start_print(app, &params) + } "app.contactSheet" => { if app.export.is_some() { return Some(Err("an export is already running".into())); @@ -1238,6 +1418,7 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Ok(Value::Null) } "merge.hdrLast" => crate::merge::start_last(app, "merge.hdr"), + "merge.hdrBatch" => crate::merge::start_batch(app, p), "merge.panoramaLast" => crate::merge::start_last(app, "merge.panorama"), "merge.hdrPanoramaLast" => crate::merge::start_last(app, "merge.hdrPanorama"), "dialog.mergeHdr" => crate::merge::open(app, "merge.hdr"), @@ -1654,9 +1835,14 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re if let (false, Ok(v)) = (preset_paths.is_empty(), &r) { let n = v["imported"].as_array().map_or(0, Vec::len); let failed = v["failed"].as_array().map_or(0, Vec::len); + // Lightroom local adjustment presets land in the Masking panel's Effect menu: part of the same toast + let effects = v["effects"].as_array().map_or(0, Vec::len); + if effects > 0 { + parts.push(crate::panels::mask_effects::import_toast(v)); + } let profile_news = !parts.is_empty() || !problems.is_empty(); let msg = match (n, failed) { - // a zip of LUTs only: the profile half already says what happened + // a zip of LUTs (or only local presets): the other half already says what happened (0, 0) if profile_news => None, (0, 0) => Some("No new presets".to_string()), (n, 0) => Some(crate::i18n::tr_format!("Imported {n} preset{}", if n == 1 { "" } else { "s" }, n = n)), @@ -1786,6 +1972,49 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re } return Some(r); } + "dialog.saveMaskEffect" => { + if app.session.active_mask.is_none() { + return Some(Err("select a mask first".into())); + } + crate::panels::mask_effects::open_save(app); + Ok(Value::Null) + } + "dialog.manageMaskEffects" => { + crate::panels::mask_effects::open_manage(app); + Ok(Value::Null) + } + "file.exportMaskEffects" => { + let path = match p.get("path").and_then(Value::as_str) { + Some(x) => Some(x.to_string()), + None => { + let name = format!("{}.lceffect", p.get("group").and_then(Value::as_str).unwrap_or("Effect Presets")); + let req = PickRequest::save( + crate::i18n::tr("Export Effect Presets"), + crate::i18n::tr("LightCraft Effect Presets"), + &["lceffect"], + name.clone(), + ); + match crate::pick::ask(app, id, p, "path", req, |s| s.save_preset_file.as_mut().map(|f| f(&name).into_iter().collect())) { + Picked::Now(v) => v.into_iter().next(), + Picked::Later => return Some(Ok(Value::Null)), + Picked::Unavailable => return Some(Err("no file dialog on this platform".into())), + } + } + }; + let Some(path) = path else { return Some(Ok(Value::Null)) }; + let mut params = json!({"path": path}); + for k in ["names", "group"] { + if let Some(v) = p.get(k) { + params[k] = v.clone(); + } + } + let r = app.session.execute("mask.exportEffects", &params).map_err(|e| e.to_string()); + match &r { + Ok(v) => app.toast(&ctx, crate::i18n::tr_format!("Exported {} effect preset{}", v["count"], if v["count"] == 1 { "" } else { "s" })), + Err(e) => app.toast_error(&ctx, e.clone()), + } + return Some(r); + } "file.exportPresets" => { let group = p.get("group").and_then(Value::as_str).map(str::to_string); let path = match p.get("path").and_then(Value::as_str) { @@ -1891,10 +2120,21 @@ pub fn run_ui_command(app: &mut LightcraftApp, id: &str, p: &Value) -> Option<Re Some(r) } +/// Views whose bottom bar has the thumbnail-size slider and that draw tiles of that size. +fn thumb_size_view(app: &LightcraftApp) -> bool { + matches!(app.ui.view, ViewMode::PhotoGrid | ViewMode::SquareGrid | ViewMode::People) +} + pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { match id { "library.selectOnlyActive" => app.session.active().is_some(), - "dialog.contactSheet" | "app.contactSheet" => !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none(), + "view.thumbSizeUp" | "view.thumbSizeDown" => thumb_size_view(app), + "dialog.contactSheet" | "app.contactSheet" | "dialog.print" | "app.print" => { + !cfg!(target_arch = "wasm32") && app.session.active().is_some() && app.export.is_none() + } + "dialog.slideshowExport" | "app.slideshowExport" => { + !cfg!(target_arch = "wasm32") && !app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) + } s if s.starts_with("panel.") || s.starts_with("tool.") || s.starts_with("section.") => app.session.active().is_some() || s == "panel.close", "app.export" | "dialog.export" | "dialog.createPreset" | "dialog.rename" | "dialog.captureTime" | "dialog.copySettings" => { app.session.active().is_some() @@ -1904,12 +2144,13 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "app.exportPrevious" => app.session.active().is_some() && app.session.last_export.is_some(), "dialog.pasteSettings" => app.session.active().is_some() && app.session.clipboard.is_some(), "photo.openOriginalInExternal" => { + // only a file type LightCraft reads can be handed over (#2135); the extension alone + // decides, so no disk or share is touched while a menu is drawn app.services.open_with.is_some() - && app - .session - .active() - .and_then(|id| app.session.catalog.photo(id)) - .is_some_and(|p| matches!(p.source, lightcraft_engine::catalog::Source::File { .. })) + && app.session.active().and_then(|id| app.session.catalog.photo(id)).is_some_and(|p| match &p.source { + lightcraft_engine::catalog::Source::File { path } => lightcraft_engine::open_path::is_media(std::path::Path::new(path)), + _ => false, + }) } "app.showInFinder" => { app.services.reveal.is_some() @@ -1921,6 +2162,8 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { } "app.openSamModelFolder" => app.session.segmenter.dir.is_some() && app.services.open_with.is_some(), "file.exportPresets" => app.session.presets.iter().any(|p| !p.builtin), + "file.exportMaskEffects" => !app.session.mask_effects.is_empty(), + "dialog.saveMaskEffect" => app.session.active().is_some() && app.session.active_mask.is_some(), "file.exportCurvePresets" => !app.session.curve_presets.is_empty(), "view.compare" => app.session.catalog.len() > 1, "view.fullScreenPreview" | "view.infoOverlay" | "view.navigator" => app.session.active().is_some() || app.ui.fullscreen, @@ -1931,8 +2174,9 @@ pub fn ui_enabled(app: &LightcraftApp, id: &str) -> bool { "file.restoreLibrary" => app.services.restore_library.is_some(), "app.clearRecentLibraries" => !app.ui.settings.recent_libraries.is_empty(), "compare.swap" | "compare.makeSelect" | "compare.set" => app.ui.view == ViewMode::Compare, + "slideshow.togglePause" | "slideshow.next" | "slideshow.previous" | "slideshow.end" => app.ui.slideshow.is_some(), s if s.starts_with("dialog.merge") || (s.starts_with("merge.") && s.ends_with("Last")) => { - app.session.targets(&serde_json::json!({})).len() >= 2 && app.merge.final_task.is_none() + app.session.selected().len() >= 2 && app.merge.final_task.is_none() } _ => true, } @@ -1973,13 +2217,39 @@ pub fn menu_entries(app: &LightcraftApp) -> Vec<MenuEntry> { v } +/// The Delete key in an album: a normal album loses the selected photos (`album.removePhotos`), +/// which stay in the library and their folder; a smart album keeps them (its rules decide) and +/// says so. True when the key was handled here, false outside albums (then it deletes the photos +/// from the library). Photo ▸ Delete Photo still deletes from the library everywhere. +pub fn delete_in_album(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + let lightcraft_engine::LibrarySource::Album(aid) = app.session.source else { return false }; + let Some(album) = app.session.catalog.album(aid) else { return false }; + if album.is_smart() { + app.toast(ctx, crate::i18n::tr("Smart albums update automatically: change their rules, or use Photo ▸ Delete Photo")); + return true; + } + if app.session.selected().is_empty() { + return true; + } + match app.run("album.removePhotos", json!({"id": aid.0})) { + Ok(r) => { + let n = r["removed"].as_u64().unwrap_or(0); + let name = r["album"].as_str().unwrap_or_default().to_string(); + let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); + app.toast(ctx, crate::i18n::tr_format!("Removed {what} from {name}", what = what, name = name)); + } + Err(e) => app.toast(ctx, e), + } + true +} + /// With Settings → General → "Confirm before deleting" on, open the confirmation dialog instead /// of deleting; true when it did (the dialog's OK runs `photo.delete`). pub fn confirm_delete(app: &mut LightcraftApp) -> bool { if !app.ui.settings.confirm_delete { return false; } - let count = app.session.targets(&json!({})).len(); + let count = app.session.selected().len(); if count == 0 { return false; } @@ -2055,8 +2325,15 @@ fn edit_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value, photocraft: if let Some(id) = id { app.ui.external_edits.push(*id); } - if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(path, &editor)) { - failed = Some(e); + // the copy is LightCraft's own render, but it goes through the same gate as any other + // path handed to the system opener (#2135) + match lightcraft_engine::open_path::openable(path, "") { + Err(e) => failed = Some(e), + Ok(absolute) => { + if let Some(Err(e)) = app.services.open_with.as_mut().map(|f| f(&absolute, &editor)) { + failed = Some(e); + } + } } } match (failed, copies.as_slice()) { @@ -2097,7 +2374,7 @@ fn open_original_in(app: &mut LightcraftApp, ctx: &egui::Context, p: &Value) -> } fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { - let mut ids = app.session.targets(p); + let mut ids = app.session.targets(p).map_err(|e| e.to_string())?; ids.dedup(); if ids.is_empty() { return Err("no photo selected".into()); @@ -2108,11 +2385,13 @@ fn open_originals(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { let mut files = Vec::new(); let mut skipped = Vec::new(); for id in ids { - match app.session.catalog.photo(id).map(|ph| &ph.source) { - Some(lightcraft_engine::catalog::Source::File { path }) if std::path::Path::new(path).is_file() => files.push((id, path.clone())), - Some(lightcraft_engine::catalog::Source::File { path }) => { - skipped.push(json!({"id": id.0, "reason": format!("the original is missing: {path}")})) - } + // the path may come from an imported catalog: only a photo file LightCraft reads is + // handed to the opener, never a program (#2135) + match app.session.catalog.photo(id).map(|ph| (ph.file_name.clone(), ph.source.clone())) { + Some((name, lightcraft_engine::catalog::Source::File { path })) => match lightcraft_engine::open_path::openable(&path, &name) { + Ok(absolute) => files.push((id, absolute)), + Err(reason) => skipped.push(json!({"id": id.0, "reason": reason})), + }, Some(_) => skipped.push(json!({"id": id.0, "reason": "a demo photo has no original file"})), None => skipped.push(json!({"id": id.0, "reason": "no such photo"})), } diff --git a/crates/ui-egui/src/merge.rs b/crates/ui-egui/src/merge.rs index a603692fa..61485a271 100644 --- a/crates/ui-egui/src/merge.rs +++ b/crates/ui-egui/src/merge.rs @@ -108,8 +108,8 @@ pub struct MergeState { pub last_result: Option<Value>, /// Photos the dialog merges (the selection when it opened). pub ids: Vec<PhotoId>, - /// The options of the last merge started, per kind ("merge.hdr" …): Merge with Last Settings. - pub last: std::collections::HashMap<String, MergeDialog>, + /// HDR for Each Bracket Set: the sets still to merge after the running one. + pub batch: std::collections::VecDeque<Vec<PhotoId>>, } impl MergeState { @@ -146,9 +146,17 @@ fn spawn(app: &LightcraftApp, options: &MergeDialog, ids: &[PhotoId], preview: b Ok(MergeTask { preview, job, options: options.clone(), progress, cancel, rx, guard: None }) } -/// Open the merge dialog for the selection. +/// The options of the last merge of kind `command` started (kept in the library preferences), or +/// the defaults the first time. Fields missing from an older or hand-edited prefs.json take their +/// defaults; options that don't parse at all fall back to the defaults. +pub fn last_options(app: &LightcraftApp, command: &str) -> MergeDialog { + let saved = app.session.last_merge.get(command).and_then(|v| serde_json::from_value::<MergeDialog>(v.clone()).ok()); + MergeDialog { command: command.into(), ..saved.unwrap_or_default() } +} + +/// Open the merge dialog for the selection, with the options of the last merge of this kind. pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } @@ -159,7 +167,7 @@ pub fn open(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { if let Some(t) = app.merge.preview_task.take() { t.cancel.store(true, Ordering::Relaxed); } - app.ui.dialog = Some(crate::state::Dialog::Merge { opts: MergeDialog::for_command(command) }); + app.ui.dialog = Some(crate::state::Dialog::Merge { opts: last_options(app, command) }); Ok(json!({"photos": app.merge.ids.len()})) } @@ -178,22 +186,50 @@ pub fn start_final(app: &mut LightcraftApp, opts: &MergeDialog) -> Result<Value, guard.progress(0, 1000); task.guard = Some(guard); app.merge.final_task = Some(task); - app.merge.last.insert(opts.command.clone(), opts.clone()); + app.session.last_merge.insert(opts.command.clone(), opts.params()); + if let Err(e) = app.session.save_prefs() { + log::warn!("merge: {e}"); + } Ok(json!({"started": true, "photos": ids.len()})) } /// Merge the selection without the dialog, with the options last used for `command` (the /// defaults the first time). pub fn start_last(app: &mut LightcraftApp, command: &str) -> Result<Value, String> { - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); if ids.len() < 2 { return Err("select at least 2 photos to merge".into()); } app.merge.ids = ids; - let opts = app.merge.last.get(command).cloned().unwrap_or_else(|| MergeDialog::for_command(command)); + let opts = last_options(app, command); start_final(app, &opts) } +/// HDR for Each Bracket Set (`merge.hdrBatch`): merge each bracket set among the photos in the background, one after +/// the other, with the options last used for HDR (the defaults the first time). +pub fn start_batch(app: &mut LightcraftApp, p: &Value) -> Result<Value, String> { + if app.merge.final_task.is_some() { + return Err("a merge is already running".into()); + } + let ids = app.session.targets(p).map_err(|e| e.to_string())?; + let sets = app.session.catalog.bracket_sets(&ids); + if sets.is_empty() { + return Err(lightcraft_engine::merge::NO_BRACKET_SETS.into()); + } + let n = sets.len(); + app.merge.batch = sets.into(); + next_in_batch(app)?; + Ok(json!({"started": true, "sets": n})) +} + +/// Start the next bracket set of a batch, if any. +fn next_in_batch(app: &mut LightcraftApp) -> Result<(), String> { + let Some(ids) = app.merge.batch.pop_front() else { return Ok(()) }; + app.merge.ids = ids; + let opts = last_options(app, "merge.hdr"); + start_final(app, &opts).map(|_| ()) +} + /// Per frame: keep the preview in sync with the dialog's options, collect finished jobs. pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { // preview for the open dialog @@ -269,15 +305,27 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { }); match r.map_err(lightcraft_engine::EngineError::Other).and_then(|out| app.session.finish_merge(&t.job, out)) { Ok(v) => { + let message = added_message(what, &v); app.merge.last_result = Some(v); - app.toast(ctx, crate::i18n::tr_format!("{what} merge added", what = what)); + app.toast(ctx, message); + } + Err(_) if t.cancel.load(Ordering::Relaxed) => { + // cancelling one merge of a batch stops the batch + app.merge.batch.clear(); + app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)) } - Err(_) if t.cancel.load(Ordering::Relaxed) => app.toast(ctx, crate::i18n::tr_format!("{what} merge cancelled", what = what)), Err(e) => { app.ui.status = e.to_string(); app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)); } } + // a failed set doesn't stop the others + while !app.merge.batch.is_empty() { + match next_in_batch(app) { + Ok(()) => break, + Err(e) => app.toast(ctx, crate::i18n::tr_format!("{what} merge failed: {e}", e = e, what = what)), + } + } } Err(_) => { let (f, stage) = t.progress.lock().map(|g| g.clone()).unwrap_or_default(); @@ -292,6 +340,27 @@ pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { } } +/// The message for a finished merge (`what`: "Panorama", …; `info`: its result): a panorama +/// smaller than its photos' resolution says why (issue #1455). +fn added_message(what: &str, info: &Value) -> String { + let size = |k: &str| info[k].as_f64().map(|mp| format!("{mp:.0} MP")); + match (info["limit"].as_str(), size("megapixels"), size("nativeMegapixels")) { + (Some("memory"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}: its full size, {full}, needs more memory than this computer has", + what = what, + mp = mp, + full = full + ), + (Some("size"), Some(mp), Some(full)) => crate::i18n::tr_format!( + "{what} merge added at {mp}, the largest a panorama can be (its full size is {full})", + what = what, + mp = mp, + full = full + ), + _ => crate::i18n::tr_format!("{what} merge added", what = what), + } +} + fn last_failed_matches(app: &LightcraftApp, opts: &MergeDialog) -> bool { app.merge.failed_options.as_ref() == Some(opts) } @@ -423,3 +492,25 @@ fn num(ui: &mut egui::Ui, spec: &lightcraft_develop::ControlSpec, v: &mut f64) - None => false, } } + +#[cfg(test)] +mod tests { + use super::*; + + /// Issue #1455: a panorama held to the computer's memory, or to the largest size, says so. + #[test] + fn a_smaller_panorama_says_why() { + let added = |info: Value| added_message("Panorama", &info); + assert_eq!( + added(json!({"limit": "memory", "megapixels": 166.2, "nativeMegapixels": 194.0})), + "Panorama merge added at 166 MP: its full size, 194 MP, needs more memory than this computer has" + ); + assert_eq!( + added(json!({"limit": "size", "megapixels": 512.0, "nativeMegapixels": 700.4})), + "Panorama merge added at 512 MP, the largest a panorama can be (its full size is 700 MP)" + ); + assert_eq!(added(json!({"limit": null, "megapixels": 90.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added(json!({"limit": "asked", "megapixels": 20.0, "nativeMegapixels": 90.0})), "Panorama merge added"); + assert_eq!(added_message("HDR", &json!({"ev": [0.0]})), "HDR merge added"); + } +} diff --git a/crates/ui-egui/src/panels/activity.rs b/crates/ui-egui/src/panels/activity.rs index 7b0d449c6..2e4137fda 100644 --- a/crates/ui-egui/src/panels/activity.rs +++ b/crates/ui-egui/src/panels/activity.rs @@ -130,6 +130,7 @@ fn row(ui: &mut egui::Ui, t: &Tokens, task: &TaskInfo, width: f32) -> bool { }); if cross { let (rect, resp) = ui.allocate_exact_size(vec2(CROSS, CROSS), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Cancel")); paint(ui.painter(), rect.shrink(2.0), Icon::Close, if resp.hovered() { t.text } else { t.icon }); register(ui.ctx(), format!("activity:cancel:{}", task.id), rect); clicked = resp.on_hover_text(tr("Cancel")).clicked(); diff --git a/crates/ui-egui/src/panels/bottombar.rs b/crates/ui-egui/src/panels/bottombar.rs index b986b8995..62cbe8968 100644 --- a/crates/ui-egui/src/panels/bottombar.rs +++ b/crates/ui-egui/src/panels/bottombar.rs @@ -62,9 +62,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } } ui.separator(); - ui.menu_button(crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr("Sort"), |ui| sort_menu(app, ui)); if compact_zoom && matches!(app.ui.view, ViewMode::Detail | ViewMode::Compare) { - ui.menu_button(crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| click_zoom_menu(app, ui)); + crate::widgets::menu_button(ui, crate::i18n::tr_format!("Click {}:1", app.ui.click_zoom / 100), |ui| { + click_zoom_menu(app, ui) + }); } }); } @@ -112,6 +114,15 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: let flag = active.as_ref().map(|p| p.flag).unwrap_or_default(); for (id, icon, f) in [("unflag", Icon::Circle, Flag::None), ("pick", Icon::FlagPick, Flag::Pick), ("reject", Icon::FlagReject, Flag::Reject)] { let (r, resp) = pill_ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce_selected( + &resp, + crate::i18n::tr(match f { + Flag::None => "Unflagged", + Flag::Pick => "Pick", + Flag::Reject => "Reject", + }), + flag == f, + ); register(pill_ui.ctx(), format!("flag:{id}"), r); let on = flag == f && f != Flag::None; let c = if on { @@ -134,6 +145,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let copy_r = Rect::from_min_size(pos2(pill.right() + 10.0, pill.top()), vec2(copy_width, 30.0)); let cresp = ui.interact(copy_r, egui::Id::new("copy-settings"), Sense::click()); + crate::widgets::announce(&cresp, label.to_string()); register(ui.ctx(), "button:copySettings", copy_r); ui.painter().rect_filled(copy_r, 15.0, if cresp.hovered() { t.hover } else { t.canvas }); ui.painter().text(copy_r.center(), Align2::CENTER_CENTER, label, t.font(13.0), if active.is_some() { t.text_label } else { t.text_disabled }); @@ -144,6 +156,7 @@ fn centre(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, from: f32, to: } let gear_r = Rect::from_min_size(pos2(copy_r.right() + 4.0, pill.top()), vec2(30.0, 30.0)); let gresp = ui.interact(gear_r, egui::Id::new("copy-gear"), Sense::click()); + crate::widgets::announce(&gresp, crate::i18n::tr("Copy Settings…")); register(ui.ctx(), "icon:copyGear", gear_r); ui.painter().rect_filled(gear_r, 15.0, if gresp.hovered() { t.hover } else { t.canvas }); paint(ui.painter(), gear_r.shrink(7.0), Icon::Gear, t.icon); @@ -227,17 +240,19 @@ fn right_side(app: &mut LightcraftApp, ui: &mut egui::Ui, full: Rect, compact_zo } else { // thumbnail size slider let (r, resp) = child.allocate_exact_size(vec2(110.0, 20.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Thumbnail size")); register(child.ctx(), "slider:thumbSize", r); let p = child.painter(); let y = r.center().y; p.line_segment([pos2(r.left(), y), pos2(r.right(), y)], egui::Stroke::new(2.0, t.track)); - let f = (app.ui.thumb_size - 90.0) / (480.0 - 90.0); + let (min, max) = (crate::state::THUMB_SIZE_MIN, crate::state::THUMB_SIZE_MAX); + let f = (app.ui.thumb_size - min) / (max - min); let x = r.left() + f * r.width(); p.rect_filled(Rect::from_center_size(pos2(x, y), vec2(3.0, 14.0)), 1.0, t.thumb_hover); if let Some(pp) = resp.interact_pointer_pos() && (resp.dragged() || resp.clicked()) { - app.ui.thumb_size = 90.0 + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (480.0 - 90.0); + app.ui.thumb_size = min + ((pp.x - r.left()) / r.width()).clamp(0.0, 1.0) * (max - min); } } if child.min_rect().width() > 0.0 { child.min_rect().left() } else { full.right() } @@ -272,6 +287,14 @@ fn sort_menu(app: &mut LightcraftApp, ui: &mut egui::Ui) { if cur.key == Random && ui.button(crate::i18n::tr("Reshuffle")).clicked() { let _ = app.run("library.shuffle", json!({})); } + // a view that holds its photos shows what matches now when asked to + if app.session.hold_view { + let r = ui.button(crate::i18n::tr("Refresh View")); + crate::widgets::register(ui.ctx(), "button:refreshView", r.rect); + if r.clicked() { + let _ = app.run("library.refresh", json!({})); + } + } ui.separator(); // a shuffle has no direction worth choosing ui.add_enabled_ui(cur.key != Random, |ui| { diff --git a/crates/ui-egui/src/panels/chips.rs b/crates/ui-egui/src/panels/chips.rs index d1acd15a5..30f03971b 100644 --- a/crates/ui-egui/src/panels/chips.rs +++ b/crates/ui-egui/src/panels/chips.rs @@ -18,7 +18,8 @@ const TAIL: f32 = 170.0; /// "14 of 120 photos" when a filter narrows the source, else "14 photos". pub fn count_text(matching: usize, total: Option<usize>, filtering: bool) -> String { match total { - Some(t) if filtering && t != matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t), + // (a held view can show more than now match its source: no "3 of 2") + Some(t) if filtering && t > matching => crate::i18n::tr_format!("{matching} of {t} photos", matching = matching, t = t), _ => crate::i18n::tr_format!("{matching} photos", matching = matching), } } @@ -101,6 +102,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, chips: &[FilterChip]) { ui.painter().with_clip_rect(text_r).text(pos2(r.left() + 10.0, r.center().y), egui::Align2::LEFT_CENTER, &label, font.clone(), t.text); let xr = Rect::from_center_size(pos2(r.right() - 12.0, r.center().y), vec2(18.0, 18.0)); let resp = ui.interact(xr, egui::Id::new(("filter-chip-x", i)), Sense::click()).on_hover_text(crate::i18n::tr("Remove this filter")); + crate::widgets::announce(&resp, format!("{}: {label}", crate::i18n::tr("Remove this filter"))); register(ui.ctx(), format!("chip:{i}"), xr); let col = if resp.hovered() { t.text } else { t.text_dim }; let m = 3.5; diff --git a/crates/ui-egui/src/panels/compare.rs b/crates/ui-egui/src/panels/compare.rs index 4aacfaead..6c1b5ba18 100644 --- a/crates/ui-egui/src/panels/compare.rs +++ b/crates/ui-egui/src/panels/compare.rs @@ -197,6 +197,7 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo let t = Tokens::get(ui.ctx()); let ppp = ui.ctx().pixels_per_point(); let resp = ui.interact(area, egui::Id::new(("cull-tile", slot_index(slot), id.0)), Sense::click_and_drag()); + crate::widgets::announce(&resp, app.session.catalog.photo(id).map(|p| p.file_name.clone()).unwrap_or_default()); let Some(photo) = app.session.catalog.photo(id).cloned() else { return (area, resp) }; let caption_h = 26.0; let img_area = Rect::from_min_max(area.min, pos2(area.right(), area.bottom() - caption_h)); @@ -279,6 +280,9 @@ fn photo_tile(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, slot: Slo if photo.flag == Flag::Reject { p.rect_filled(display, 0.0, Color32::from_black_alpha(110)); } + if app.ui.settings.focus_point { + super::detail::draw_focus_point(&p, display, &photo, &format!("compare:focusPoint:{}", id.0)); + } let active = app.session.selection.active == Some(id); if active { p.rect_stroke(display.intersect(img_area.shrink(1.0)), 0.0, Stroke::new(2.0, Color32::WHITE), StrokeKind::Outside); @@ -550,6 +554,7 @@ pub fn show_survey(app: &mut LightcraftApp, ui: &mut egui::Ui) { if resp.hovered() && photos.len() > 1 { let xr = Rect::from_min_size(pos2(img.right() - 26.0, img.top() + 6.0), vec2(20.0, 20.0)); let xresp = ui.interact(xr, egui::Id::new(("survey-x", id.0)), Sense::click()).on_hover_text(crate::i18n::tr("Remove from survey")); + crate::widgets::announce(&xresp, crate::i18n::tr("Remove from survey")); ui.painter().circle_filled(xr.center(), 10.0, Color32::from_black_alpha(if xresp.hovered() { 230 } else { 160 })); paint(ui.painter(), xr.shrink(4.0), Icon::Close, t.text); if xresp.clicked() { diff --git a/crates/ui-egui/src/panels/denoise.rs b/crates/ui-egui/src/panels/denoise.rs index 75488f76b..437a378a7 100644 --- a/crates/ui-egui/src/panels/denoise.rs +++ b/crates/ui-egui/src/panels/denoise.rs @@ -371,7 +371,7 @@ fn work_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, list: &V ui.label(RichText::new(crate::i18n::tr("Limit")).color(t.text_label)); let current = list["cacheGb"].as_u64().unwrap_or(20) as u32; let mut choice = current; - egui::ComboBox::from_id_salt("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { + crate::widgets::combo_box("denoise-cache-limit").selected_text(tr_format!("{size} GB", size = current)).show_ui(ui, |ui| { for gb in CACHE_LIMITS { ui.selectable_value(&mut choice, gb, tr_format!("{size} GB", size = gb)); } @@ -606,9 +606,7 @@ pub(super) fn mb(bytes: Option<u64>) -> String { pub(super) fn sentence(s: &str) -> String { let s = s.trim(); - let mut c = s.chars(); - let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default(); - out.push_str(c.as_str()); + let mut out = crate::i18n::upper_first(s); if !out.is_empty() && !out.ends_with(['.', '!', '?']) { out.push('.'); } diff --git a/crates/ui-egui/src/panels/detail.rs b/crates/ui-egui/src/panels/detail.rs index e7b162fa2..f3c76e686 100644 --- a/crates/ui-egui/src/panels/detail.rs +++ b/crates/ui-egui/src/panels/detail.rs @@ -10,7 +10,7 @@ use serde_json::json; use crate::LightcraftApp; use crate::render::Slot; -use crate::state::{BeforeAfter, RightPanel, Zoom}; +use crate::state::{BeforeAfter, RightPanel, ToolOverlay, Zoom}; use crate::theme::Tokens; use crate::widgets::register; @@ -19,6 +19,9 @@ use crate::widgets::register; pub enum Gesture { Brush { points: Vec<Point>, + /// Pen pressure (0..1) at each point; empty when no pen pressure was reported (a mouse) or the + /// brush's Pen Pressure switch is off. + pressure: Vec<f32>, }, Spot { points: Vec<Point>, @@ -122,7 +125,16 @@ pub(crate) fn fit_rect(area: Rect, aspect: f32, zoom: Zoom, img_px: [usize; 2], }; let pan = bounded_pan(area, vec2(w, h), pan); let c = pos2(area.center().x - (pan.0 - 0.5) * w, area.center().y - (pan.1 - 0.5) * h); - Rect::from_center_size(c, vec2(w, h)) + let rect = Rect::from_center_size(c, vec2(w, h)); + // At 100 % the texture maps texel for texel to screen pixels, so its corner must sit on a whole + // physical pixel: half a pixel off and the GPU's bilinear filter blurs the view (an odd-width + // canvas on a 2x display does this). Other zooms resample anyway, and a pinch must not jump. + let one_to_one = ppp.is_finite() && ppp > 0.0 && (w * ppp - img_px[0] as f32).abs() < 0.5; + if matches!(zoom, Zoom::Percent(_)) && one_to_one { + let snap = |v: f32| (v * ppp).round() / ppp; + return Rect::from_min_size(pos2(snap(rect.min.x), snap(rect.min.y)), rect.size()); + } + rect } /// Fit a decoded image inside the frame calculated from catalog metadata. Unsupported RAWs can @@ -236,6 +248,63 @@ pub(crate) fn output_px(frame: &Frame) -> [usize; 2] { [px(w), px(h)] } +/// The camera's focus point of `photo` in the displayed frame (its crop, rotation and flips), as the +/// normalized box over the drawn image: `None` when the photo has none or it is cropped away. +pub(crate) fn focus_box(photo: &lightcraft_catalog::Photo) -> Option<lightcraft_geom::Rect> { + let f = photo.meta.focus.as_ref()?; + let d = &photo.develop; + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, d, true, photo.embedded_lens.as_ref()); + let [w, h] = output_px(&frame); + let to_out = frame.out_to_norm(w, h).inverse()?; + // stored on the upright photo: the user's Rotate / Flip on top, then the crop + let r = d.orientation.map_norm_rect(f.rect); + let corners = [Point::new(r.x0, r.y0), Point::new(r.x1, r.y0), Point::new(r.x0, r.y1), Point::new(r.x1, r.y1)].map(|c| { + let q = to_out.apply(c); + (q.x / w as f64, q.y / h as f64) + }); + let xs = corners.map(|c| c.0); + let ys = corners.map(|c| c.1); + let out = lightcraft_geom::Rect { + x0: xs.iter().copied().fold(f64::INFINITY, f64::min), + y0: ys.iter().copied().fold(f64::INFINITY, f64::min), + x1: xs.iter().copied().fold(f64::NEG_INFINITY, f64::max), + y1: ys.iter().copied().fold(f64::NEG_INFINITY, f64::max), + }; + if ![out.x0, out.y0, out.x1, out.y1].iter().all(|v| v.is_finite()) { + return None; + } + // its centre must be in the frame; the box may reach past the edge (it is clipped when drawn) + let (cx, cy) = ((out.x0 + out.x1) / 2.0, (out.y0 + out.y1) / 2.0); + ((0.0..=1.0).contains(&cx) && (0.0..=1.0).contains(&cy)).then_some(out) +} + +/// Where Toggle Zoom centres the view on the photo's focus point (`app.ui.pan`), if it has one in frame. +pub(crate) fn focus_pan(app: &LightcraftApp, id: PhotoId) -> Option<(f32, f32)> { + let r = focus_box(app.session.catalog.photo(id)?)?; + Some((((r.x0 + r.x1) / 2.0) as f32, ((r.y0 + r.y1) / 2.0) as f32)) +} + +/// Draw the camera's focus box over a photo drawn in `img` (View ▸ Show Focus Point): green corner +/// brackets with a dark keyline, the way cameras show their AF frame, never smaller than 14 pt. +/// `key` names it for tests and the control channel (`canvas:focusPoint`, `compare:focusPoint:<id>`). +pub(crate) fn draw_focus_point(p: &egui::Painter, img: Rect, photo: &lightcraft_catalog::Photo, key: &str) { + let Some(r) = focus_box(photo) else { return }; + let at = |x: f64, y: f64| pos2(img.left() + x as f32 * img.width(), img.top() + y as f32 * img.height()); + let b = Rect::from_two_pos(at(r.x0, r.y0), at(r.x1, r.y1)); + let b = Rect::from_center_size(b.center(), b.size().max(vec2(14.0, 14.0))); + let arm = (b.width().min(b.height()) * 0.3).max(5.0); + let green = Color32::from_rgb(90, 230, 110); + for (c, dx, dy) in [(b.left_top(), 1.0, 1.0), (b.right_top(), -1.0, 1.0), (b.left_bottom(), 1.0, -1.0), (b.right_bottom(), -1.0, -1.0)] { + let pts = vec![c + vec2(dx * arm, 0.0), c, c + vec2(0.0, dy * arm)]; + p.add(egui::Shape::line(pts.clone(), Stroke::new(4.0, Color32::from_black_alpha(170)))); + p.add(egui::Shape::line(pts, Stroke::new(2.0, green))); + } + let c = b.center(); + p.circle_filled(c, 2.5, Color32::from_black_alpha(170)); + p.circle_filled(c, 1.5, green); + register(p.ctx(), key, b); +} + /// Ease the loupe rect toward `target` while a click-zoom animation runs; otherwise follow it exactly. fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { let t = if *anim { 0.22 } else { 0.0 }; @@ -249,7 +318,7 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { r } -/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask +/// Whether an overlay can be rendered on a region tile without changing what the tile means. Mask (one or all) /// overlays are evaluated from the same mask alpha as the full render; Point Color range and Visualize /// HDR act pixel by pixel (the HDR rendition is rendered for the same window); Visualize Spots' high /// pass reads about 3σ = 1.8 % of the long edge, inside the window's margin @@ -257,10 +326,7 @@ fn animated_rect(ctx: &egui::Context, anim: &mut bool, target: Rect) -> Rect { pub(crate) fn overlay_supports_window(overlay: lightcraft_pipeline::Overlay) -> bool { use lightcraft_pipeline::Overlay as O; match overlay { - O::None | O::Mask { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, - // Show All Masks reads every mask's alpha in one GPU readback for the whole frame; not - // tested on a window yet, so it keeps the whole-frame render - O::AllMasks { .. } => false, + O::None | O::Mask { .. } | O::AllMasks { .. } | O::PointColorRange(_) | O::HdrRange | O::Spots(_) => true, } } @@ -382,7 +448,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { filmstrip(app, ui, Rect::from_min_max(pos2(full.left(), canvas.bottom()), full.max)); } let Some(id) = app.session.active() else { - super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, canvas); + } else { + super::empty_message(ui, canvas, "No photo selected", "Choose a photo in the grid or filmstrip"); + } return; }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; @@ -436,11 +506,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // tool's overlays need the frame's own geometry. let display_aspect = visible_texture_aspect.filter(|a| !crop_tool && (a / aspect - 1.0).abs() > 0.02).unwrap_or(aspect); let resp = ui.interact(canvas, egui::Id::new("loupe"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Photo")); let mut target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); let mut img_rect = animated_rect(ui.ctx(), &mut app.ui.zoom_anim, target_rect); + let in_pane = |pane: Rect, image: Rect| image.translate(pane.center() - main_area.center()); let mut navigating = false; for &view_area in &areas { - let view_img = if view_area == main_area { img_rect } else { fit_rect(view_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan) }; + let view_img = in_pane(view_area, img_rect); if navigate_gesture(app, ui, &resp, view_area, view_img, native) { navigating = true; target_rect = fit_rect(main_area, display_aspect, app.ui.zoom, native, ppp, app.ui.pan); @@ -448,16 +520,30 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { break; } } + let before_img = in_pane(areas[0], img_rect); + let before_target = in_pane(areas[0], target_rect); app.image_rect = Some(img_rect); // request renders: the loupe at display resolution (drafts during drags) let interacting = app.session.interaction.is_some(); - let scale = if interacting { 0.6 } else { 1.0 }; + let overlay = view_overlay(app, &d); + // a drag drafts at full size where the GPU draws the view (by the GPU's own rule, `handles`; + // `ready` first: `available` would wait for a device still being created) + let gpu = interacting + && lightcraft_engine::gpu::handles( + lightcraft_pipeline::OutputDepth::U8, + app.ui.soft_proof && lightcraft_pipeline::proof_profile(app.ui.proof.profile).is_some(), + overlay, + ) + && lightcraft_engine::gpu::ready() + && lightcraft_engine::gpu::available(); + let shown = app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id && t.quick.is_none()).map(|t| (t.key, t.ms)); + let drag = app.session.interaction.as_ref().map(|i| (i.photo, i.label.as_str())); + let draft = app.drag_scale.scale(drag, gpu, shown.map(|(key, _)| key)); // render at the final size: a click-zoom animation only changes how the result is drawn let native_long = native[0].max(native[1]); let drawn_long = target_rect.width().max(target_rect.height()) * ppp; // the whole frame at about canvas size; the zoomed part of it is a window render (below) let look = d.hash64(); - let overlay = view_overlay(app, &d); // Diagnostic views that need whole-frame context, or reads too far for one window, still // render the whole frame at the size it is drawn. Mask overlays and soft proofing (both act // pixel by pixel) are window-safe. @@ -466,13 +552,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { canvas_long: canvas.width().max(canvas.height()) * ppp, native_long, texture_side, - draft_scale: scale, + draft, windows: overlay_supports_window(overlay), }; let mut plan = crate::region::plan(&app.ui.settings, sizes); if plan.window_edge.is_some_and(|edge| app.window_refused == Some((id, look, edge))) { plan = crate::region::plan(&app.ui.settings, crate::region::ViewSizes { windows: false, ..sizes }); } + // time the full-size drafts where they are what shows: under a window the whole-frame draft + // is small anyway, and the frame's time is the window's upload + if let Some((key, ms)) = shown.filter(|_| plan.window_edge.is_none()) { + app.drag_scale.observe(key, ms, app.perf.host_frame_ms); + } // a pinch changes the zoom every frame: keep the sizes of before it until it is quiet let now = ui.input(|i| i.time); let plan = app.size_hold.apply(id, now, navigating, plan); @@ -563,11 +654,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // (slot, before side?, rect to draw in, rect it is requested for, area whose pixels show) let mut window_views: Vec<WindowView> = Vec::new(); if window_ctx.frame_edge.is_some() { - let before_rect = |r: Rect, area: Rect| WindowView { slot: Slot::RegionBefore, before: true, img: r, target: r, visible: area }; let after = WindowView { slot: Slot::Region, before: false, img: img_rect, target: target_rect, visible: main_area }; if split { - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); - window_views.push(before_rect(br, areas[0])); + window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: before_img, target: before_target, visible: areas[0] }); window_views.push(after); } else if show_before { window_views.push(WindowView { slot: Slot::RegionBefore, before: true, img: img_rect, target: target_rect, visible: canvas }); @@ -624,7 +713,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if split { // each view stays in its pane: zoomed in, a photo reaches past it (and would cover the // other view's margins with its whole-frame render, magnified and blurred) - let br = fit_rect(areas[0], aspect, app.ui.zoom, native, ppp, app.ui.pan); + let br = before_img; draw(Slot::Before, br, areas[0]); shown = draw(Slot::Main, img_rect, main_area); // the labels under each photo, or under its pane when the photo reaches past it @@ -701,6 +790,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if app.ui.show_clipping && !show_before && !fullscreen { clipping_overlay(app, &p, img_rect); } + // View ▸ Loupe Overlay ▸ Layout Image, over the photo (the After view in a before/after) + super::layout_overlay::draw(app, &p, img_rect); register(ui.ctx(), "canvas:image", img_rect); let map = CanvasMap::new(&frame, img_rect); info_overlay(app, &p, canvas, &photo); @@ -734,6 +825,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { None => {} } } + if app.ui.settings.focus_point && !crop_tool { + draw_focus_point(&p.with_clip_rect(img_rect.intersect(canvas)), img_rect, &photo, "canvas:focusPoint"); + } // a fine grid while a transform (geometry) slider is dragged, to judge verticals if app.ui.dragging_control.as_deref().is_some_and(|c| c.starts_with("geometry.")) { let n = 12; @@ -762,14 +856,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // side by side / stacked: Before shows the same frame, so a click in its view (white // balance, colour samples, targeted adjustment) means the same point of the photo. // Zoomed in, a view's image reaches past its pane: what counts is the pane clicked. + // A click-zoom there centres both views on the point clicked (#1742). let pointer = resp.interact_pointer_pos().or(resp.hover_pos()); - let map = match (split, areas.first(), pointer) { - (true, Some(before), Some(q)) if before.contains(q) => { - CanvasMap::new(&frame, fit_rect(*before, aspect, app.ui.zoom, native, ppp, app.ui.pan)) - } - _ => map, + let (map, img, pane) = match (split, areas.first(), pointer) { + (true, Some(before), Some(q)) if before.contains(q) => (CanvasMap::new(&frame, before_img), before_img, *before), + _ => (map, img_rect, main_area), }; - general_interaction(app, ui, &resp, &map, img_rect, main_area, native, aspect) + general_interaction(app, ui, &resp, &map, img, pane, native, aspect) } } } @@ -792,7 +885,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // the render failed while a stand-in is up: what is on screen is all there will be (also while // a library change retries it) let failed = (!rendering - && !(show_before && !split) + && (!show_before || split) && !matches!(shown, "render" | "none" | "hover") && (!app.renderer.is_pending(Slot::Main) || app.renderer.retrying(Slot::Main))) .then(|| app.renderer.failure_of(Slot::Main, id)) @@ -812,7 +905,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { preview_only_pill(ui, canvas, why); } resp.context_menu(|ui| { - ui.menu_button(crate::i18n::tr("Zoom"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Zoom"), |ui| { for (label, cmd) in [("Fit", "view.zoomFit"), ("100%", "view.zoom100"), ("Zoom In", "view.zoomIn"), ("Zoom Out", "view.zoomOut")] { if ui.button(crate::i18n::tr(label)).clicked() { let _ = app.run(cmd, json!({})); @@ -862,7 +955,9 @@ fn info_overlay(app: &LightcraftApp, p: &egui::Painter, canvas: Rect, photo: &li let mut y = canvas.top() + 12.0; for (i, l) in lines.iter().enumerate() { let font = if i == 0 { t.semibold(15.0) } else { t.font(12.5) }; - let g = p.layout_no_wrap(l.clone(), font, Color32::WHITE); + // laid out uncoloured: `galley` only fills placeholder text with its colour, so a white + // layout made the shadow a second white copy (#1690) + let g = p.layout_no_wrap(l.clone(), font, Color32::PLACEHOLDER); let at = pos2(canvas.left() + 14.0, y); // a soft shadow keeps the text readable on bright photos p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); @@ -930,6 +1025,7 @@ fn region_overlay( for (h, (fx, fy, cursor)) in REGION_HANDLES.iter().enumerate() { let c = pos2(rect.left() + fx * rect.width(), rect.top() + fy * rect.height()); let resp = ui.interact(Rect::from_center_size(c, vec2(14.0, 14.0)), egui::Id::new(("region-handle", index, h)), Sense::drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Resize region")); if resp.hovered() || resp.dragged() { ui.ctx().set_cursor_icon(*cursor); } @@ -974,6 +1070,7 @@ fn region_overlay( if dragging.is_none() && live.is_none() && pointer.is_some_and(|h| rect.expand(3.0).contains(h)) { let xr = Rect::from_center_size(pos2(rect.right() - 13.0, rect.top() + 13.0), vec2(16.0, 16.0)); let resp = ui.interact(xr, egui::Id::new(("region-x", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Remove region")); register(ui.ctx(), format!("regionRemove:{index}"), xr); let fill = Color32::from_black_alpha(if resp.hovered() { 235 } else { 190 }); p.rect_filled(xr, 2.0, fill); @@ -1023,8 +1120,9 @@ fn region_overlay( if is_face && live.is_none() { // a click on the label opens the name box, filled with the name or the guess let resp = ui.interact(label, egui::Id::new(("region-label", index)), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Click to name this face")); register(ui.ctx(), format!("regionLabel:{index}"), label); - if resp.on_hover_text("Click to name this face").clicked() { + if resp.on_hover_text(crate::i18n::tr("Click to name this face")).clicked() { let start = r.name.clone().or_else(|| hint.and_then(|h| h.suggestion.as_ref().map(|(n, _)| n.clone()))).unwrap_or_default(); *editing = Some(crate::state::NameEdit { photo: photo.id.0, index, text: start, fresh: true }); } @@ -1060,6 +1158,7 @@ fn filter_pill(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect) { let w = (g.size().x + 40.0).min((canvas.width() - 2.0 * inset).max(60.0)); let r = Rect::from_min_size(canvas.min + vec2(inset, inset), vec2(w, 20.0)); let resp = ui.interact(r, egui::Id::new("loupe-filter-pill"), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Filters")); register(ui.ctx(), "loupe:filters", r); p.rect_filled(r, 10.0, Color32::from_black_alpha(if resp.hovered() { 225 } else { 185 })); let text_clip = Rect::from_min_max(r.min, pos2(r.right() - 24.0, r.max.y)); @@ -1125,6 +1224,7 @@ fn navigator(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, img: Rect p.rect_stroke(view.expand(1.5), 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); register(ui.ctx(), "canvas:navigator", frame); let resp = ui.interact(frame.expand(5.0), egui::Id::new("navigator"), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Navigator")); if (resp.clicked() || resp.dragged()) && let Some(q) = resp.interact_pointer_pos() { @@ -1430,6 +1530,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons p.rect_filled(Rect::from_center_size(*h, vec2(s, 3.0)), 0.0, Color32::WHITE); p.rect_filled(Rect::from_center_size(*h, vec2(3.0, s)), 0.0, Color32::WHITE); } + // the keyboard: each handle (and the box itself) a stand-in Tab reaches and the arrows move + if crop_keys(app, ui, map, &handles, &pts, d, frame) { + return; + } let inside = |q: Pos2| { let n = map.norm(q); let s = to_straight(n, d.crop.geometry.angle, frame); @@ -1483,8 +1587,10 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = app.run("tool.done", json!({})); return; } + // egui reports a drag only once the pointer has moved a few px: the zone is where the button + // went down, where the cursor showed it, not where the pointer is by then if resp.drag_started() - && let Some(q) = resp.interact_pointer_pos() + && let Some(q) = ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()) { if let Err(e) = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)) { @@ -1539,6 +1645,64 @@ fn crop_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let _ = id; } +/// The crop handles' names for screen readers, in `crop.drag`'s numbering: 0..3 corners, 4..7 +/// edges (top, right, bottom, left), 8 the whole box. +const CROP_HANDLE_NAMES: [&str; 9] = [ + "Crop top-left corner", + "Crop top-right corner", + "Crop bottom-right corner", + "Crop bottom-left corner", + "Crop top edge", + "Crop right edge", + "Crop bottom edge", + "Crop left edge", + "Crop box", +]; + +/// Keyboard stand-ins for the crop handles: an arrow press drags the focused one by a step (one +/// undo step each), as a pointer drag from its place would. Return applies the crop. True when the +/// frame is over (the crop was applied). +fn crop_keys( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + map: &CanvasMap, + handles: &[Pos2], + pts: &[Pos2], + d: &DevelopSettings, + frame: &Frame, +) -> bool { + let r = d.crop.geometry.rect; + let centre = if pts.len() == 4 { pts.iter().fold(Pos2::ZERO, |acc, q| acc + q.to_vec2() / 4.0) } else { return false }; + let pct = |v: f64| if v.is_finite() { (v * 100.0).round() as i64 } else { 0 }; + let value = format!("left {}%, top {}%, right {}%, bottom {}%", pct(r.x0), pct(r.y0), pct(r.x1), pct(r.y1)); + for (i, name) in CROP_HANDLE_NAMES.iter().enumerate() { + let Some(at) = handles.get(i).copied().or_else(|| (i == 8).then_some(centre)) else { continue }; + let keys = crate::canvas_keys::handle(ui, &format!("crop:{i}"), crate::i18n::tr(name), value.clone(), at); + if keys.activated { + let _ = app.run("tool.done", json!({})); + return true; + } + let Some((dx, dy)) = keys.delta else { continue }; + // the drag runs in the straightened crop frame, as a pointer drag from the handle would; + // the arrows follow the screen + let angle = d.crop.geometry.angle; + let to_q = at + vec2(dx as f32 * map.rect.width(), dy as f32 * map.rect.height()); + let (from, to) = (to_straight(map.norm(at), angle, frame), to_straight(map.norm(to_q), angle, frame)); + let started = app.run("develop.beginInteraction", json!({"label": "Crop"})).and_then(|_| app.begin_crop().map(|_| serde_json::Value::Null)); + if let Err(e) = started { + app.ui.status = e; + let _ = app.session.cancel_interaction(); + return false; + } + let _ = app.run( + "crop.drag", + json!({"handle": i, "from": [from.x, from.y], "to": [to.x, to.y], "start": [r.x0, r.y0, r.x1, r.y1], "keepRatio": false}), + ); + let _ = app.run("develop.endInteraction", json!({})); + } + false +} + /// The crop angle as the rotation readout shows it: like the Straighten value, in degrees. pub(crate) fn crop_angle_label(angle: f64) -> String { let shown = @@ -1848,6 +2012,12 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons let p = &ui.painter_at(clip); // (mask, component, handle, screen position) of everything that can be grabbed let mut grips: Vec<(u32, usize, u8, Pos2)> = Vec::new(); + // pins follow Show Pins and the Tool Overlay mode (Shift+H); Auto hides them while brushing + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + let mut pins = p.clone(); + if mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Brush { .. })) { + pins.set_invisible(); + } for m in &d.masks { let sel = Some(m.id) == active; for (ci, c) in m.components.iter().enumerate() { @@ -1900,15 +2070,17 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons _ => {} } if app.ui.mask_pins + && mode.shows(sel, live) && let Some(at) = component_pin(&c.shape) { let q = map.screen(at); - pin(p, q, sel); + pin(&pins, q, sel); register(ui.ctx(), format!("maskPin:{}:{ci}", m.id), Rect::from_center_size(q, vec2(14.0, 14.0))); grips.push((m.id, ci, 0, q)); } } } + mask_keys(app, ui, map, d, &grips); // Object selections (SAM 3) of the selected mask: each click, green to include, red to exclude // (clicks still on their way to the model included) let pending = app.session.segmenter.pending_clicks().cloned(); @@ -1971,16 +2143,24 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons && (resp.drag_started() || resp.dragged() || resp.clicked()) { let n = map.norm(q); + let force = if app.ui.brush_pressure { ui.input(pen_force) } else { None }; match &mut app.gesture { // a held, unmoving pointer records nothing: coverage builds by passes, not time (#518) - Some(Gesture::Brush { points }) if points.last() == Some(&n) => {} - Some(Gesture::Brush { points }) => points.push(n), - _ => app.gesture = Some(Gesture::Brush { points: vec![n] }), + Some(Gesture::Brush { points, .. }) if points.last() == Some(&n) => {} + Some(Gesture::Brush { points, pressure }) => { + record_pressure(pressure, points.len(), force); + points.push(n); + } + _ => { + let mut pressure = Vec::new(); + record_pressure(&mut pressure, 0, force); + app.gesture = Some(Gesture::Brush { points: vec![n], pressure }); + } } } // the stroke being painted (the render shows it once it's committed), with the committed // stroke's dab spacing, feather and flow (issue #517) - if let Some(Gesture::Brush { points }) = &app.gesture { + if let Some(Gesture::Brush { points, pressure }) = &app.gesture { let screen: Vec<Pos2> = points.iter().map(|q| map.screen(*q)).collect(); let opacity = if app.ui.mask_overlay && app.ui.mask_overlay_opacity > 0.0 { app.ui.mask_overlay_opacity / 100.0 } else { 110.0 / 255.0 }; let params = crate::brush_live::LiveParams { @@ -1993,21 +2173,24 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons color: app.ui.mask_overlay_color, opacity: opacity.clamp(0.0, 1.0), }; - crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen); + crate::brush_live::paint(&mut app.brush_live, ui.ctx(), p, params, &screen, pressure); } else { app.brush_live = None; } if (resp.drag_stopped() || resp.clicked()) - && let Some(Gesture::Brush { points }) = app.gesture.take() + && let Some(Gesture::Brush { points, pressure }) = app.gesture.take() { app.brush_live = None; let pts: Vec<[f64; 2]> = points.iter().map(|q| [q.x, q.y]).collect(); // holding ⌥ (Alt) paints with the other mode: Erase while adding, Add while erasing - let erase = app.ui.brush_erase != resp.ctx.input(|i| i.modifiers.alt); + let (alt, shift) = resp.ctx.input(|i| (i.modifiers.alt, i.modifiers.shift)); + let erase = app.ui.brush_erase != alt; + // a ⇧-click draws a straight line from where the last stroke ended, as in Lightroom + let connect = shift && resp.clicked() && pts.len() == 1; let _ = app.run( "mask.brushStroke", json!({"points": pts, "size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, - "erase": erase, "autoMask": app.ui.brush_auto_mask}), + "erase": erase, "autoMask": app.ui.brush_auto_mask, "pressure": pressure, "connect": connect}), ); } return; @@ -2070,6 +2253,48 @@ fn mask_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respons } } +/// A mask handle's name for screen readers: `handle` 0 is the component's pin, 1..5 a radial +/// gradient's right / bottom / left / top edge and rotation, 1..2 a linear gradient's start and end. +fn mask_handle_name(mask: &str, shape: &MaskShape, handle: u8) -> String { + let part = match (shape, handle) { + (_, 0) => "pin", + (MaskShape::Radial { .. }, 1) => "right edge", + (MaskShape::Radial { .. }, 2) => "bottom edge", + (MaskShape::Radial { .. }, 3) => "left edge", + (MaskShape::Radial { .. }, 4) => "top edge", + (MaskShape::Radial { .. }, _) => "rotation", + (MaskShape::Linear { .. }, 1) => "start", + (MaskShape::Linear { .. }, _) => "end", + _ => "handle", + }; + format!("{mask} {}", crate::i18n::tr(part)) +} + +/// Keyboard stand-ins for the mask handles and pins: an arrow press moves the focused one by a +/// step (one undo step), as dragging it would; Return on a pin selects its mask. +fn mask_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(u32, usize, u8, Pos2)]) { + for &(mid, ci, handle, q) in grips { + let Some(m) = d.masks.iter().find(|m| m.id == mid) else { continue }; + let Some(shape) = m.components.get(ci).map(|c| &c.shape) else { continue }; + let at = map.norm(q); + let mask_name = if m.name.trim().is_empty() { format!("{} {mid}", crate::i18n::tr("Mask")) } else { m.name.clone() }; + let name = mask_handle_name(&mask_name, shape, handle); + let keys = crate::canvas_keys::handle(ui, &format!("mask:{mid}:{ci}:{handle}"), &name, crate::canvas_keys::position_text(at.x, at.y), q); + if keys.activated && app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let Some((dx, dy)) = keys.delta else { continue }; + if app.session.active_mask != Some(mid) { + let _ = app.run("mask.select", json!({"id": mid})); + } + let to = Point::new(at.x + dx, at.y + dy); + let new_shape = moved_shape(shape, handle, Point::new(dx, dy), to, map, false); + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Mask"})); + let _ = app.run("mask.update", json!({"id": mid, "component": ci, "shape": new_shape})); + let _ = app.run("develop.endInteraction", json!({})); + } +} + /// Long-edge units → normalized units for x and y. fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { // derive from the mapping: 1 normalized unit in x and y in screen points @@ -2080,6 +2305,15 @@ fn frame_long_norm(map: &CanvasMap) -> (f64, f64) { (l / x.max(1e-9), l / y.max(1e-9)) } +/// Auto's condition for the Tool Overlay (Shift+H): the pointer is over the photo, or a pin is +/// being dragged. +fn pins_live(app: &LightcraftApp, resp: &egui::Response, map: &CanvasMap) -> bool { + resp.hover_pos().is_some_and(|q| map.rect.contains(q)) + || matches!(app.gesture, Some(Gesture::SpotMove { .. } | Gesture::MaskHandle { .. })) + // a handle moved with the keyboard stays shown while it has the focus + || crate::canvas_keys::has_focus(&resp.ctx) +} + fn pin(p: &egui::Painter, c: Pos2, sel: bool) { p.circle_filled(c, 6.0, if sel { Color32::from_rgb(1, 101, 221) } else { Color32::from_gray(200) }); p.circle_stroke(c, 6.0, Stroke::new(1.5, Color32::WHITE)); @@ -2089,15 +2323,28 @@ fn pin(p: &egui::Painter, c: Pos2, sel: bool) { /// The Remove tool on the photo: every spot's outline and pin (the selected one with its source), /// click a pin to select its spot, drag a target or source to move it, paint elsewhere to add one. +/// The Tool Overlay mode (Shift+H) says which spots show; a hidden spot can't be grabbed, so a +/// click there adds a new one. fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, map: &CanvasMap, d: &DevelopSettings) { let long = (map.rect.width().max(map.rect.height())) as f64; - let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); let active = app.session.active_spot.filter(|i| *i < d.spots.len()); + let (mode, live) = (app.ui.tool_overlay, pins_live(app, resp, map)); + // Auto also hides them while a new spot is painted + let painting = mode == ToolOverlay::Auto && matches!(app.gesture, Some(Gesture::Spot { .. })); + let p = ui.painter_at(app.canvas_rect.unwrap_or(map.rect)); + let mut pins = p.clone(); + if painting { + pins.set_invisible(); + } // (spot, is source, screen centre, radius) of everything that can be grabbed let mut grips: Vec<(usize, bool, Pos2, f32)> = Vec::new(); for (i, s) in d.spots.iter().enumerate() { let r = (s.size * long) as f32; let sel = Some(i) == active; + if !mode.shows(sel, live) { + continue; + } + let p = &pins; let col = Color32::from_white_alpha(if sel { 230 } else { 150 }); for q in &s.points { p.circle_stroke(map.screen(*q), r, Stroke::new(if sel { 1.5 } else { 1.0 }, col)); @@ -2114,10 +2361,13 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo register(ui.ctx(), format!("spotSource:{i}"), Rect::from_center_size(sq, vec2(14.0, 14.0))); grips.push((i, true, sq, r)); } - pin(&p, tq, sel); + pin(p, tq, sel); register(ui.ctx(), format!("spotPin:{i}"), Rect::from_center_size(tq, vec2(14.0, 14.0))); grips.push((i, false, tq, r)); } + if spot_keys(app, ui, map, d, &grips) { + return; + } let r = (app.ui.remove_size as f64 * long) as f32; // a target or source under `q`: the selected spot's first, then the nearest pin let hit = |q: Pos2| { @@ -2202,6 +2452,61 @@ fn remove_overlay(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Respo } } +/// Keyboard stand-ins for the Remove tool: each spot's target and source (an arrow press moves +/// it a step, one undo step; Return selects the spot), and a placement point that the arrows move +/// and Return turns into a new spot of the brush's size there. True when a spot was added. +fn spot_keys(app: &mut LightcraftApp, ui: &mut egui::Ui, map: &CanvasMap, d: &DevelopSettings, grips: &[(usize, bool, Pos2, f32)]) -> bool { + for &(spot, source, q, _) in grips { + let at = map.norm(q); + let name = + format!("{} {}{}", crate::i18n::tr("Spot"), spot + 1, if source { format!(" {}", crate::i18n::tr("source")) } else { String::new() }); + let size = d.spots.get(spot).map(|s| s.size).unwrap_or_default(); + let value = format!("{}, {} {:.0}%", crate::canvas_keys::position_text(at.x, at.y), crate::i18n::tr("size"), size * 100.0); + let keys = crate::canvas_keys::handle(ui, &format!("spot:{spot}:{}", if source { "source" } else { "target" }), &name, value, q); + if (keys.activated || keys.delta.is_some()) && app.session.active_spot != Some(spot) { + let _ = app.run("spot.select", json!({"index": spot})); + } + if let Some((dx, dy)) = keys.delta { + let _ = app.run("develop.beginInteraction", json!({"label": "Edit Spot"})); + let _ = app.run("spot.update", json!({"index": spot, if source { "moveSource" } else { "move" }: [dx, dy]})); + let _ = app.run("develop.endInteraction", json!({})); + } + } + // where a new spot goes: the photo's centre at first, then wherever the arrows took it + let key = egui::Id::new("lc-spot-placement"); + let place: (f64, f64) = ui.data(|m| m.get_temp(key)).unwrap_or((0.5, 0.5)); + let q = map.screen(Point::new(place.0, place.1)); + let keys = crate::canvas_keys::handle( + ui, + "spot:new", + crate::i18n::tr("New spot (Return adds it here)"), + crate::canvas_keys::position_text(place.0, place.1), + q, + ); + if let Some((dx, dy)) = keys.delta { + let moved = ((place.0 + dx).clamp(0.0, 1.0), (place.1 + dy).clamp(0.0, 1.0)); + ui.data_mut(|m| m.insert_temp(key, moved)); + } + if crate::canvas_keys::has_focus(ui.ctx()) && ui.memory(|m| m.has_focus(crate::canvas_keys::id("spot:new"))) { + let r = (app.ui.remove_size as f64 * (map.rect.width().max(map.rect.height())) as f64) as f32; + ui.painter().circle_stroke(q, r, Stroke::new(1.0, Color32::WHITE)); + } + if keys.activated { + let mode = match app.ui.tool.as_str() { + "heal" => "heal", + "clone" => "clone", + "fill" => "fill", + _ => "remove", + }; + let _ = app.run( + "spot.add", + json!({"mode": mode, "points": [[place.0, place.1]], "size": app.ui.remove_size, "feather": app.ui.remove_feather, "opacity": app.ui.remove_opacity}), + ); + return true; + } + false +} + // ------------------------------------------------------------------------ red eye /// Red Eye tool: drag an ellipse over an eye (a new correction), click one to select it. @@ -2286,7 +2591,13 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { child.style_mut().always_scroll_the_only_direction = true; // only a new active photo (or the strip appearing) scrolls it; see `grid::follow_active` let follow = super::grid::follow_active(child.ctx(), egui::Id::new("film-follow-active"), active, app.session.source); + // a wheel notch scrolls a third of its width; see `grid::wheel_step` + register(ui.ctx(), "filmstrip", child.max_rect()); + let wheel = super::grid::wheel_step(&child, egui::Id::new("film-wheel"), child.max_rect(), child.max_rect().width(), true); egui::ScrollArea::horizontal().id_salt("filmstrip").auto_shrink([false, false]).show_viewport(&mut child, |ui, vp| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } let (area, _) = ui.allocate_exact_size(vec2(ids.len() as f32 * cell_w, r.height() - 16.0), Sense::hover()); app.film_scroll = Some(vp.left()); for (i, id) in ids.iter().enumerate() { @@ -2300,6 +2611,7 @@ pub(crate) fn filmstrip(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect) { continue; } let resp = ui.interact(cr, egui::Id::new(("film", id.0)), Sense::click()); + crate::widgets::announce(&resp, app.session.catalog.photo(*id).map(|p| p.file_name.clone()).unwrap_or_default()); register(ui.ctx(), format!("film:{}", id.0), cr); let state = app.session.selection.state_of(*id); let sel = state == lightcraft_engine::SelectionState::Active; @@ -2418,6 +2730,22 @@ mod preview_geometry_tests { assert!(frame.contains_rect(image)); } + /// A 100 % view must put the photo's corner on a whole physical pixel, or the GPU's bilinear + /// filter softens it (a 1279.5 pt canvas on a 2x display centred the photo half a pixel off). + #[test] + fn percent_zoom_lands_on_whole_physical_pixels() { + let area = Rect::from_min_size(pos2(0.0, 42.0), vec2(1279.5, 726.0)); + for ppp in [1.0, 1.25, 2.0, 3.0] { + for pan in [(0.5, 0.5), (0.3, 0.7), (0.4999, 0.5001)] { + let image = fit_rect(area, 5152.0 / 7728.0, Zoom::Percent(100.0), [5152, 7728], ppp, pan); + for v in [image.min.x, image.min.y] { + assert!((v * ppp - (v * ppp).round()).abs() < 1e-3, "{v} pt is not a whole pixel at {ppp}x, pan {pan:?}"); + } + assert!((image.width() * ppp - 5152.0).abs() < 1e-3, "size stays the photo's own pixels"); + } + } + } + #[test] fn portrait_preview_aspect_fits_detail_area_without_stretching() { let area = Rect::from_min_size(pos2(0.0, 0.0), vec2(400.0, 200.0)); @@ -2679,6 +3007,58 @@ mod wheel_zoom_tests { } } +/// The pen pressure (0..1) reported this frame: the force of the latest touch or pen event that carries one (winit +/// reports a pen's pressure as a touch force, on the platforms that give it), `None` for a mouse. +pub(crate) fn pen_force(i: &egui::InputState) -> Option<f32> { + i.events.iter().rev().find_map(|e| match e { + egui::Event::Touch { force: Some(f), .. } if f.is_finite() => Some(f.clamp(0.0, 1.0)), + _ => None, + }) +} + +/// Record the pressure of the stroke's point `index`: `force` when the pen reported one this frame, else the last +/// known one. Points before the first reported force take it too; a stroke without any keeps no pressure list. +pub(crate) fn record_pressure(pressure: &mut Vec<f32>, index: usize, force: Option<f32>) { + match (force, pressure.last().copied()) { + (Some(f), _) if pressure.is_empty() => pressure.extend(std::iter::repeat_n(f, index + 1)), + (Some(f), _) => pressure.push(f), + (None, Some(last)) => pressure.push(last), + (None, None) => {} + } +} + +#[cfg(test)] +mod pen_tests { + use super::*; + + #[test] + fn pen_pressure_is_recorded_per_point_and_absent_for_a_mouse() { + let mut p = Vec::new(); + record_pressure(&mut p, 0, None); + record_pressure(&mut p, 1, None); + assert!(p.is_empty(), "a mouse: no pressure list"); + // the first force covers the points before it, a frame without one repeats the last + record_pressure(&mut p, 2, Some(0.4)); + record_pressure(&mut p, 3, None); + record_pressure(&mut p, 4, Some(0.9)); + assert_eq!(p, vec![0.4, 0.4, 0.4, 0.4, 0.9]); + + let mut i = egui::InputState::default(); + assert_eq!(pen_force(&i), None); + let touch = |force| egui::Event::Touch { + device_id: egui::TouchDeviceId(1), + id: egui::TouchId(1), + phase: egui::TouchPhase::Move, + pos: egui::pos2(1.0, 1.0), + force, + }; + i.events = vec![touch(Some(0.3)), touch(Some(f32::NAN)), touch(None)]; + assert_eq!(pen_force(&i), Some(0.3), "the latest usable force"); + i.events = vec![touch(Some(4.0))]; + assert_eq!(pen_force(&i), Some(1.0)); + } +} + #[cfg(test)] mod filter_pill_tests { use super::*; @@ -2701,3 +3081,41 @@ mod filter_pill_tests { assert_eq!(left, top, "left and top insets: {pill:?} in {canvas:?}"); } } + +#[cfg(test)] +mod info_overlay_tests { + use super::*; + + /// #1690: the info overlay's shadow is a dark copy under the text. Drawn as a second white + /// copy one point off, each line read doubled. + #[test] + fn the_info_overlay_shadow_is_dark() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), crate::Services { png: None, ..Default::default() }); + app.ui.info_overlay = crate::state::InfoOverlay::Basic; + let photo = app.session.catalog.photos().next().map(|p| (**p).clone()).expect("the demo catalog has photos"); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + let canvas = Rect::from_min_size(pos2(52.0, 82.0), vec2(800.0, 500.0)); + let raw = crate::headless::HeadlessView::raw_input(vec2(1000.0, 700.0), 1.0, 0.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| info_overlay(&app, ui.painter(), canvas, &photo)); + out.textures_delta.clear(); + // the colour a text shape paints its glyphs in, as epaint resolves it + let ink = |s: &egui::epaint::TextShape| { + let c = s.galley.job.sections.first().map_or(Color32::PLACEHOLDER, |sec| sec.format.color); + s.override_text_color.unwrap_or(if c == Color32::PLACEHOLDER { s.fallback_color } else { c }) + }; + fn texts(shape: &egui::epaint::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::epaint::Shape::Text(t) => out.push(t.clone()), + egui::epaint::Shape::Vec(v) => v.iter().for_each(|s| texts(s, out)), + _ => {} + } + } + let mut all = Vec::new(); + out.shapes.iter().for_each(|c| texts(&c.shape, &mut all)); + let copies: Vec<Color32> = all.iter().filter(|t| t.galley.job.text == photo.file_name).map(ink).collect(); + assert_eq!(copies.len(), 2, "the file name and its shadow: {copies:?}"); + assert_eq!(copies[1], Color32::WHITE, "the text on top"); + assert!(copies[0].r() < 64 && copies[0].a() > 0, "the shadow under it is dark: {:?}", copies[0]); + } +} diff --git a/crates/ui-egui/src/panels/dialogs.rs b/crates/ui-egui/src/panels/dialogs.rs index a79261fdc..5562e94d1 100644 --- a/crates/ui-egui/src/panels/dialogs.rs +++ b/crates/ui-egui/src/panels/dialogs.rs @@ -22,7 +22,7 @@ pub(crate) fn rename_preview( ) -> (Option<Vec<lightcraft_engine::rename::RenamePlan>>, usize) { use std::hash::{Hash, Hasher}; type Rows = std::sync::Arc<std::sync::Mutex<Option<Vec<lightcraft_engine::rename::RenamePlan>>>>; - let ids = app.session.targets(&json!({})); + let ids = app.session.selected(); let photos = lightcraft_engine::rename::rename_photos(&app.session.catalog, &ids); let total = photos.len(); let mut h = std::collections::hash_map::DefaultHasher::new(); @@ -71,7 +71,7 @@ fn watermark_preview( type Base = std::sync::Arc<std::sync::Mutex<(u8, Option<Result<lightcraft_raster::Rgba8, String>>)>>; type Shown = (u64, u8, lightcraft_engine::export::Watermark, usize, egui::TextureHandle); let t = Tokens::get(ui.ctx()); - let Some(&id) = app.session.targets(&json!({})).first() else { return }; + let Some(&id) = app.session.selected().first() else { return }; let Some(photo) = app.session.catalog.photo(id).cloned() else { return }; let (out_w, out_h) = lightcraft_engine::export::output_size(&photo, &lightcraft_engine::export::ExportOptions { resize, ..Default::default() }); let export_short = out_w.min(out_h); @@ -176,6 +176,7 @@ pub const WHATS_NEW: &str = include_str!("../../../../docs/whats-new.md"); fn window_id(dialog: &Dialog) -> egui::Id { egui::Id::new(match dialog { Dialog::Import { .. } => "lightcraft-import-dialog", + Dialog::Print => "lightcraft-print-dialog", Dialog::Settings { .. } => "lightcraft-settings-dialog", _ => "lightcraft-dialog", }) @@ -226,9 +227,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::RenameAlbum { .. } => "Rename Album", Dialog::Rename { .. } => "Rename Photos", Dialog::Import { .. } => "Import Photos", + Dialog::LightroomResult { .. } => "Lightroom Catalog Import", Dialog::LabelNames { .. } => "Edit Color Label Names", Dialog::CaptureTime { .. } => "Edit Capture Time", Dialog::RenameKeyword { .. } => "Rename Keyword", + Dialog::RenamePerson { .. } => "Rename Person", Dialog::KeywordTag { editing: None, .. } => "Create Keyword Tag", Dialog::KeywordTag { .. } => "Edit Keyword Tag", Dialog::DeleteKeyword { .. } => "Delete Keyword", @@ -251,10 +254,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::PasteSettings { .. } => "Paste Selected Settings", Dialog::Export { .. } => "Export", Dialog::ContactSheet { .. } => "Contact Sheet PDF", + Dialog::SlideshowExport { .. } => "Export Slideshow", + Dialog::Print => "Print", Dialog::Merge { opts } => opts.title(), Dialog::Settings { .. } => "Settings", Dialog::ConfirmDelete { .. } => "Delete Photos", + Dialog::MovePhotos { .. } => "Move Photos", Dialog::LightroomCatalog { .. } => "Lightroom Classic Catalog", + Dialog::SaveEffect { .. } => "New Effect Preset", + Dialog::ManageEffects { .. } => "Manage Effect Presets", + Dialog::DeleteEffects { names } if names.len() == 1 => "Delete Effect Preset", + Dialog::DeleteEffects { .. } => "Delete Effect Presets", Dialog::RemoveFolder { disk: true, .. } => "Remove Disk from Library", Dialog::RemoveFolder { .. } => "Remove Folder from Library", Dialog::SynchronizeFolder { .. } => "Synchronize Folder", @@ -270,13 +280,24 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // own so the size it is given doesn't carry over to the other dialogs. Its content scrolls // rather than growing the window when the options below the grid get taller (e.g. Copy). let import = matches!(dlg, Dialog::Import { .. }); + // The Print dialog sizes itself to the window and scrolls its settings column on its own. + let print = matches!(dlg, Dialog::Print); let window_id = window_id(&dlg); // Every other dialog fits the window (#781): its options scroll when they don't, and the title // bar and the button row stay outside the scroll area. The room they take is measured on the // previous frame (a guess on the first one). let chrome_id = window_id.with("chrome"); let chrome = ctx.data(|d| d.get_temp::<f32>(chrome_id)).unwrap_or(DIALOG_CHROME_GUESS); - let body_max = (screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome).max(DIALOG_MIN_BODY); + // Settings keeps its top edge, and the widest it has been, while tabs of different sizes are shown, so its tab + // row stays where it is (centred like the others, the row jumped with each tab's height) + let settings = matches!(dlg, Dialog::Settings { .. }); + let top = (screen.height() * 0.08).clamp(DIALOG_SCREEN_MARGIN, 96.0); + let body_max = + if settings { screen.height() - top - DIALOG_SCREEN_MARGIN - chrome } else { screen.height() - 2.0 * DIALOG_SCREEN_MARGIN - chrome } + .max(DIALOG_MIN_BODY); + let widest_id = window_id.with("widest"); + let widest = if settings { ctx.data(|d| d.get_temp::<f32>(widest_id)).unwrap_or(0.0).min(screen.width() * 0.9) } else { 0.0 }; + let mut content_w = 0.0f32; let mut body_h = None; let shown = egui::Window::new(crate::i18n::tr(&title)).id(window_id) .collapsible(false) @@ -284,18 +305,21 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .vscroll(import) .resize(|r| if import { r.default_height(crate::import::DIALOG_SIZE[1]) } else { r }) .frame(frame) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .anchor(if settings { egui::Align2::CENTER_TOP } else { egui::Align2::CENTER_CENTER }, [0.0, if settings { top } else { 0.0 }]) + .min_width(widest) .default_width(match dlg { Dialog::Import { .. } => crate::import::DIALOG_SIZE[0], Dialog::SmartRules { .. } => 680.0, Dialog::AllMetadata { .. } => 620.0, + Dialog::Print => 1100.0, Dialog::FaceModel { .. } => 460.0, Dialog::LightroomCatalog { .. } => 440.0, + Dialog::ManageEffects { .. } => 560.0, _ => 380.0, }) .show(ctx, |ui| { ui.spacing_mut().item_spacing.y = 8.0; - body_h = dialog_body(ui, (!import).then_some(title.as_str()), body_max, |ui| match &mut dlg { + body_h = dialog_body(ui, (!import && !print).then_some(title.as_str()), body_max, |ui| match &mut dlg { Dialog::AutoStack { gap } => { ui.label(egui::RichText::new(crate::i18n::tr("Stack photos taken within this time of each other:")).color(t.text_label)); let r = ui.add( @@ -345,6 +369,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::DenoiseModel { info, accepted } => crate::panels::denoise::model_dialog(app, ui, &t, info, accepted), Dialog::FaceModel { info, accepted, .. } => crate::panels::faces::model_dialog(app, ui, &t, info, accepted), + Dialog::LightroomResult { summary, library, details, .. } => { + ui.label(summary.as_str()); + ui.add_space(8.0); + if let Some(library) = library { + ui.label(crate::i18n::tr("Destination library")); + ui.add(egui::Label::new(library.as_str()).wrap().selectable(true)); + ui.label(crate::i18n::tr("Lightroom catalogs import into the open library; no separate catalog folder is created.")); + } else { + ui.label(crate::i18n::tr("These photos are only in memory. Open a library on disk and import the catalog again to save them.")); + } + for detail in details.iter() { + ui.add(egui::Label::new(detail).wrap().selectable(true)); + } + } Dialog::SystemInfo { rows } => { egui::Grid::new("sysinfo").num_columns(2).spacing([16.0, 4.0]).striped(true).show(ui, |ui| { for (k, v) in rows.iter() { @@ -431,7 +469,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let rules = app.session.view_rules(); - let n = app.session.catalog.query(&rules, &Default::default()).len(); + // counted when the library or the rules change (or, for "in the last…" rules, the minute), not every frame + let now = (app.session.clock)(); + let minute = if rules.depends_on_now() { now.get(..16).unwrap_or(&now) } else { "" }; + let n = app.caches.dialog_count(&app.session.catalog, ("smartAlbum", format!("{rules:?}"), minute), || { + lightcraft_catalog::rules::set_now(Some(now.clone())); + app.session.catalog.query(&rules, &Default::default()).len() + }); ui.label(egui::RichText::new(crate::i18n::tr_format!("Matches: {}", crate::i18n::filter_label(&rules, &app.session.catalog))).color(t.text_label)); ui.label( egui::RichText::new(crate::i18n::tr_format!("{n} photo{} now · updates automatically as photos change", if n == 1 { "" } else { "s" }, n = n)) @@ -439,7 +483,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::CaptureTime { mode, time, days, hours, minutes, zone } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); field(ui, "Change", |ui| { ui.spacing_mut().item_spacing.x = 4.0; for (i, (label, m)) in [("Set date & time", "set"), ("Shift by", "shift"), ("Time zone", "zone")].iter().enumerate() { @@ -523,7 +567,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ); } Dialog::Rename { template, start } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); let template_id = egui::Id::new("rename-template"); let tags_open = field(ui, "Template", |ui| { @@ -558,7 +602,8 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { egui::Grid::new("rename-preview").num_columns(3).spacing([8.0, 2.0]).show(ui, |ui| { for pl in rows { ui.label(egui::RichText::new(&pl.from).color(t.text_dim)); - ui.label(egui::RichText::new("→").color(t.text_dim)); + let (arrow, _) = ui.allocate_exact_size(egui::vec2(16.0, 16.0), egui::Sense::hover()); + crate::icons::paint(ui.painter(), arrow, crate::icons::Icon::ChevronRight, t.text_dim); ui.label(egui::RichText::new(&pl.to).color(t.text)); ui.end_row(); } @@ -573,8 +618,40 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { ui.label(egui::RichText::new(crate::i18n::tr_format!("… and {more} more", more = more)).color(t.text_dim)); } } + Dialog::RenamePerson { from, to, merge } => { + let submitted = dialog_field(ui, "field:personName", to, "Name"); + let plan = person_rename_plan(&app.session.catalog, from, to); + if !plan.needs_merge { + *merge = false; + } + if submitted && plan.block.is_none() && (!plan.needs_merge || *merge) { + confirm = true; + } + if let Some(why) = plan.block { + ui.label(egui::RichText::new(crate::i18n::tr(why)).color(t.caution)); + } else { + ui.label( + egui::RichText::new(crate::i18n::tr_format!( + "Renames {n} face{} across the library. The files are not changed.", + if plan.faces == 1 { "" } else { "s" }, + n = plan.faces + )) + .color(t.text_dim), + ); + } + if plan.needs_merge { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("“{to}” is already a person. Combining them makes one person; Undo separates them again.", to = to.trim())) + .color(t.caution), + ); + let r = ui.checkbox(merge, crate::i18n::tr("Combine with the existing person")); + crate::widgets::register(ui.ctx(), "check:personMerge", r.rect); + } + } Dialog::RenameKeyword { from, to } => { - let n = app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count(); + let n = app.caches.dialog_count(&app.session.catalog, ("renameKeyword", from.as_str()), || { + app.session.catalog.photos().filter(|p| p.meta.keywords.iter().any(|k| lightcraft_catalog::keywords::is_under(k, from))).count() + }); if dialog_field(ui, "field:keywordName", to, "New name") { confirm = true; } @@ -590,11 +667,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } let options: Vec<String> = app - .session - .catalog - .keyword_suggestions(from, into, 12) - .into_iter() + .caches + .suggestions(&app.session.catalog, from, into, 12) + .iter() .filter(|k| !from.iter().any(|f| lightcraft_catalog::keywords::is_under(k, f))) + .cloned() .collect(); ui.horizontal_wrapped(|ui| { for k in options { @@ -614,7 +691,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { confirm = true; } // as in Lightroom, the option is only there when something is selected - if !*folder && !app.session.targets(&json!({})).is_empty() { + if !*folder && !app.session.selected().is_empty() { ui.add_space(6.0); let r = ui.checkbox(include_selected, crate::i18n::tr("Include selected photos")); crate::widgets::register(ui.ctx(), "check:albumIncludeSelected", r.rect); @@ -633,30 +710,31 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } Dialog::CopySettings { groups } => group_checklist(ui, "copyGroup", groups), Dialog::PasteSettings { groups } => { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); ui.label( egui::RichText::new(crate::i18n::tr_format!("Paste into {n} photo{} — only settings that were copied are pasted", if n == 1 { "" } else { "s" }, n = n)) .color(t.text_dim), ); group_checklist(ui, "pasteGroup", groups); } + Dialog::SlideshowExport { settings } => slideshow_export_body(app, ui, settings), Dialog::ContactSheet { options } => { options.columns = options.columns.clamp(1, 8); options.rows = options.rows.clamp(1, 10); - let n = app.session.targets(&json!({})).len(); - ui.label(format!("{n} selected photos · {} pages", n.div_ceil(options.columns * options.rows))); + let n = app.session.selected().len(); + ui.label(crate::i18n::tr_format!("{n} selected photos · {pages} pages", n = n, pages = n.div_ceil(options.columns * options.rows))); field(ui, "Paper", |ui| { - egui::ComboBox::from_id_salt("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { + crate::widgets::combo_box("contactSheetPaper").selected_text(&options.paper).show_ui(ui, |ui| { ui.selectable_value(&mut options.paper, "a4".into(), "A4"); - ui.selectable_value(&mut options.paper, "letter".into(), "Letter"); + ui.selectable_value(&mut options.paper, "letter".into(), crate::i18n::tr("Letter")); }); }); - ui.checkbox(&mut options.landscape, "Landscape"); + ui.checkbox(&mut options.landscape, crate::i18n::tr("Landscape")); field(ui, "Columns", |ui| { ui.add(egui::DragValue::new(&mut options.columns).range(1..=8)); }); field(ui, "Rows", |ui| { ui.add(egui::DragValue::new(&mut options.rows).range(1..=10)); }); - ui.checkbox(&mut options.captions, "Filename captions"); - ui.label("150 dpi · sRGB · current edits · fit without cropping"); - ui.label("Choose a PDF destination after confirming. Print the PDF from your viewer."); + ui.checkbox(&mut options.captions, crate::i18n::tr("Filename captions")); + ui.label(crate::i18n::tr("150 dpi · sRGB · current edits · fit without cropping")); + ui.label(crate::i18n::tr("Choose a PDF destination after confirming. Print the PDF from your viewer.")); } Dialog::Export { opts, full_size, resize, preset_name, limit_kb, dir } => { use lightcraft_engine::export::{ExportFormat as F, MetadataPolicy as M, SharpenAmount as A, SharpenFor as S}; @@ -665,7 +743,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { // Preset: load a built-in or saved set of options into the dialog field(ui, "Preset", |ui| { let mut chosen = None; - let c = egui::ComboBox::from_id_salt("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportPreset").width(220.0).selected_text(crate::i18n::tr("Choose…")).show_ui(ui, |ui| { let mut builtin = true; for (p, b) in app.session.all_export_presets() { if builtin && !b { @@ -725,10 +803,10 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.bit_depth = Some(bd); } if matches!(opts.format, F::Jpeg | F::Tiff | F::Avif | F::Png | F::Jxl) { - let tip = "Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"; - ui.checkbox(&mut opts.hdr, "HDR output").on_hover_text(tip); + let tip = crate::i18n::tr("Photos edited in HDR: JPEG with an HDR gain map (looks right everywhere, brighter highlights on HDR displays), AVIF as 10-bit, PNG and JPEG XL as 16-bit Rec. 2020 PQ; TIFF needs 32-bit float"); + ui.checkbox(&mut opts.hdr, crate::i18n::tr("HDR output")).on_hover_text(tip); if opts.hdr && !opts.hdr_output() { - ui.label(egui::RichText::new("HDR TIFF needs 32-bit float.").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("HDR TIFF needs 32-bit float.")).color(t.text_dim)); } if opts.hdr && opts.format == F::Avif { use lightcraft_engine::export::HdrCurve as H; @@ -737,13 +815,17 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if !rendered { } else if opts.format == F::Avif && opts.hdr { - let curve = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { "HLG" } else { "PQ" }; - ui.label(egui::RichText::new(format!("Color space: Rec. 2020 {curve} (HDR AVIF)")).color(t.text_dim)); + let label = if opts.hdr_transfer == lightcraft_engine::export::HdrCurve::Hlg { + crate::i18n::tr("Color space: Rec. 2020 HLG (HDR AVIF)") + } else { + crate::i18n::tr("Color space: Rec. 2020 PQ (HDR AVIF)") + }; + ui.label(egui::RichText::new(label).color(t.text_dim)); } else if opts.format == F::Png && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)").color(t.text_dim)) - .on_hover_text("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map)."); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ, 16-bit (HDR PNG)")).color(t.text_dim)) + .on_hover_text(crate::i18n::tr("Viewers that don't read the PNG cICP chunk show PQ values as sRGB: flat and washed out. For a picture that looks right everywhere, use JPEG (gain map).")); } else if opts.format == F::Jxl && opts.hdr { - ui.label(egui::RichText::new("Color space: Rec. 2020 PQ (HDR JPEG XL)").color(t.text_dim)); + ui.label(egui::RichText::new(crate::i18n::tr("Color space: Rec. 2020 PQ (HDR JPEG XL)")).color(t.text_dim)); } else if opts.format == F::Avif { ui.label(egui::RichText::new(crate::i18n::tr("Color space: sRGB (AVIF)")).color(t.text_dim)); } else { @@ -904,7 +986,7 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } if opts.format == F::Dng { use lightcraft_engine::export::DngCompression as Z; - choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None")], &mut opts.dng_compression); + choices(ui, "Compression", "exportDngCompression", &[(Z::Lossless, "Lossless"), (Z::Deflate, "ZIP"), (Z::Uncompressed, "None"), (Z::Lossy, "Lossy")], &mut opts.dng_compression); } let naming_id = egui::Id::new("export-naming"); let tags_open = field(ui, "File name", |ui| { @@ -923,18 +1005,32 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { opts.start_number = v as u32; } } - field(ui, "Folder", |ui| { - trailing_button_row(ui, |ui| { - if app.services.pick_folder.is_some() - && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() - && let Some(pick) = app.services.pick_folder.as_mut() - && let Some(d) = pick() - { - *dir = d; - } - crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); + // like Lightroom Classic's Export To: one folder, or each photo's own folder (not on + // the web, where originals live in the browser's storage, not in folders) + use lightcraft_engine::export::DestinationMode as D; + if !cfg!(target_arch = "wasm32") || opts.destination_mode != D::SpecificFolder { + choices( + ui, + "Export to", + "exportDestinationMode", + &[(D::SpecificFolder, "Specific folder"), (D::SameAsOriginal, "Same folder as original photo")], + &mut opts.destination_mode, + ); + } + if opts.destination_mode == D::SpecificFolder { + field(ui, "Folder", |ui| { + trailing_button_row(ui, |ui| { + if app.services.pick_folder.is_some() + && crate::widgets::text_button(ui, "exportChooseFolder", crate::i18n::tr("Choose…"), false).clicked() + && let Some(pick) = app.services.pick_folder.as_mut() + && let Some(d) = pick() + { + *dir = d; + } + crate::widgets::named(ui.add(egui::TextEdit::singleline(dir).desired_width(ui.available_width())), crate::i18n::tr("Folder")); + }); }); - }); + } field(ui, "Subfolder", |ui| { crate::widgets::named(ui.add(egui::TextEdit::singleline(&mut opts.subfolder).hint_text(crate::i18n::tr("none")).desired_width(f32::INFINITY)), crate::i18n::tr("Subfolder")) }); @@ -1113,11 +1209,36 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { .color(t.text_dim), ); } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + if crate::panels::mask_effects::save_body(app, ui, name, group, fields, amount, brush) { + confirm = true; + } + } + Dialog::ManageEffects { search, selected, rename, confirm_delete, new_group } => { + crate::panels::mask_effects::manage_body(app, ui, search, selected, rename, confirm_delete, new_group) + } + Dialog::DeleteEffects { names } => { + let what = match names.as_slice() { + [one] => crate::i18n::tr_format!("Delete the effect preset “{name}”?", name = one), + _ => crate::i18n::tr_format!("Delete these {n} effect presets?", n = names.len()), + }; + ui.label(what); + ui.label(egui::RichText::new(crate::i18n::tr("Masks keep their sliders. Export presets first to keep a copy.")).color(t.text_dim)); + } Dialog::ConfirmDelete { count } => { let what = if *count == 1 { crate::i18n::tr("this photo").to_string() } else { crate::i18n::tr_format!("these {count} photos", count = count) }; ui.label(crate::i18n::tr_format!("Move {what} to Recently Deleted?", what = what)); ui.label(egui::RichText::new(crate::i18n::tr("They can be restored from Recently Deleted until it is emptied.")).color(t.text_dim)); } + Dialog::MovePhotos { ids, name, dont_ask, .. } => { + let n = ids.len(); + ui.label(crate::i18n::tr_format!("Move {n} photo{} to “{}”?", if n == 1 { "" } else { "s" }, name, n = n)); + ui.label(egui::RichText::new(crate::i18n::tr("Their files and XMP sidecars move on disk. Edit → Undo moves them back.")).color(t.text_dim)); + ui.add_space(4.0); + let c = ui.checkbox(dont_ask, crate::i18n::tr("Don't ask again")); + crate::widgets::register(ui.ctx(), "check:moveDontAsk", c.rect); + c.on_hover_text(crate::i18n::tr("Settings → Confirm before moving photos to another folder on disk turns it back on")); + } d @ Dialog::SynchronizeFolder { .. } => crate::sync::body(app, ui, d), Dialog::LightroomCatalog { folder, catalog } => { ui.label(crate::i18n::tr("This is a Lightroom catalog, not a LightCraft library. Import it into a LightCraft library instead?")); @@ -1169,11 +1290,13 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::theme::font_credits(app.chinese_font.is_some()) )); ui.add_space(10.0); - let discord = egui::Button::new(egui::RichText::new(crate::i18n::tr("Join the ArtCraft Discord")).font(t.semibold(15.0)).color(egui::Color32::WHITE)) - .fill(t.accent) - .min_size(egui::vec2(260.0, 34.0)); - let r = ui.add(discord).on_hover_text(crate::links::DISCORD); - crate::widgets::register(ui.ctx(), "button:aboutDiscord", r.rect); + let r = crate::button::Button::new("Join the ArtCraft Discord") + .primary() + .large() + .min_width(260.0) + .widget("button:aboutDiscord") + .tooltip(crate::links::DISCORD) + .show(ui); if r.clicked() { let _ = crate::links::open(app, crate::links::DISCORD); } @@ -1192,17 +1315,20 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { } } Dialog::Shortcuts => crate::panels::keymap::body(app, ui, &t), + Dialog::Print => crate::print_view::body(app, ui, &t), }); ui.add_space(4.0); - ui.horizontal(|ui| { + // the confirm on the right with Cancel to its left (macOS and GNOME's order), filled with + // the accent when it is the dialog's primary action. The row spans the width the window + // gives its content this frame (its default width, or its content's if wider), so it + // reaches the edge without widening the window. + let row_w = ui.available_width().max(ui.min_rect().width()).max(1.0); + let row_h = ui.spacing().interact_size.y; + ui.allocate_ui_with_layout(egui::vec2(row_w, row_h), egui::Layout::left_to_right(egui::Align::Center), |ui| { // a model file LightCraft cannot use has nothing to confirm, nor has a dialog that // only shows something (one Close, no Cancel / OK) let unusable_model = matches!(&dlg, Dialog::DenoiseModel { info, .. } | Dialog::FaceModel { info, .. } if info["kind"] == "unsupported"); - let informational = unusable_model - || matches!( - dlg, - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::SystemInfo { .. } | Dialog::AllMetadata { .. } | Dialog::WhatsNew - ); + let informational = unusable_model || matches!(dlg, Dialog::LightroomResult { first: None, .. }) || only_shows(&dlg); let sam = &app.session.segmenter; let (sam_installed, sam_running, sam_failed) = (sam.installed(), sam.download_status().running, sam.download_status().error.is_some()); // no download location in this build: nothing to offer but the manual install @@ -1210,15 +1336,9 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { let cancel = match &dlg { Dialog::SamModel { .. } if sam_running || sam_installed || sam_nowhere => "Close", Dialog::SamModel { .. } => "Not Now", + Dialog::LightroomResult { .. } => "Close", _ => "Cancel", }; - if !informational { - let r = ui.button(crate::i18n::tr(cancel)); - crate::widgets::register(ui.ctx(), "button:dialogCancel", r.rect); - if r.clicked() { - close = true; - } - } let add_label; let ok = match &dlg { Dialog::Import { opts } => { @@ -1227,13 +1347,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { add_label = crate::i18n::tr_format!("{verb} {n} Photo{}", if n == 1 { "" } else { "s" }, verb = crate::i18n::tr(verb), n = n); add_label.as_str() } + Dialog::LightroomResult { first: Some(_), .. } => "Show in Library", Dialog::ContactSheet { .. } => "Choose PDF…", + Dialog::SlideshowExport { .. } => "Export…", + Dialog::Print => "Print to PDF…", Dialog::Merge { .. } => "Merge", Dialog::DenoiseModel { info, .. } if info["download"].is_string() => "Accept & Download", Dialog::DenoiseModel { .. } => "Install", Dialog::FaceModel { info, .. } if !informational && info["download"].is_string() => "Download", Dialog::FaceModel { .. } if !informational => "Install", - Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } => "Delete", + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } => "Delete", + Dialog::SaveEffect { .. } => "Create", + Dialog::MovePhotos { .. } => "Move", // (not "Merge": that is photo merging, HDR / panorama, in some languages) Dialog::MoveKeyword { .. } => "Merge Keywords", Dialog::KeywordSet { .. } => "Save", @@ -1246,22 +1371,53 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::SamModel { .. } if sam_installed || sam_running || sam_nowhere => "", Dialog::SamModel { error, .. } if error.is_some() || sam_failed => "Try Again", Dialog::SamModel { .. } => "Download", + Dialog::RenamePerson { from, to, .. } if person_rename_plan(&app.session.catalog, from, to).needs_merge => "Merge", _ if informational => "Close", _ => "OK", }; // installing waits for the licence to be accepted // (and Synchronize waits for the scan) - let can_confirm = informational - || !matches!( - &dlg, - Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } - ) - // smart-album rules that can't mean anything wait to be fixed - && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty()); - let r = (!ok.is_empty()).then(|| ui.add_enabled(can_confirm, egui::Button::new(crate::i18n::tr(ok)))); - if let Some(r) = &r { - crate::widgets::register(ui.ctx(), "button:dialogOk", r.rect); + let rename_blocked = match &dlg { + Dialog::RenamePerson { from, to, merge } => { + let plan = person_rename_plan(&app.session.catalog, from, to); + plan.block.is_some() || (plan.needs_merge && !*merge) + } + _ => false, + }; + let can_confirm = !rename_blocked + && (informational + || !matches!( + &dlg, + Dialog::DenoiseModel { accepted: false, .. } | Dialog::FaceModel { accepted: false, .. } | Dialog::SynchronizeFolder { counts: None, .. } + ) + // smart-album rules that can't mean anything wait to be fixed + && !matches!(&dlg, Dialog::SmartRules { id, rules, .. } if !rules.check_for(&app.session.catalog, id.map(lightcraft_catalog::AlbumId)).is_empty())); + let primary = !informational && can_confirm && confirm_style(&dlg) == ConfirmStyle::Primary; + use crate::button::{Button, Kind}; + // (each kind of dialog its own buttons: the keyboard's place doesn't carry from a + // dialog to the one it opens) + let kind = std::mem::discriminant(&dlg); + let cancel_button = Button::new(cancel).widget("button:dialogCancel").id_salt(kind).min_width(72.0); + let ok_button = Button::new(ok) + .kind(if primary { Kind::Primary } else { Kind::Secondary }) + .enabled(can_confirm) + .widget("button:dialogOk") + .id_salt(kind) + .min_width(72.0); + // (Cancel first, then the confirm: the keyboard goes through them as the eye does) + let gap = ui.spacing().item_spacing.x; + let mut buttons_w = 0.0; + if !informational { + buttons_w += cancel_button.desired_size(ui).x + gap; } + if !ok.is_empty() { + buttons_w += ok_button.desired_size(ui).x; + } + ui.add_space((ui.available_width() - buttons_w).max(0.0)); + if !informational && cancel_button.show(ui).clicked() { + close = true; + } + let r = (!ok.is_empty()).then(|| ok_button.show(ui)); if r.is_some_and(|r| r.clicked()) { if informational { close = true; @@ -1273,7 +1429,11 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { if import { crate::import::note_dialog_bottom(ui); } + content_w = ui.min_rect().width(); }); + if settings && content_w > widest + 0.5 { + ctx.data_mut(|d| d.insert_temp(widest_id, content_w)); + } if let Some(w) = shown { ctx.memory_mut(|m| m.set_modal_layer(w.response.layer_id)); if let Some(body_h) = body_h { @@ -1301,12 +1461,15 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { Dialog::Import { .. } | Dialog::Export { .. } | Dialog::ContactSheet { .. } + | Dialog::SlideshowExport { .. } + | Dialog::Print | Dialog::NewAlbum { .. } | Dialog::NewSmartAlbum { .. } | Dialog::SmartRules { .. } | Dialog::KeywordTag { .. } | Dialog::KeywordSet { .. } | Dialog::SynchronizeFolder { .. } + | Dialog::SaveEffect { .. } ) => { app.toast(ctx, e) @@ -1322,6 +1485,18 @@ pub fn show(app: &mut LightcraftApp, ctx: &egui::Context) { crate::panels::grid::toast_skipped(app, ctx, &r); close = true; } + // a move says how it went, or why nothing moved + Ok(r) if matches!(dlg, Dialog::MovePhotos { .. }) => { + if let Dialog::MovePhotos { name, .. } = &dlg { + let text = crate::panels::left::moved_text(r["moved"].as_u64().unwrap_or(0), name); + app.toast(ctx, text); + } + close = true; + } + Err(e) if matches!(dlg, Dialog::MovePhotos { .. }) => { + app.toast(ctx, e); + close = true; + } Ok(_) if keeps_open(app, &dlg) => { if let Dialog::SamModel { error, .. } = &mut dlg { *error = None; @@ -1552,6 +1727,20 @@ pub(crate) fn album_entries(cat: &lightcraft_catalog::Catalog, editing: Option<u pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_json::Value, String> { match dlg { + Dialog::LightroomResult { first, .. } => { + if let Some(first) = first { + app.run("library.source", json!({"kind": "all"}))?; + let mut filter = serde_json::to_value(lightcraft_catalog::Filter::default()).map_err(|e| e.to_string())?; + // The filter command merges a patch; these defaults are omitted by serde. + filter["only"] = json!([]); + filter["labels"] = json!([]); + filter["ruleSet"] = serde_json::Value::Null; + app.run("library.filter", filter)?; + app.run("library.select", json!({"ids": [first]}))?; + app.ui.view = crate::state::ViewMode::PhotoGrid; + } + Ok(serde_json::Value::Null) + } Dialog::SamModel { then, .. } => { if app.session.segmenter.installed() { // installed: start what the user was doing @@ -1596,6 +1785,7 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso } Dialog::Rename { template, start } => app.run("photo.rename", json!({"template": template, "start": start})), Dialog::RenameKeyword { from, to } => app.run("keyword.rename", json!({"from": from, "to": to})), + Dialog::RenamePerson { from, to, merge } => app.run("faces.renamePerson", json!({"from": from, "to": to.trim(), "merge": merge})), Dialog::KeywordTag { editing, name, @@ -1711,6 +1901,8 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso }), ), Dialog::ContactSheet { options } => app.run("app.contactSheet", json!(options)), + Dialog::SlideshowExport { settings } => app.run("app.slideshowExport", json!(settings)), + Dialog::Print => app.run("app.print", json!({})), Dialog::CopySettings { groups } => app.run("develop.copy", json!({"groups": groups})), Dialog::PasteSettings { groups } => app.run("develop.paste", json!({"groups": groups})), Dialog::Export { opts, full_size, resize, limit_kb, dir, .. } => { @@ -1722,13 +1914,28 @@ pub fn confirm_dialog(app: &mut LightcraftApp, dlg: &Dialog) -> Result<serde_jso Dialog::Merge { opts } => crate::merge::start_final(app, opts), Dialog::Import { opts } => crate::import::start(app, opts), Dialog::ConfirmDelete { .. } => app.run("photo.delete", json!({})), + Dialog::MovePhotos { ids, folder, dont_ask, library, .. } => { + // another library opened meanwhile (from the menu bar, an agent): these numbers are + // other photos there + if *library != app.session.library_generation() { + return Err(crate::i18n::tr("Another library was opened since the photos were dropped: nothing was moved").to_string()); + } + if *dont_ask { + app.ui.settings.ask_before_move = false; + } + crate::panels::left::move_photos(app, Some(ids), folder).map(|n| json!({"moved": n})) + } // the catalog found: no file dialog; none: File ▸ Import Lightroom Catalog… asks for it Dialog::LightroomCatalog { catalog, .. } => { app.run("file.importLightroom", catalog.as_ref().map_or_else(|| json!({}), |c| json!({"path": c}))) } + Dialog::SaveEffect { name, group, fields, amount, brush } => { + crate::panels::mask_effects::save_confirm(app, name, group, fields, *amount, *brush) + } + Dialog::DeleteEffects { names } => app.run("mask.deleteEffect", json!({"names": names})), Dialog::RemoveFolder { path, disk, .. } => app.run("library.removeFolder", json!({"path": path, "disk": disk})), d @ Dialog::SynchronizeFolder { .. } => crate::sync::confirm(app, d), - Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } => Ok(serde_json::Value::Null), + Dialog::About | Dialog::Shortcuts | Dialog::Settings { .. } | Dialog::ManageEffects { .. } => Ok(serde_json::Value::Null), } } @@ -1821,7 +2028,7 @@ fn export_size(ui: &mut egui::Ui, full: &mut bool, r: &mut lightcraft_engine::ex field(ui, "Resize to", |ui| { let cur = MODES.iter().find(|m| m.0 == r.mode).map_or("Long Edge", |m| m.1); let before = r.mode; - let c = egui::ComboBox::from_id_salt("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { + let c = crate::widgets::combo_box("exportResizeMode").width(150.0).selected_text(crate::i18n::tr(cur)).show_ui(ui, |ui| { for (m, l) in MODES { ui.selectable_value(&mut r.mode, m, crate::i18n::tr(l)); } @@ -1891,6 +2098,60 @@ fn num(ui: &mut egui::Ui, spec: &ControlSpec, v: &mut f64) -> bool { } /// A labelled row (fixed label column). +/// View ▸ Export Slideshow…: what to write (PDF, JPEG sequence, video) and how big; where comes +/// after (a save dialog). +fn slideshow_export_body(app: &mut LightcraftApp, ui: &mut egui::Ui, settings: &mut lightcraft_engine::slideshow_export::ExportSettings) { + use lightcraft_engine::slideshow_export::{ExportSettings, Format}; + let t = Tokens::get(ui.ctx()); + let photos = app.session.visible_cloned().len(); + let slide = app.session.slideshow.slide_duration; + let (intro, ending) = app.session.slide_titles.durations(slide); + let titles = usize::from(intro.is_some()) + usize::from(ending.is_some()); + let seconds = photos as f64 * slide + intro.unwrap_or(0.0) + ending.unwrap_or(0.0); + let summary = crate::i18n::tr_format!( + "{n} photos in view · {slides} slides · {time}", + n = photos, + slides = photos + titles, + time = format!("{}:{:02}", (seconds / 60.0).floor() as u64, (seconds % 60.0).round() as u64) + ); + ui.label(egui::RichText::new(summary).color(t.text_dim)); + let before = settings.format; + choices(ui, "Format", "slideshowExportFormat", &[(Format::Pdf, "PDF"), (Format::Jpeg, "JPEG"), (Format::Video, "Video")], &mut settings.format); + if settings.format != before { + *settings = ExportSettings { full_screen: settings.full_screen, ..ExportSettings::for_format(settings.format) }; + } + match settings.format { + Format::Video => { + choices(ui, "Size", "slideshowExportSize", &[(720, "720p"), (1080, "1080p")], &mut settings.size); + choices(ui, "Frame rate", "slideshowExportFps", &[(24, "24 fps"), (30, "30 fps")], &mut settings.fps); + let mut webm = settings.container == "webm"; + choices(ui, "Container", "slideshowExportContainer", &[(false, "MP4"), (true, "WebM")], &mut webm); + settings.container = if webm { "webm".into() } else { "mp4".into() }; + } + _ => { + choices(ui, "Size", "slideshowExportSize", &[(1024, "1024 px"), (1920, "1920 px"), (3840, "3840 px")], &mut settings.size); + } + } + field(ui, "Quality", |ui| { + let r = ui.add(egui::Slider::new(&mut settings.quality, 1..=100)); + crate::widgets::register(ui.ctx(), "slider:slideshowExportQuality", r.rect); + }); + let note = match settings.format { + Format::Pdf => { + let r = ui.checkbox(&mut settings.full_screen, crate::i18n::tr("Automatically show full screen")); + crate::widgets::register(ui.ctx(), "check:slideshowExportFullScreen", r.rect); + "One page per slide (the long side in pixels = points), with the slide duration and fade for the viewer's presentation mode." + } + Format::Jpeg => "Numbered JPEG files (name-001.jpg…) in a new folder named in the next step.", + Format::Video => "AV1 video, each slide held for its duration with its fades. No sound: music is not exported yet.", + }; + ui.label(egui::RichText::new(crate::i18n::tr(note)).color(t.text_dim)); + ui.label( + egui::RichText::new(crate::i18n::tr("Slides use the slideshow's layout, overlays and intro and ending screens, in view order.")) + .color(t.text_dim), + ); +} + fn field<R>(ui: &mut egui::Ui, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { let t = Tokens::get(ui.ctx()); ui.horizontal(|ui| { @@ -1929,6 +2190,54 @@ fn return_confirms(dlg: &Dialog) -> bool { matches!(dlg, Dialog::CopySettings { .. } | Dialog::PasteSettings { .. }) } +struct PersonRenamePlan { + faces: usize, + needs_merge: bool, + /// Why Rename stays disabled. `None` when the typed name can be applied. + block: Option<&'static str>, +} + +/// What renaming `from` to `to` would do, using the same rules as `faces.renamePerson`. +fn person_rename_plan(cat: &lightcraft_catalog::Catalog, from: &str, to: &str) -> PersonRenamePlan { + let to_trim = to.trim(); + if to_trim.is_empty() { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("Type a name") }; + } + if to_trim.chars().count() > 200 || to_trim.chars().any(char::is_control) { + return PersonRenamePlan { faces: 0, needs_merge: false, block: Some("A name is at most 200 characters, without control characters") }; + } + let from_key = from.trim().to_lowercase(); + let to_key = to_trim.to_lowercase(); + let mut matched = 0usize; + let mut faces = 0usize; + let mut needs_merge = false; + if !from_key.is_empty() { + for photo in cat.photos().filter(|p| p.in_library()) { + for region in photo.meta.regions.iter().filter(|r| r.kind == lightcraft_meta::RegionKind::Face) { + let Some(name) = region.name.as_deref().map(str::trim).filter(|n| !n.is_empty()) else { continue }; + let key = name.to_lowercase(); + if key == from_key { + matched += 1; + if region.name.as_deref() != Some(to_trim) { + faces += 1; + } + } else if key == to_key { + needs_merge = true; + } + } + } + } + let block = if matched == 0 { + Some("No person by that name") + } else if faces == 0 { + Some("That is already their name") + } else { + None + }; + // a case-only change is the same person, not a merge + PersonRenamePlan { faces, needs_merge: needs_merge && from_key != to_key, block } +} + /// A dialog's one text field: it keeps the focus (except while its own menu has it) and opens /// with its text selected, so typing replaces it. True when Return confirms the dialog; Esc /// cancels the dialog (`show`). Taking the focus back with a bare `TextEdit` every frame hid the @@ -1975,3 +2284,36 @@ mod watermark_position_tests { } } } + +/// How a dialog's confirm button looks: [`ConfirmStyle::Primary`] (the accent's fill) when it +/// makes or brings in something, plain when it deletes or removes (never the eye-catching +/// default) or when the dialog only shows something (its Close). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConfirmStyle { + Primary, + Plain, +} + +pub fn confirm_style(dlg: &Dialog) -> ConfirmStyle { + match dlg { + Dialog::ConfirmDelete { .. } | Dialog::DeleteKeyword { .. } | Dialog::DeleteEffects { .. } | Dialog::RemoveFolder { .. } => { + ConfirmStyle::Plain + } + _ if only_shows(dlg) => ConfirmStyle::Plain, + _ => ConfirmStyle::Primary, + } +} + +/// A dialog that only shows something: one Close, nothing to confirm or cancel. +fn only_shows(dlg: &Dialog) -> bool { + matches!( + dlg, + Dialog::About + | Dialog::Shortcuts + | Dialog::Settings { .. } + | Dialog::SystemInfo { .. } + | Dialog::AllMetadata { .. } + | Dialog::WhatsNew + | Dialog::ManageEffects { .. } + ) +} diff --git a/crates/ui-egui/src/panels/edit.rs b/crates/ui-egui/src/panels/edit.rs index 533354b6c..ded1c0bd0 100644 --- a/crates/ui-egui/src/panels/edit.rs +++ b/crates/ui-egui/src/panels/edit.rs @@ -125,7 +125,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("develop.hdr", json!({"enabled": !d.hdr.enabled})); } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - if text_button(ui, "reset", crate::i18n::tr("Reset"), false).on_hover_text(crate::i18n::tr("Reset all edits (Cmd+Shift+R)")).clicked() + if text_button(ui, "reset", crate::i18n::tr("Reset"), false) + .on_hover_text(reset_tip(&app.ui.settings.keymap, ui.ctx().os() == egui::os::OperatingSystem::Mac)) + .clicked() { reset_clicked = true; } @@ -133,9 +135,13 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); }); if let Some(why) = &preview_only { - egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }).show(ui, |ui| { - crate::widgets::preview_only_notice(ui, "edit", why); - }); + let reload = egui::Frame::NONE + .inner_margin(egui::Margin { left: 24, right: 22, top: 0, bottom: 12 }) + .show(ui, |ui| crate::widgets::preview_only_notice(ui, "edit", why)) + .inner; + if reload { + reload_preview_only(app, ui.ctx(), id); + } } let n = app.session.selection.ids.len(); if n > 1 && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { @@ -159,7 +165,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Profile")).font(t.font(13.0)).color(t.text_dim)); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); - let r = crate::widgets::dropdown(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label); + // a long name is cut short before Browse Profiles: a wider row would widen the whole panel's + // content, and the sliders below would run past its edge + let max = ui.available_width() - 28.0 - ui.spacing().item_spacing.x; + let r = crate::widgets::dropdown_within(ui, "profile", crate::i18n::profile_label(&d.profile.id, name), t.font(15.0), t.text_label, max); egui::Popup::menu(&r).show(|ui| profile_menu(app, ui, &d)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if crate::widgets::icon_button( @@ -367,9 +376,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } if lp && !has_lens { ui.label( - egui::RichText::new(crate::i18n::tr("No lens data embedded in this file (DNG lens opcodes only).")) - .size(11.0) - .color(Tokens::get(ui.ctx()).text_dim), + egui::RichText::new(crate::i18n::tr("No lens data embedded in this file.")).size(11.0).color(Tokens::get(ui.ctx()).text_dim), ); } }); @@ -455,7 +462,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) ui.separator(); } for g in profile_groups() { - let r = ui.menu_button(crate::i18n::tr(g), |ui| { + let r = crate::widgets::menu_button(ui, crate::i18n::tr(g), |ui| { ui.set_min_width(170.0); for p in PROFILES.iter().filter(|p| p.group == g) { item(ui, "group", p.id, p.name, &mut pick); @@ -469,7 +476,7 @@ fn profile_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) lut_groups.dedup(); for g in &lut_groups { // a folder's name as it is, and its own widget id even when it matches a built-in group - let r = ui.menu_button(g.as_str(), |ui| { + let r = crate::widgets::menu_button(ui, g.as_str(), |ui| { ui.set_min_width(170.0); for p in app.session.lut_profiles.iter().filter(|p| &p.group == g) { item(ui, "group", &p.id, &p.name, &mut pick); @@ -531,8 +538,23 @@ fn section( if let Some(on) = toggled { let _ = app.run("develop.sectionEnabled", json!({"section": id, "enabled": on})); } else if resp.clicked() { - app.ui.toggle_section(id); + // ⌥-click opens just this section, as in Lightroom Classic + let solo = ui.input(|i| i.modifiers.alt); + if let Err(e) = app.run(&format!("section.{id}"), json!({"solo": solo})) { + app.toast(ui.ctx(), e); + } } + resp.context_menu(|ui| { + let mut solo = app.ui.single_panel; + let item = ui.checkbox(&mut solo, crate::i18n::tr("Solo Mode")); + register(ui.ctx(), "sectionMenu:solo", item.rect); + if item.clicked() { + if let Err(e) = app.run("view.soloSections", json!({"on": solo})) { + app.toast(ui.ctx(), e); + } + ui.close(); + } + }); if open { body(app, ui, d); } @@ -592,6 +614,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let hdr = hist.as_ref().and_then(|h| h.hdr); let legend = if hdr.is_some() { 26.0 } else { 0.0 }; let (r, resp) = ui.allocate_exact_size(vec2(w, 118.0 + legend), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr("Histogram")); register(ui.ctx(), "histogram", r); let p = ui.painter(); p.rect_filled(r, 0.0, t.button); @@ -620,6 +643,7 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { "Left: the standard (SDR) range up to SDR white. Right: the HDR range, one band per stop above SDR white (the orange line: the HDR headroom). With Visualize HDR range on, the bands take its colours.", )); } + let clipping = hist.as_ref().map(|h| h.clipping()); if let Some(h) = hist { let smooth = |v: &[u32]| -> Vec<f32> { (0..256) @@ -678,15 +702,62 @@ fn histogram(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { p.add(egui::Shape::line(pts, Stroke::new(1.5, *c))); } } - let (lo, hi) = h.clipping(); - for (on, x, active) in [(lo > 0.003, plot.left() + 12.0, app.ui.show_clipping), (hi > 0.003, plot.right() - 12.0, app.ui.show_clipping)] { - let tri = Rect::from_center_size(pos2(x, plot.top() + 10.0), vec2(14.0, 12.0)); - p.rect_filled(tri, 2.0, if active { t.hover } else { Color32::from_gray(60) }); - paint(p, tri.shrink(2.0), Icon::ChevronDown, if on { Color32::WHITE } else { Color32::from_gray(130) }); - } } - if resp.clicked() { - app.ui.show_clipping = !app.ui.show_clipping; + let mut clicked = resp.clicked(); + // the panel's floating scroll bar widens over the right edge when hovered: both buttons keep + // clear of it, the left one too so they stay symmetric (#1640) + let scroll = ui.spacing().scroll; + let inset = 20.0 + scroll.bar_width + scroll.bar_outer_margin; + for (name, x, label, clipped) in [ + ("shadows", plot.left() + inset, "Shadow clipping", clipping.map(|(lo, _)| lo > 0.003)), + ("highlights", plot.right() - inset, "Highlight clipping", clipping.map(|(_, hi)| hi > 0.003)), + ] { + let rect = Rect::from_center_size(pos2(x, plot.top() + 14.0), vec2(24.0, 22.0)); + let response = ui.interact(rect, ui.id().with(("histogram-clipping", name)), Sense::click()); + register(ui.ctx(), format!("histogram:{name}"), rect); + response.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, ui.is_enabled(), app.ui.show_clipping, crate::i18n::tr(label))); + clicked |= response.clicked(); + let active = app.ui.show_clipping; + // Keep the hit target comfortable, with a smaller box and clear space to the plot edges. + let box_rect = rect.shrink2(vec2(2.0, 2.0)); + p.rect_filled( + box_rect, + 3.0, + if active { + t.pressed + } else if response.hovered() { + t.hover + } else { + Color32::from_gray(60) + }, + ); + if active { + p.rect_stroke(box_rect, 3.0, Stroke::new(1.0, t.text), egui::StrokeKind::Inside); + } + paint( + p, + Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), + Icon::ChevronDown, + if clipped == Some(true) || active { t.text } else { t.text_dim }, + ); + let status = match clipped { + Some(true) => "Clipping detected in this preview.", + Some(false) => "No clipping detected in this preview.", + None => "Histogram not yet available.", + }; + response.on_hover_text(format!( + "{}\n{}\n{}", + crate::i18n::tr(label), + crate::i18n::tr(if active { + "Click to hide clipping warnings for shadows and highlights." + } else { + "Click to show clipping warnings for shadows and highlights." + }), + crate::i18n::tr(status) + )); + } + if clicked { + let _ = app.run("view.clipping", json!({})); } // EXIF strip if let Some(ph) = app.session.catalog.photo(id) { @@ -741,7 +812,7 @@ fn soft_proofing(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let device = app.ui.proof_profile.as_ref().map(|(_, n)| n.clone()); let shown = device.clone().unwrap_or_else(|| cur.label().to_string()); let mut pick = false; - egui::ComboBox::from_id_salt("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { + crate::widgets::combo_box("proof-space").width(170.0).selected_text(shown).show_ui(ui, |ui| { for s in OutputSpace::ALL { if ui.selectable_label(device.is_none() && cur == s, s.label()).clicked() { let _ = app.run("view.softProof", json!({"space": s})); @@ -854,6 +925,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev ("blue", hex("#3377ee")), ] { let (r, resp) = ui.allocate_exact_size(vec2(24.0, 24.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {ch}", crate::i18n::tr("Curve channel"))); register(ui.ctx(), format!("curveChannel:{ch}"), r); let sel = app.ui.curve_channel == ch; if ch == "parametric" { @@ -893,6 +965,7 @@ fn curve_editor(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId, d: &Dev let (outer, _) = ui.allocate_exact_size(vec2(w, side + 12.0), Sense::hover()); let r = Rect::from_min_size(pos2(outer.left() + 24.0, outer.top() + 6.0), vec2(side, side)); let resp = ui.interact(r, egui::Id::new(("curve", &ch)), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Tone Curve")); register(ui.ctx(), "curve", r); resp.context_menu(|ui| curve_reset_menu(app, ui, &ch)); let p = ui.painter(); @@ -1209,6 +1282,7 @@ fn mixer(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) { ui.spacing_mut().item_spacing.x = 5.0; for (i, b) in bands.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(b)); register(ui.ctx(), format!("mixerBand:{b}"), r); ui.painter().circle_filled(r.center(), 9.0, hex(BAND_COLORS[i])); if i == sel { @@ -1300,6 +1374,7 @@ fn point_color(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings) } for (i, p) in d.point_colors.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Point Color"), i + 1)); register(ui.ctx(), format!("pointColor:{i}"), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, oklch_color(p.lum, p.chroma, p.hue)); if i == sel { @@ -1435,6 +1510,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings let zone = r.expand(10.0); let hovering = ui.rect_contains_pointer(zone); let resp = ui.interact(r, egui::Id::new(("wheel", key)), Sense::click_and_drag()); + crate::widgets::announce(&resp, format!("{} {key}", crate::i18n::tr("Color wheel"))); let resp = resp.on_hover_text(crate::i18n::tr( "Drag to set hue and saturation. Hold Shift to keep the hue, Cmd/Ctrl to keep the saturation, Alt/Option to fine-adjust. Double-click to reset.", )); @@ -1524,6 +1600,7 @@ fn grading_wheel(app: &mut LightcraftApp, ui: &mut egui::Ui, d: &DevelopSettings if hovering || hidden { let eye = Rect::from_center_size(r.left_bottom() + vec2(2.0, -2.0), vec2(16.0, 16.0)); let er = ui.interact(eye, egui::Id::new(("wheelEye", key)), Sense::click()); + crate::widgets::announce(&er, crate::i18n::tr("Show / hide")); register(ui.ctx(), format!("wheelEye:{key}"), eye); paint(ui.painter(), eye, if hidden { Icon::EyeOff } else { Icon::Eye }, if hidden { t.text_disabled } else { t.icon }); let er = er.on_hover_text(crate::i18n::tr(if hidden { "Show this wheel's effect" } else { "Hide this wheel's effect" })); @@ -1629,20 +1706,47 @@ pub(crate) fn wheel_pick(v: egui::Vec2, rad: f32, lock: Option<WheelLock>) -> (f } fn paint_wheel(p: &egui::Painter, c: Pos2, rad: f32) { + p.add(wheel_mesh(c, rad)); + p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); +} + +/// The picture of a grading wheel: a hue disc whose rim is fully saturated display sRGB at full +/// brightness, fading to a neutral grey at the centre. It is built from concentric rings so the +/// saturation ramps evenly with the radius instead of one fan of triangles blending each hue +/// straight into grey (which made the whole disc look muddy, issue #77). Only the drawing: +/// what a position means for the edit is [`wheel_pick`]. +pub(crate) fn wheel_mesh(c: Pos2, rad: f32) -> Mesh { + const SEGMENTS: u32 = 72; + const RINGS: u32 = 12; + /// Brightness (HSV value) at the centre; the rim is 1.0. + const CENTRE_V: f32 = 0.6; + let colour = |hue_deg: f32, t: f32| { + let rgb = lightcraft_color::perceptual::hsv_to_rgb(hue_deg, t, CENTRE_V + (1.0 - CENTRE_V) * t); + let u = |x: f32| (x.clamp(0.0, 1.0) * 255.0).round() as u8; + Color32::from_rgb(u(rgb[0]), u(rgb[1]), u(rgb[2])) + }; let mut mesh = Mesh::default(); - let n = 48; - mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: Color32::from_gray(128) }); - for i in 0..=n { - let a = i as f32 / n as f32 * std::f32::consts::TAU; - let rgb = lightcraft_color::perceptual::hsv_to_rgb(a.to_degrees(), 0.75, 0.8); - let col = Color32::from_rgb((rgb[0] * 255.0) as u8, (rgb[1] * 255.0) as u8, (rgb[2] * 255.0) as u8); - mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad, uv: Pos2::ZERO, color: col }); - if i > 0 { - mesh.indices.extend([0, i as u32, i as u32 + 1]); + mesh.vertices.push(Vertex { pos: c, uv: Pos2::ZERO, color: colour(0.0, 0.0) }); + // ring k (1..=RINGS) holds SEGMENTS + 1 vertices starting at index 1 + (k - 1) * (SEGMENTS + 1) + let row = SEGMENTS + 1; + for k in 1..=RINGS { + let t = k as f32 / RINGS as f32; + for i in 0..=SEGMENTS { + let a = i as f32 / SEGMENTS as f32 * std::f32::consts::TAU; + mesh.vertices.push(Vertex { pos: c + vec2(a.cos(), -a.sin()) * rad * t, uv: Pos2::ZERO, color: colour(a.to_degrees(), t) }); } } - p.add(mesh); - p.circle_stroke(c, rad, Stroke::new(1.0, Color32::from_gray(40))); + for i in 0..SEGMENTS { + mesh.indices.extend([0, 1 + i, 2 + i]); + } + for k in 1..RINGS { + let inner = 1 + (k - 1) * row; + let outer = inner + row; + for i in 0..SEGMENTS { + mesh.indices.extend([inner + i, outer + i, outer + i + 1, inner + i, outer + i + 1, inner + i + 1]); + } + } + mesh } /// Quick Develop (grid with several photos selected): relative steps applied to every selected @@ -1666,8 +1770,21 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { egui::Grid::new("quick-develop").num_columns(5).spacing([4.0, 3.0]).show(ui, |ui| { for (label, ctl, small, big) in rows { ui.label(egui::RichText::new(crate::i18n::tr(label)).size(11.5).color(t.text_dim)); - for (txt, d) in [("◀◀", -big), ("◀", -small), ("▶", small), ("▶▶", big)] { - let r = ui.add(egui::Button::new(egui::RichText::new(txt).size(10.0)).min_size(egui::vec2(28.0, 18.0))); + // Keep the existing automation ids while drawing all step arrows as vectors. + for (txt, icon, d) in [ + ("◀◀", Icon::ChevronDoubleLeft, -big), + ("◀", Icon::ChevronLeft, -small), + ("▶", Icon::ChevronRight, small), + ("▶▶", Icon::ChevronDoubleRight, big), + ] { + let r = crate::widgets::arrow_button(ui, "", icon, false); + r.widget_info(|| { + egui::WidgetInfo::labeled( + egui::WidgetType::Button, + ui.is_enabled(), + crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label)), + ) + }); crate::widgets::register(ui.ctx(), format!("button:quick-{ctl}-{txt}"), r.rect); if r.on_hover_text(crate::i18n::tr_format!("{label} {d:+} on every selected photo", d = d, label = crate::i18n::tr(label))) .clicked() @@ -1681,6 +1798,27 @@ fn quick_develop(app: &mut LightcraftApp, ui: &mut egui::Ui, n: usize) { }); } +/// The preview-only notice's Reload from Disk: re-read the photo's file (a raw a newer version can +/// decode, whose drive was offline when the library opened), and say what came of it. +pub(crate) fn reload_preview_only(app: &mut LightcraftApp, ctx: &egui::Context, id: PhotoId) { + let r = app.run("photo.reload", json!({"ids": [id.0]})); + let decoded = app.session.catalog.photo(id).is_some_and(|p| p.preview_only.is_none()); + match r { + Err(e) => app.toast_error(ctx, e), + Ok(_) if decoded => app.toast(ctx, crate::i18n::tr("This raw is now developed from its raw data")), + Ok(_) => app.toast(ctx, crate::i18n::tr("This raw variant still can't be decoded")), + } +} + +/// The Reset button's tooltip with the reset shortcut as this platform writes it (issue #1751). +fn reset_tip(keymap: &crate::shortcuts::Keymap, mac: bool) -> String { + let base = crate::i18n::tr("Reset all edits"); + match crate::shortcuts::shortcut_of(keymap, "develop.reset") { + Some(sc) => format!("{base} ({})", crate::menubar::shortcut_text(sc, mac)), + None => base.to_string(), + } +} + #[cfg(test)] mod curve_histogram_tests { use super::*; @@ -1730,4 +1868,16 @@ mod tests { assert_eq!(steady_hdr_histogram(&ctx, id, false, Some(sdr.clone())), Some(sdr.clone())); assert_eq!(steady_hdr_histogram(&ctx, PhotoId(8), true, Some(sdr.clone())), Some(sdr)); } + + /// Issue #1751: Windows and Linux read Ctrl where macOS reads Cmd, and a rebound shortcut shows. + #[test] + fn reset_tooltip_names_the_shortcut_of_this_platform() { + let keymap = crate::shortcuts::Keymap::new(); + assert_eq!(reset_tip(&keymap, false), "Reset all edits (Ctrl+Shift+R)"); + assert_eq!(reset_tip(&keymap, true), "Reset all edits (⌘⇧R)"); + let rebound: crate::shortcuts::Keymap = [("develop.reset".to_string(), "Cmd+Alt+R".to_string())].into(); + assert_eq!(reset_tip(&rebound, false), "Reset all edits (Ctrl+Alt+R)"); + let unbound: crate::shortcuts::Keymap = [("develop.reset".to_string(), String::new())].into(); + assert_eq!(reset_tip(&unbound, false), "Reset all edits"); + } } diff --git a/crates/ui-egui/src/panels/empty_library.rs b/crates/ui-egui/src/panels/empty_library.rs new file mode 100644 index 000000000..d420ee1a0 --- /dev/null +++ b/crates/ui-egui/src/panels/empty_library.rs @@ -0,0 +1,76 @@ +//! A library with no photos yet: what the grid and the Detail view show instead of a dead end, +//! with Import Photos… and Import Lightroom Catalog… as buttons, and the view it opens on. + +use egui::Rect; +use serde_json::json; + +use super::EmptyAction; +use crate::LightcraftApp; +use crate::shortcuts::Keymap; +use crate::state::ViewMode; + +/// The library holds no photos: none, or only ones in Recently Deleted or browsed under Local. +pub fn is_empty(session: &lightcraft_engine::Session) -> bool { + !session.catalog.photos().any(|p| p.in_library()) +} + +/// What is shown is the empty library: not a folder browsed under Local nor Recently Deleted, +/// whose photos aren't in the library but are on screen. +pub fn shown(session: &lightcraft_engine::Session) -> bool { + use lightcraft_engine::LibrarySource; + !matches!(session.source, LibrarySource::Folder | LibrarySource::RecentlyDeleted) && is_empty(session) +} + +/// At launch, an empty library opens on a grid with the sidebar, where the way in is (Detail had +/// only "Choose a photo in the grid" to say). Square Grid stays as it is; a library with photos +/// opens as left. The desktop host calls this before it notes what its settings file holds, so +/// the switch isn't saved for its own sake. +pub fn open_on_empty_library(app: &mut LightcraftApp) { + if is_empty(&app.session) { + if app.ui.view != ViewMode::SquareGrid { + app.ui.view = ViewMode::PhotoGrid; + } + app.ui.left_panel = true; + } +} + +/// The line under the buttons: dropping works anywhere in the window, and the shortcut is the +/// one Import Photos… is bound to now, written as menus write it. +pub fn hint(keymap: &Keymap, mac: bool) -> String { + // the browser build reads dropped files, not folders + let drop = if cfg!(target_arch = "wasm32") { + crate::i18n::tr("or drop photos into this window") + } else { + crate::i18n::tr("or drop photos and folders into this window") + }; + match crate::shortcuts::shortcut_of(keymap, "file.addPhotos") { + Some(sc) => format!("{drop} · {}", crate::menubar::shortcut_text(sc, mac)), + None => drop.to_string(), + } +} + +const IMPORT_PHOTOS: EmptyAction = EmptyAction { command: "file.addPhotos", label: "Import Photos…", widget: "button:emptyImportPhotos" }; +const IMPORT_LIGHTROOM: EmptyAction = + EmptyAction { command: "file.importLightroom", label: "Import Lightroom Catalog…", widget: "button:emptyImportLightroom" }; + +/// The empty library's message and buttons, centred in `rect`. A click runs the command as its +/// menu item does; a refusal shows as a toast. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, rect: Rect) { + // the browser build can't read a Lightroom catalog from disk: only offer what works there + let actions: &[EmptyAction] = if cfg!(target_arch = "wasm32") { &[IMPORT_PHOTOS] } else { &[IMPORT_PHOTOS, IMPORT_LIGHTROOM] }; + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let hint = hint(&app.ui.settings.keymap, mac); + let clicked = super::empty_state( + ui, + rect, + "Your library is empty", + "Photos you import show up here. Nothing is copied or moved unless you choose to.", + actions, + Some(&hint), + ); + if let Some(command) = clicked + && let Err(e) = app.run(command, json!({})) + { + app.toast_error(ui.ctx(), e); + } +} diff --git a/crates/ui-egui/src/panels/faces.rs b/crates/ui-egui/src/panels/faces.rs index 2b3fe0329..f1eb97caf 100644 --- a/crates/ui-egui/src/panels/faces.rs +++ b/crates/ui-egui/src/panels/faces.rs @@ -27,6 +27,13 @@ fn scan_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> (&'static st (pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, watching), focused && !minimized) } +/// The pace for other background work that reads the library's files (the focus-point reader): the face scan's, without +/// the "watching" speed-up. +pub(crate) fn background_pace(app: &LightcraftApp, ctx: &egui::Context, now: f64) -> &'static str { + let (focused, minimized) = ctx.input(|i| (i.focused, i.raw.viewports.get(&i.raw.viewport_id).and_then(|v| v.minimized).unwrap_or(false))); + pace_for(focused, minimized, now - app.caches.last_input, now - app.caches.last_move, false) +} + /// The pace for a window that has the keyboard (or not) and is minimized (or not), `worked` seconds after the user last /// dragged, typed or scrolled and `moved` seconds after they last moved the pointer, while they are (or are not) looking /// at the scan's progress. @@ -157,6 +164,42 @@ pub fn setup_banner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } +/// Where the face models run (the GPU only once a check shows it gives the CPU's results faster), and the switch that +/// keeps them on the CPU. +fn gpu_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, gpu: &Value) { + heading(ui, t, "Processor"); + let mut cpu = gpu["cpuOnly"].as_bool().unwrap_or(false); + let r = ui.checkbox(&mut cpu, crate::i18n::tr("Run face models on the CPU only")); + register(ui.ctx(), "check:settings.facesCpuOnly", r.rect); + if r.changed() { + match app.run("faces.gpu", json!({"cpuOnly": cpu})) { + Ok(_) => app.caches.faces_epoch += 1, + Err(e) => app.toast(ui.ctx(), e), + } + } + for (what, label) in [("detection", "Detection"), ("recognition", "Recognition")] { + let st = &gpu[what]; + let (text, why) = if st.is_null() { + (crate::i18n::tr("Not loaded yet").to_string(), None) + } else if st["checking"] == true { + (crate::i18n::tr("Checking the GPU…").to_string(), None) + } else if let Some(device) = st["device"].as_str() { + (device.to_string(), None) + } else { + (crate::i18n::tr("CPU").to_string(), st["why"].as_str()) + }; + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr(label)).color(t.text_dim)); + let r = ui.label(text); + register(ui.ctx(), format!("faces:device:{what}"), r.rect); + if let Some(why) = why { + r.on_hover_text(sentence(why)); + } + }); + } + hint(ui, t, "A face model uses the GPU only when a check shows it gives the same results faster."); +} + /// A download-style size: decimal megabytes, as file managers and model pages show them. fn mb(bytes: Option<u64>) -> String { match bytes { @@ -170,9 +213,7 @@ fn mb(bytes: Option<u64>) -> String { /// "its input is not an image" → "Its input is not an image." fn sentence(s: &str) -> String { let s = s.trim(); - let mut c = s.chars(); - let mut out: String = c.next().map(|f| f.to_uppercase().collect()).unwrap_or_default(); - out.push_str(c.as_str()); + let mut out = crate::i18n::upper_first(s); if !out.is_empty() && !out.ends_with(['.', '!', '?']) { out.push('.'); } @@ -231,6 +272,7 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { let all: Vec<Value> = list["models"].as_array().cloned().unwrap_or_default(); let downloads = app.session.execute("faces.models.downloads", &json!({})).map(|v| v["downloads"].as_array().cloned().unwrap_or_default()).unwrap_or_default(); + gpu_section(app, ui, t, &list["gpu"]); heading(ui, t, "Face detector"); for m in all.iter().filter(|m| m["role"] == "detector") { model_row(app, ui, t, m, downloads.iter().find(|d| d["id"] == m["id"]), runtime); @@ -245,12 +287,12 @@ pub fn settings_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ui.add_space(6.0); ui.horizontal(|ui| { let can = app.services.pick_model_file.is_some(); - let r = ui.add_enabled(can, egui::Button::new("Add a model file…")); + let r = ui.add_enabled(can, egui::Button::new(crate::i18n::tr("Add a model file…"))); register(ui.ctx(), "faces:addModel", r.rect); if r.clicked() { let _ = app.run("dialog.faceModel", json!({})); } - ui.label(RichText::new("or drop a .onnx file on the window").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("or drop a .onnx file on the window")).color(t.text_dim)); }); // what is wrong with the user's own catalog (catalog.json in the models folder), if anything for e in list["catalog"]["errors"].as_array().into_iter().flatten().filter_map(Value::as_str).take(3) { @@ -293,9 +335,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, ui.label(RichText::new(size).color(t.text_dim)); } if selected { - ui.label(RichText::new("in use").color(t.accent)); + ui.label(RichText::new(crate::i18n::tr("in use")).color(t.accent)); } else if installed { - ui.label(RichText::new("installed").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("installed")).color(t.text_dim)); } }); ui.label(RichText::new(licence_line(m)).font(t.font(11.5)).color(if m["licence"]["commercial"] == "yes" { @@ -329,7 +371,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, register(ui.ctx(), format!("faces:progress:{id}"), bar.rect); } (Some("done"), _) => { - ui.label(RichText::new("Downloaded and checked. Installing…").font(t.font(11.5)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Downloaded and checked. Installing…")).font(t.font(11.5)).color(t.text_dim)); } (Some("failed"), Some(d)) => { let why = sentence(d["error"].as_str().unwrap_or("The download failed")); @@ -340,7 +382,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, }); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if installed { - let r = ui.button("Remove"); + let r = ui.button(crate::i18n::tr("Remove")); register(ui.ctx(), format!("faces:remove:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.remove", json!({"id": id})); @@ -357,7 +399,7 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, } else { match dl_state { Some("running") => { - let r = ui.button("Cancel"); + let r = ui.button(crate::i18n::tr("Cancel")); register(ui.ctx(), format!("faces:cancelDownload:{id}"), r.rect); if r.clicked() { let _ = app.run("faces.models.downloadCancel", json!({"id": id})); @@ -368,9 +410,9 @@ fn model_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, m: &Value, Some("done") => {} _ => { if let Some(url) = m["source"].as_str() { - let r = ui - .button("Open page") - .on_hover_text("Opens the model's own page in your browser, to read about it or get the file yourself."); + let r = ui.button(crate::i18n::tr("Open page")).on_hover_text(crate::i18n::tr( + "Opens the model's own page in your browser, to read about it or get the file yourself.", + )); register(ui.ctx(), format!("faces:get:{id}"), r.rect); if r.clicked() { open_page(app, url); @@ -403,7 +445,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info let kind = info["kind"].as_str().unwrap_or("unsupported"); let file = info["fileName"].as_str().unwrap_or(""); if kind == "unsupported" { - ui.label(RichText::new("This file cannot be used yet").font(t.semibold(13.5)).color(t.caution)); + ui.label(RichText::new(crate::i18n::tr("This file cannot be used yet")).font(t.semibold(13.5)).color(t.caution)); ui.add( egui::Label::new( RichText::new(sentence(info["reason"].as_str().unwrap_or("It is not a face recognition model LightCraft understands"))) @@ -420,7 +462,7 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info ui.label(RichText::new(mb(info["sizeBytes"].as_u64())).color(t.text_dim)); }); if info["alreadyInstalled"] == true { - ui.label(RichText::new("Already installed. Installing again is harmless.").color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Already installed. Installing again is harmless.")).color(t.text_dim)); } let download = info["download"].is_string(); let detector = m["role"] == "detector"; @@ -445,11 +487,13 @@ pub fn model_dialog(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, info } let provenance = m["provenance"].as_str().unwrap_or(""); if !provenance.is_empty() { - ui.add(egui::Label::new(RichText::new(format!("Trained on: {provenance}")).color(t.text_dim)).wrap()); + ui.add( + egui::Label::new(RichText::new(crate::i18n::tr_format!("Trained on: {provenance}", provenance = provenance)).color(t.text_dim)).wrap(), + ); } if kind == "draft" { ui.add_space(2.0); - ui.label(RichText::new("LightCraft does not know this model, so it assumed:").color(t.text_label)); + ui.label(RichText::new(crate::i18n::tr("LightCraft does not know this model, so it assumed:")).color(t.text_label)); for a in info["assumptions"].as_array().into_iter().flatten().filter_map(Value::as_str) { ui.add(egui::Label::new(RichText::new(format!("• {a}")).font(t.font(12.0)).color(t.text_dim)).wrap()); } @@ -640,7 +684,7 @@ pub fn name_editor( outcome = Editor::Setup; } } - ui.label(RichText::new("Enter to confirm, Esc to cancel").font(t.font(11.0)).color(t.text_dim)); + ui.label(RichText::new(crate::i18n::tr("Enter to confirm, Esc to cancel")).font(t.font(11.0)).color(t.text_dim)); }); }); if ctx.input(|i| i.key_pressed(egui::Key::Escape)) { diff --git a/crates/ui-egui/src/panels/filterbar.rs b/crates/ui-egui/src/panels/filterbar.rs index c0962b59f..b35335cc9 100644 --- a/crates/ui-egui/src/panels/filterbar.rs +++ b/crates/ui-egui/src/panels/filterbar.rs @@ -32,6 +32,7 @@ fn caption(ui: &mut egui::Ui, text: &str) { fn toggle(ui: &mut egui::Ui, id: &str, on: bool, tip: &str, draw: impl FnOnce(&egui::Painter, Rect, Color32)) -> egui::Response { let t = Tokens::get(ui.ctx()); let (r, resp) = ui.allocate_exact_size(vec2(26.0, 26.0), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr(tip), on); register(ui.ctx(), format!("filter:{id}"), r); let p = ui.painter(); if on { @@ -131,6 +132,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); for i in 1..=5u8 { let (r, resp) = ui.allocate_exact_size(vec2(17.0, 26.0), Sense::click()); + crate::widgets::announce(&resp, format!("{i} ★")); register(ui.ctx(), format!("filter:star{i}"), r); let filled = i <= f.rating; let c = if filled { @@ -280,7 +282,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let _ = app.run("library.clearFilter", json!({})); } // filter presets: apply one, save the current filter, delete - let presets = ui.button(egui::RichText::new(crate::i18n::tr("Presets ▾")).font(t.font(12.5))); + let presets = crate::widgets::dropdown(ui, "filterPresets", crate::i18n::tr("Presets"), t.font(12.5), t.text); register(ui.ctx(), "button:filterPresets", presets.rect); egui::Popup::menu(&presets).show(|ui| { let names: Vec<String> = app.session.filter_presets.iter().map(|f| f.name.clone()).collect(); diff --git a/crates/ui-egui/src/panels/grid.rs b/crates/ui-egui/src/panels/grid.rs index 52edfa460..c6365b784 100644 --- a/crates/ui-egui/src/panels/grid.rs +++ b/crates/ui-egui/src/panels/grid.rs @@ -192,6 +192,8 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { ); } else if app.session.source == lightcraft_engine::LibrarySource::Folder { super::empty_message(ui, ui.max_rect(), "No photos in this folder", "Turn on Include subfolders, or pick another folder under Local"); + } else if super::empty_library::shown(&app.session) { + super::empty_library::show(app, ui, ui.max_rect()); } else { super::empty_message(ui, ui.max_rect(), "No photos", "Import photos with File → Import Photos… (Cmd+Shift+I), or drop them here"); } @@ -236,7 +238,14 @@ fn show_inner(app: &mut LightcraftApp, ui: &mut egui::Ui) { }; let mut visible_ids = HashSet::new(); let mut visited = 0u64; + // a wheel notch scrolls a third of the grid's height + let scroll_rect = ui.available_rect_before_wrap(); + crate::widgets::register(ui.ctx(), "grid:scroll", scroll_rect); + let wheel = wheel_step(ui, egui::Id::new("grid-wheel"), scroll_rect, scroll_rect.height(), false); egui::ScrollArea::vertical().id_salt("grid-scroll").auto_shrink([false, false]).show_viewport(ui, |ui, viewport| { + if wheel != egui::Vec2::ZERO { + ui.scroll_with_delta_animation(wheel, egui::style::ScrollAnimation::none()); + } let (area, _) = ui.allocate_exact_size(vec2(ui.available_width(), total_h), Sense::hover()); let origin = area.min; app.grid_scroll = Some(viewport.top()); @@ -318,6 +327,43 @@ pub(crate) fn follow_changed( was != active || where_was != source || drawn + 1 < pass } +/// How far a mouse wheel scrolls a photo strip this frame (`key`: the grid, the filmstrip; `area`: its +/// rect on screen; `span`: its visible height, or width when `sideways`), for `Ui::scroll_with_delta_animation` +/// inside its `ScrollArea`. A notch scrolls [`crate::theme::WHEEL_STRIP_SHARE`] (a third) of `span`, as in +/// Lightroom (about two rows of a six-row grid), instead of the [`crate::theme::WHEEL_LINE_POINTS`] a side +/// panel scrolls. Not whole rows: the photos land somewhere else after each notch, so the move shows. It +/// jumps at once instead of gliding, so the photos stay sharp while scrolling; egui's glide of the notch is +/// swallowed until it ends. Trackpads report points and scroll as before. A vertical wheel scrolls a +/// `sideways` strip. +pub(crate) fn wheel_step(ui: &egui::Ui, key: egui::Id, area: Rect, span: f32, sideways: bool) -> egui::Vec2 { + if !ui.rect_contains_pointer(area) || !span.is_finite() { + ui.data_mut(|d| d.remove_temp::<bool>(key)); + return egui::Vec2::ZERO; + } + let (notches, gliding) = ui.input(|i| { + let notches = i.events.iter().fold(egui::Vec2::ZERO, |sum, e| match e { + egui::Event::MouseWheel { unit: egui::MouseWheelUnit::Line, delta, modifiers, .. } + if *modifiers == egui::Modifiers::NONE && delta.is_finite() => + { + sum + *delta + } + _ => sum, + }); + (notches, i.smooth_scroll_delta != egui::Vec2::ZERO) + }); + if notches != egui::Vec2::ZERO { + ui.data_mut(|d| d.insert_temp(key, true)); + } else if !gliding { + ui.data_mut(|d| d.remove_temp::<bool>(key)); + } + if !ui.data(|d| d.get_temp::<bool>(key)).unwrap_or(false) { + return egui::Vec2::ZERO; + } + ui.ctx().input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let step = span * crate::theme::WHEEL_STRIP_SHARE; + if sideways { egui::vec2((notches.x + notches.y) * step, 0.0) } else { egui::vec2(0.0, notches.y * step) } +} + /// Height of a date header row (points). pub const HEADER_H: f32 = 40.0; @@ -458,6 +504,7 @@ pub fn layout(aspects: &[f32], groups: &[(usize, usize)], avail_w: f32, target: fn group_header(app: &mut LightcraftApp, ui: &mut egui::Ui, run: &DateRun, ids: &[PhotoId], r: Rect, pinned: bool) { let t = Tokens::get(ui.ctx()); let resp = ui.interact(r, egui::Id::new(("group-header", run.start, pinned)), Sense::click()); + crate::widgets::announce(&resp, if run.key.is_empty() { crate::i18n::tr("Unknown date").to_string() } else { run.key.clone() }); if !pinned { register(ui.ctx(), format!("group:{}", if run.key.is_empty() { "unknown" } else { &run.key }), r); } @@ -827,10 +874,15 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { // Prefer the current folder, showing more ancestors as space permits. let visible = ((ui.available_width() / 80.0) as usize).clamp(1, 4); let skip = parts.len().saturating_sub(visible); + let separator = |ui: &mut egui::Ui| { + let (r, _) = ui.allocate_exact_size(vec2(16.0, 24.0), Sense::hover()); + paint(ui.painter(), Rect::from_center_size(r.center(), vec2(16.0, 16.0)), Icon::ChevronRight, t.text_dim); + }; if skip > 0 { - ui.label(egui::RichText::new("… ›").color(t.text_dim)).on_hover_text(path); + ui.label(egui::RichText::new("…").color(t.text_dim)).on_hover_text(path); + separator(ui); } - let separator_w = ui.painter().layout_no_wrap("›".into(), t.font(13.0), t.text_dim).size().x + 8.0; + let separator_w = 16.0 + 2.0 * ui.spacing().item_spacing.x; for (i, part) in parts.iter().enumerate().skip(skip) { let remaining = parts.len() - i; let last = remaining == 1; @@ -848,7 +900,7 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { { let _ = app.run("library.browse", json!({"path": to})); } - ui.label(egui::RichText::new("›").size(13.0).color(t.text_dim)); + separator(ui); } r.on_hover_text(path); } @@ -856,6 +908,13 @@ fn folder_breadcrumbs(app: &mut LightcraftApp, ui: &mut egui::Ui, path: &str) { /// While photos are dragged: a badge at the pointer; the drag ends when the button is up /// (drop targets act on the release frame, before this runs). +/// What the label following dragged photos says this frame instead of how many there are: where a +/// drop would put them (set by the drop target under the pointer, read by [`drag_feedback`]). +pub fn drag_hint(ctx: &egui::Context, text: String) { + let frame = ctx.cumulative_frame_nr(); + ctx.data_mut(|d| d.insert_temp(egui::Id::new("drag-photos-hint"), (frame, text))); +} + pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let Some(ids) = &app.ui.dragging_photos else { return }; let (released, down, pos) = ctx.input(|i| (i.pointer.any_released(), i.pointer.any_down(), i.pointer.latest_pos())); @@ -867,13 +926,14 @@ pub fn drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { let t = Tokens::get(ctx); let n = ids.len(); ctx.set_cursor_icon(egui::CursorIcon::Grabbing); + // a drop target under the pointer says where they would go (only this frame's) + let frame = ctx.cumulative_frame_nr(); + let hint = ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).filter(|(f, _)| *f == frame).map(|(_, text)| text); + let text = hint.unwrap_or_else(|| crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)); egui::Area::new(egui::Id::new("drag-photos")).order(egui::Order::Tooltip).interactable(false).fixed_pos(pos + vec2(14.0, 10.0)).show(ctx, |ui| { egui::Frame::NONE.fill(t.accent).corner_radius(10.0).inner_margin(egui::Margin::symmetric(9, 3)).show(ui, |ui| { - ui.label( - egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n)) - .color(Color32::WHITE) - .font(t.semibold(12.0)), - ); + let label = ui.add(egui::Label::new(egui::RichText::new(text).color(Color32::WHITE).font(t.semibold(12.0))).extend()); + crate::widgets::register(ui.ctx(), "dragPhotos", label.rect); }); }); } @@ -989,7 +1049,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // Photo ▸ Edit In: a copy with this photo's edits, opened in PhotoCraft or another editor // (desktop only: the web can't start programs) ui.add_enabled_ui(crate::menus::ui_enabled(app, "photo.editInPhotoCraft"), |ui| { - ui.menu_button(crate::i18n::tr("Edit In"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Edit In"), |ui| { if ui.button(crate::i18n::tr("Edit in PhotoCraft")).clicked() { let _ = app.run("photo.editInPhotoCraft", json!({})); } @@ -1025,27 +1085,27 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let _ = app.run("photo.setReference", json!({"id": id.0})); } ui.separator(); - ui.menu_button(crate::i18n::tr("Set Rating"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Rating"), |ui| { for r in 0..=5 { if ui.button(if r == 0 { crate::i18n::tr("No Stars").to_string() } else { "★".repeat(r) }).clicked() { let _ = app.run("photo.rate", json!({"rating": r})); } } }); - ui.menu_button(crate::i18n::tr("Set Flag"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Flag"), |ui| { for (l, f) in [("Pick", "pick"), ("Reject", "reject"), ("Unflagged", "none")] { if ui.button(crate::i18n::tr(l)).clicked() { let _ = app.run("photo.flag", json!({"flag": f})); } } }); - ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); - if local_only(app, &app.session.targets(&serde_json::Value::Null)) { + crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| label_menu(app, ui)); + if local_only(app, &app.session.selected()) { // albums hold library photos (issue #684): nothing here to add until they join My Photos let add = ui.add_enabled(false, egui::Button::new(crate::i18n::tr("Add to Album"))).on_disabled_hover_text(albums_hint()); register(ui.ctx(), "button:contextAddToAlbum", add.rect); } else { - let add = ui.menu_button(crate::i18n::tr("Add to Album"), |ui| { + let add = crate::widgets::menu_button(ui, crate::i18n::tr("Add to Album"), |ui| { let albums: Vec<_> = app.session.catalog.albums().filter(|a| !a.folder && !a.is_smart()).map(|a| (a.id.0, a.name.clone())).collect(); let any = !albums.is_empty(); for (aid, name) in albums { @@ -1110,7 +1170,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Create Version")).clicked() { let _ = app.run("version.create", json!({})); } - ui.menu_button(crate::i18n::tr("Stack"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Stack"), |ui| { let stacked = app.session.catalog.stack_of(id).is_some(); let several = app.session.selection.ids.len() > 1; for (label, cmd, on) in [ @@ -1145,8 +1205,8 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Reset Edits")).clicked() { let _ = app.run("develop.reset", json!({})); } - ui.menu_button(crate::i18n::tr("Photo Merge"), |ui| { - let n = app.session.targets(&json!({})).len(); + crate::widgets::menu_button(ui, crate::i18n::tr("Photo Merge"), |ui| { + let n = app.session.selected().len(); for (id, label) in [("dialog.mergeHdr", "HDR…"), ("dialog.mergePanorama", "Panorama…"), ("dialog.mergeHdrPanorama", "HDR Panorama…")] { if ui.add_enabled(n >= 2, egui::Button::new(crate::i18n::tr(label))).clicked() { let _ = app.run(id, json!({})); @@ -1173,7 +1233,7 @@ pub fn context_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if ui.button(crate::i18n::tr("Export…")).clicked() { let _ = app.run("dialog.export", json!({})); } - ui.menu_button(crate::i18n::tr("Export with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { match app.run("app.export", json!({"preset": p.name, "background": true})) { diff --git a/crates/ui-egui/src/panels/keymap.rs b/crates/ui-egui/src/panels/keymap.rs index a844b7b24..1bda17758 100644 --- a/crates/ui-egui/src/panels/keymap.rs +++ b/crates/ui-egui/src/panels/keymap.rs @@ -151,6 +151,15 @@ fn fixed_keys(app: &LightcraftApp, ui: &mut egui::Ui, t: &Tokens, mac: bool, nee for (sc, label) in [("6", "Red label"), ("7", "Yellow label"), ("8", "Green label"), ("9", "Blue label")] { fixed.push((sc.to_string(), label)); } + // grid navigation (Photo Grid and Square Grid): fixed keys that belong to no single command + for (sc, label) in [ + ("Home", "Grid: first photo of the view"), + ("End", "Grid: last photo of the view"), + ("Shift+Left", "Grid: add the previous photo to the selection"), + ("Shift+Right", "Grid: add the next photo to the selection"), + ] { + fixed.push((sc.to_string(), label)); + } let fixed: Vec<_> = fixed .into_iter() .filter(|(sc, label)| { diff --git a/crates/ui-egui/src/panels/keyword_list.rs b/crates/ui-egui/src/panels/keyword_list.rs index 2d691112a..d42aa1ff5 100644 --- a/crates/ui-egui/src/panels/keyword_list.rs +++ b/crates/ui-egui/src/panels/keyword_list.rs @@ -8,6 +8,7 @@ use lightcraft_catalog::{Catalog, PhotoId}; use serde_json::json; use crate::LightcraftApp; +use crate::icons::{Icon, paint}; use crate::state::Dialog; use crate::theme::Tokens; use crate::widgets::register; @@ -21,7 +22,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); crate::widgets::divider(ui); // the title, where a dragged keyword goes back to the top level - let (title, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); + let (title, title_resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); register(ui.ctx(), "keywordList:topLevel", title); let dragging = app.ui.dragging_keyword.clone(); let (pointer, released) = ui.input(|i| (i.pointer.latest_pos(), i.pointer.any_released())); @@ -29,14 +30,18 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if dragging.is_some() && over_title { ui.painter().rect_stroke(title.shrink2(vec2(16.0, 6.0)), 4.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } + // (it folds the list shut; while a keyword is dragged it says where the drop goes) let heading = if dragging.is_some() { crate::i18n::tr("Drop here for the top level") } else { crate::i18n::tr("Keyword List") }; - ui.painter().text(pos2(title.left() + 24.0, title.center().y + 2.0), Align2::LEFT_CENTER, heading, t.semibold(15.0), t.text); + let open = super::right::fold_title(app, ui, "keywordList", title, &title_resp, heading); if released && over_title && let Some(k) = dragging.clone() { drop_keyword(app, ui.ctx(), &k, None); } + if !open { + return; + } let fid = egui::Id::new("keyword-list-filter"); let mut filter: String = ui.data(|d| d.get_temp(fid)).unwrap_or_default(); ui.horizontal(|ui| { @@ -72,7 +77,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.label(egui::RichText::new(crate::i18n::tr(none)).color(t.text_dim)); }); } - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let ticks = app.caches.keyword_ticks(&app.session.catalog, &selection); for r in &rows { row(app, ui, r, &selection, &ticks, filter.trim().is_empty()); @@ -161,6 +166,7 @@ fn drop_keyword(app: &mut LightcraftApp, ctx: &egui::Context, keyword: &str, par fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId], ticks: &Ticks, can_fold: bool) { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), ROW_H), Sense::click_and_drag()); + crate::widgets::announce(&resp, r.path.clone()); register(ui.ctx(), format!("keywordRow:{}", r.path), rect); if resp.drag_started() { app.ui.dragging_keyword = Some(r.path.clone()); @@ -178,17 +184,13 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // the triangle if r.has_children { let c = pos2(x + 6.0, cy); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, egui::Id::new(("keyword-list-tri", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&tr, crate::i18n::tr(if r.open { "Collapse" } else { "Expand" })); register(ui.ctx(), format!("keywordRowToggle:{}", r.path), tri); - let pts = if r.open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - }; // while a filter opens the levels the triangle does nothing: it doesn't light up either let lit = tr.hovered() && can_fold; - ui.painter().add(egui::Shape::convex_polygon(pts, if lit { t.text } else { t.text_dim }, Stroke::NONE)); + paint(ui.painter(), tri, if r.open { Icon::ChevronDown } else { Icon::ChevronRight }, if lit { t.text } else { t.text_dim }); if tr.clicked() && can_fold { let key = r.path.to_lowercase(); if r.open { @@ -205,6 +207,7 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId // (it senses drags too, so a press there that moves doesn't pick the keyword up) let sense = if on { Sense::click_and_drag() } else { Sense::hover() }; let tb = ui.interact(boxr.expand(3.0), egui::Id::new(("keyword-list-tick", r.path.to_lowercase())), sense); + crate::widgets::announce(&tb, format!("{} {}", crate::i18n::tr("Keyword"), r.path)); register(ui.ctx(), format!("keywordCheck:{}", r.path), boxr); let edge = if on { t.text_label } else { t.text_dim.gamma_multiply(0.5) }; match state { @@ -241,9 +244,10 @@ fn row(app: &mut LightcraftApp, ui: &mut egui::Ui, r: &Row, selection: &[PhotoId ui.painter().galley(count_rect.min, count, t.text_dim); let arrow = Rect::from_center_size(pos2(count_rect.left() - 12.0, cy), vec2(16.0, 16.0)); let ar = ui.interact(arrow, egui::Id::new(("keyword-list-show", r.path.to_lowercase())), Sense::click()); + crate::widgets::announce(&ar, crate::i18n::tr("Show photos")); register(ui.ctx(), format!("keywordShow:{}", r.path), arrow); if resp.hovered() || ar.hovered() { - ui.painter().text(arrow.center(), Align2::CENTER_CENTER, "→", t.font(13.0), if ar.hovered() { t.text } else { t.text_dim }); + paint(ui.painter(), arrow, Icon::ChevronRight, if ar.hovered() { t.text } else { t.text_dim }); } if ar.on_hover_text(crate::i18n::tr("Show Photos with Keyword")).clicked() { super::left::browse_all_photos(app, true); @@ -617,13 +621,13 @@ mod tests { "Export Keywords…", ]; let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("locales"); - for code in ["de", "es", "fr", "pt-br", "ru", "uk", "ja", "zh-hans", "zh-hant"] { + for code in ["de", "es", "fr", "pt-br", "ru", "uk", "it", "ka", "ja", "zh-hans", "zh-hant", "pl", "cs"] { let text = std::fs::read_to_string(dir.join(format!("{code}.json"))).unwrap(); let catalog: std::collections::HashMap<String, String> = serde_json::from_str(&text).unwrap(); for label in labels { assert!(catalog.contains_key(label), "{code}: “{label}”"); } } - assert_eq!(Locale::ALL.len(), 10, "a language added: list its file above"); + assert_eq!(Locale::ALL.len(), 14, "a language added: list its file above"); } } diff --git a/crates/ui-egui/src/panels/keywording.rs b/crates/ui-egui/src/panels/keywording.rs index 857498599..bad2a2f43 100644 --- a/crates/ui-egui/src/panels/keywording.rs +++ b/crates/ui-egui/src/panels/keywording.rs @@ -30,7 +30,7 @@ pub fn view_switch(app: &mut LightcraftApp, ui: &mut egui::Ui) { /// exported files will carry. One only some of the photos have is marked with an asterisk. pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let export = app.ui.keywording_view == crate::state::KeywordingView::WillExport; let names = app.caches.keyword_names(&app.session.catalog, &selection, export); if names.is_empty() { @@ -40,7 +40,7 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { let prefix = if export { "keywordExport" } else { "keywordContaining" }; ui.horizontal_wrapped(|ui| { for n in names.iter() { - let (body, _) = chip(ui, &n.path, !n.on_all(), false); + let (body, _) = chip(ui, &n.path, &n.path, !n.on_all(), false, ui.max_rect().width()); register(ui.ctx(), format!("{prefix}:{}", n.path), body.rect); if n.on_all() { body.on_hover_text(&n.path); @@ -52,21 +52,38 @@ pub fn names_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { }); } -/// One chip, measured before it is placed so that a wrapping row moves it whole to the next line: -/// the name (cut short to fit the row) and, with `cross`, a × at its end. A `partial` one (only -/// some of the selected photos) is dimmed and marked with an asterisk. Returns the name's response -/// and the ×'s. -fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Response, Option<egui::Response>) { - use egui::{Align2, Rect, Sense, pos2, vec2}; +/// Chips on a line of the grouped box are this far apart. +const GAP: f32 = 6.0; +/// The × at a chip's end is a target this wide, and as tall as the chip. +const CROSS_W: f32 = 22.0; + +/// The room a chip needs besides its name: the space before it and, with a ×, the ×'s target (the +/// × sits in the middle of it, which leaves the name its space), else the space after it. +fn chip_edges(cross: bool) -> f32 { + 8.0 + if cross { CROSS_W } else { 8.0 } +} + +/// What a chip shows of `name` in a row `row_w` wide (cut short to fit it), and the chip's width. +fn chip_size(ui: &egui::Ui, name: &str, partial: bool, cross: bool, row_w: f32) -> (String, f32) { + let font = Tokens::get(ui.ctx()).font(13.0); + let room = (row_w - chip_edges(cross)).max(24.0); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), egui::Color32::WHITE).size().x; + let shown = chip_label(name, partial, room, measure); + let w = measure(&shown) + chip_edges(cross); + (shown, w) +} + +/// One chip of the keyword `path`, measured before it is placed so that a wrapping row moves it +/// whole to the next line: `name` (the path, or what the grouped box shows of it; cut short to fit +/// a row `row_w` wide) and, with `cross`, a × at its end. A `partial` one (only some of the +/// selected photos) is dimmed and marked with an asterisk. Returns the name's response and the ×'s. +fn chip(ui: &mut egui::Ui, path: &str, name: &str, partial: bool, cross: bool, row_w: f32) -> (egui::Response, Option<egui::Response>) { + use egui::{Align2, CornerRadius, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; let t = Tokens::get(ui.ctx()); let font = t.font(13.0); let color = if partial { t.text_dim } else { t.text_label }; - let cross_w = if cross { 18.0 } else { 0.0 }; - // at most the row's width - let room = (ui.max_rect().width() - 16.0 - cross_w).max(24.0); - let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), font.clone(), color).size().x; - let shown = chip_label(path, partial, room, measure); - let w = measure(&shown) + 16.0 + cross_w; + let cross_w = if cross { CROSS_W } else { 0.0 }; + let (shown, w) = chip_size(ui, name, partial, cross, row_w); // the row places it; its name and × answer under ids of their own keyword, so an open menu // stays with its keyword when the chips change let (rect, _) = ui.allocate_exact_size(vec2(w, 22.0), Sense::hover()); @@ -75,24 +92,57 @@ fn chip(ui: &mut egui::Ui, path: &str, partial: bool, cross: bool) -> (egui::Res let full = path.replace('|', " › "); let resp = ui.interact(body, id, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &full)); + // the ×: the whole end of the chip, so that it is hard to miss and easy to tell from the name let x = cross.then(|| { - let xr = Rect::from_center_size(pos2(rect.right() - 11.0, rect.center().y), vec2(14.0, 14.0)); + let xr = Rect::from_min_max(pos2(rect.right() - cross_w, rect.top()), rect.max); let xresp = ui.interact(xr, id.with("remove"), Sense::click()); let said = crate::i18n::tr_format!("Remove “{name}”", name = full); xresp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &said)); xresp }); - ui.painter().rect_stroke(rect, 10.0, egui::Stroke::new(1.0, t.field_border), egui::StrokeKind::Inside); - if resp.hovered() || x.as_ref().is_some_and(egui::Response::hovered) { - ui.painter().rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); + let p = ui.painter(); + p.rect_stroke(rect, 10.0, Stroke::new(1.0, t.field_border), StrokeKind::Inside); + if resp.hovered() { + p.rect_filled(rect.shrink(1.0), 10.0, t.hover.gamma_multiply(0.5)); } - ui.painter().text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font.clone(), color); + p.text(pos2(rect.left() + 8.0, rect.center().y), Align2::LEFT_CENTER, &shown, font, color); if let Some(x) = &x { - ui.painter().text(x.rect.center(), Align2::CENTER_CENTER, "×", t.font(13.0), if x.hovered() { t.text } else { t.text_dim }); + // under the pointer the × lights up on its own: this click removes, the name's doesn't + if x.hovered() { + p.rect_filled(x.rect.shrink(1.0), CornerRadius { nw: 0, sw: 0, ne: 10, se: 10 }, t.hover); + } + let (c, arm) = (x.rect.center(), 3.5); + let stroke = Stroke::new(1.5, if x.hovered() || x.has_focus() { t.text } else { t.text_dim }); + p.line_segment([c + vec2(-arm, -arm), c + vec2(arm, arm)], stroke); + p.line_segment([c + vec2(-arm, arm), c + vec2(arm, -arm)], stroke); + if x.has_focus() { + p.rect_stroke(x.rect, CornerRadius { nw: 4, sw: 4, ne: 10, se: 10 }, Stroke::new(1.5, t.accent), StrokeKind::Inside); + } + } + // reached with Tab, a chip shows where the keyboard is + if resp.has_focus() { + p.rect_stroke(rect, 10.0, Stroke::new(1.5, t.accent), StrokeKind::Inside); } (resp, x) } +/// Take the chip's keyword off the selected photos. Several photos losing it at once is said, with +/// the way back (one photo's chip going is enough); a refusal is said too. +fn remove(app: &mut LightcraftApp, ctx: &egui::Context, chip: &Chip) { + match app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})) { + Ok(_) if chip.have > 1 => { + let name = chip.path.replace('|', " › "); + app.toast_for( + ctx, + crate::i18n::tr_format!("Removed “{name}” from {n} photos. Edit ▸ Undo puts it back.", name = name, n = chip.have), + 6.0, + ); + } + Ok(_) => {} + Err(e) => app.toast_error(ctx, e), + } +} + /// A chip's menu: it acts on the selected photos (deleting a keyword from the whole library is the /// Keyword List's). fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { @@ -105,7 +155,7 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { let _ = app.run("photo.setMeta", json!({"addKeywords": [chip.path]})); } if item(ui, "remove", crate::i18n::tr("Remove from Selected Photos")) { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [chip.path]})); + remove(app, ui.ctx(), chip); } if item(ui, "show", crate::i18n::tr("Show Photos with Keyword")) { super::left::browse_all_photos(app, true); @@ -117,36 +167,167 @@ fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, chip: &Chip) { } } +/// With several photos selected, how many the box's keywords are of (`keywordScope`): what a × +/// or a keyword typed here acts on. +pub fn scope(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId]) { + let photos = app.caches.selected_photos(&app.session.catalog, selection); + if photos < 2 { + return; + } + let t = Tokens::get(ui.ctx()); + let said = crate::i18n::tr_format!("Keywords of {n} selected photos", n = photos); + let r = ui.label(egui::RichText::new(said).size(11.5).color(t.text_label)); + register(ui.ctx(), "keywordScope", r.rect); + ui.add_space(4.0); +} + /// The selection's keywords as chips: the name (right-click for its menu), and × to take it off -/// every selected photo. One only some of them have is marked with an asterisk. -pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui) { - let selection = app.session.targets(&serde_json::Value::Null); - let chips = app.caches.keyword_chips(&app.session.catalog, &selection); +/// every selected photo. One only some of them have is marked with an asterisk. Whole names, or +/// grouped by the keyword they are inside (Settings ▸ Interface). +pub fn chip_row(app: &mut LightcraftApp, ui: &mut egui::Ui, selection: &[PhotoId]) { + let chips = app.caches.keyword_chips(&app.session.catalog, selection); + if chips.is_empty() { + let t = Tokens::get(ui.ctx()); + let r = ui.label(egui::RichText::new(crate::i18n::tr("No keywords yet")).color(t.text_dim)); + register(ui.ctx(), "keywordChipsEmpty", r.rect); + return; + } + if app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped { + let groups = app.caches.keyword_groups(&app.session.catalog, selection); + chip_groups(app, ui, &groups, crate::key_of(selection)); + return; + } ui.horizontal_wrapped(|ui| { for c in chips.iter() { - let (body, x) = chip(ui, &c.path, !c.on_all(), true); - register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); - if let Some(x) = x { - register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); - if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { - let _ = app.run("photo.setMeta", json!({"removeKeywords": [c.path]})); - } - } - // one tooltip: the whole name (it may be cut short), and how many have it - let full = c.path.replace('|', " › "); - let body = if c.on_all() { - body.on_hover_text(full) - } else { - register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); - let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); - body.on_hover_text(format!("{full}\n{n}")) - }; - let c = c.clone(); - body.context_menu(|ui| menu(app, ui, &c)); + let (body, x) = chip(ui, &c.path, &c.path, !c.on_all(), true, ui.max_rect().width()); + chip_acts(app, ui, c, body, x); } }); } +/// A drawn chip does its work: its × takes the keyword off the selected photos, its name has the +/// tooltip and the menu. +fn chip_acts(app: &mut LightcraftApp, ui: &egui::Ui, c: &Chip, body: egui::Response, x: Option<egui::Response>) { + register(ui.ctx(), format!("keywordChip:{}", c.path), body.rect); + if let Some(x) = x { + register(ui.ctx(), format!("keywordChipRemove:{}", c.path), x.rect); + if x.on_hover_text(crate::i18n::tr("Remove from Selected Photos")).clicked() { + remove(app, ui.ctx(), c); + } + } + // one tooltip: the whole name (it may be cut short), and how many have it + let full = c.path.replace('|', " › "); + let body = if c.on_all() { + body.on_hover_text(full) + } else { + register(ui.ctx(), format!("keywordChipPartial:{}", c.path), body.rect); + let n = crate::i18n::tr_format!("On {have} of {of} selected photos", have = c.have, of = c.of); + body.on_hover_text(format!("{full}\n{n}")) + }; + let c = c.clone(); + body.context_menu(|ui| menu(app, ui, &c)); +} + +/// When a list is shortened, only this many chips are measured for it: more than the lines it +/// can show hold in rows `avail` wide, however short their names are. The others are planned a +/// line each, past the lines shown. +pub(crate) fn measured(avail: f32) -> usize { + // the narrowest chip: its edges and a letter + let narrowest = chip_edges(true) + 4.0; + let row = if avail.is_finite() && avail > narrowest { ((avail / narrowest) as usize).min(4096) } else { 1 }; + row.saturating_add(1).saturating_mul(LINES + SLACK + 1) +} + +/// The grouped box: each group's parent on a line of its own, its chips under it side by side, +/// and "+N more" where a long list stops. +fn chip_groups(app: &mut LightcraftApp, ui: &mut egui::Ui, groups: &[Group], selection: u64) { + use egui::{Align2, Sense, vec2}; + let t = Tokens::get(ui.ctx()); + let avail = ui.available_width(); + // the whole list only for the selection it was asked of + let all = app.ui.keyword_chips_all == Some(selection); + let limit = measured(avail); + // the widths the lines are planned with (past what a shortened list can show: a line each) + let mut measured = 0_usize; + let sizes: Vec<(bool, Vec<f32>)> = groups + .iter() + .map(|g| { + let widths = g + .chips + .iter() + .map(|(name, c)| { + measured = measured.saturating_add(1); + if all || measured <= limit { chip_size(ui, name, !c.on_all(), true, avail).1 } else { avail } + }) + .collect(); + (!g.parent.is_empty(), widths) + }) + .collect(); + let small = t.font(11.5); + for (i, line) in lines(&sizes, avail, GAP, all).into_iter().enumerate() { + match line { + Line::Heading(g) => { + let Some(group) = groups.get(g) else { continue }; + ui.add_space(if i == 0 { 0.0 } else { 8.0 }); + let (r, resp) = ui.allocate_exact_size(vec2(avail, 18.0), Sense::hover()); + let measure = |s: &str| ui.painter().layout_no_wrap(s.to_string(), small.clone(), t.text_label).size().x; + let shown = chip_label(&group.parent, false, avail, measure); + let full = group.parent.replace('|', " › "); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, &full)); + ui.painter().text(r.left_center(), Align2::LEFT_CENTER, &shown, small.clone(), t.text_label); + // (cut short, it says its whole name on hover, as a chip does) + if shown != full { + resp.on_hover_text(&full); + } + register(ui.ctx(), format!("keywordGroup:{}", group.parent), r); + } + Line::Chips(g, row) => { + let Some(group) = groups.get(g) else { continue }; + ui.add_space(if i == 0 { 0.0 } else { 4.0 }); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = GAP; + for (name, c) in group.chips.get(row).into_iter().flatten() { + let (body, x) = chip(ui, &c.path, name, !c.on_all(), true, avail); + chip_acts(app, ui, c, body, x); + } + }); + } + Line::More(n) => { + ui.add_space(6.0); + let label = crate::i18n::tr_format!("+{n} more", n = n); + let r = ui.add(egui::Button::new(egui::RichText::new(label).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), "keywordChipsMore", r.rect); + if r.on_hover_text(crate::i18n::tr("Show every keyword of the selected photos")).clicked() { + app.ui.keyword_chips_all = Some(selection); + } + } + Line::Fewer => { + ui.add_space(6.0); + let r = ui.add(egui::Button::new(egui::RichText::new(crate::i18n::tr("Show fewer")).color(t.text_label)).corner_radius(10.0)); + register(ui.ctx(), "keywordChipsFewer", r.rect); + if r.clicked() { + app.ui.keyword_chips_all = None; + } + } + } + } +} + +/// What the grouped box's suggestions say of the keywords `paths`: each one's own name, or its +/// whole path (" › " between its levels) when another of them has the same name. +pub(crate) fn suggestion_names(paths: &[String]) -> Vec<String> { + use lightcraft_catalog::keywords::SEP; + let leaf = |p: &str| p.rsplit(SEP).next().unwrap_or(p).to_lowercase(); + let mut named: std::collections::HashMap<String, usize> = Default::default(); + for p in paths { + *named.entry(leaf(p)).or_default() += 1; + } + paths + .iter() + .map(|p| if named.get(&leaf(p)).is_some_and(|n| *n > 1) { p.replace(SEP, " › ") } else { p.rsplit(SEP).next().unwrap_or(p).to_string() }) + .collect() +} + /// A chip's label within `room` (as `measure` measures text): the keyword's path with " › " /// between its levels, and an asterisk when it is `partial`. Too long, it is cut from the front so /// that the keyword's own name and the asterisk stay ("…Lisbon › Belém tower *"). @@ -171,6 +352,281 @@ pub(crate) fn chip_label(path: &str, partial: bool, room: f32, measure: impl Fn( best } +/// The Add keyword box offers at most this many of the library's keywords. +pub(crate) const OFFERS: usize = 8; + +/// What the Add keyword box offers under itself for the text typed in it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct Offers { + /// The library's keywords matching the text (those starting with it first, then the most + /// used), without the ones every selected photo has. + pub keywords: Vec<String>, + /// The keyword Return would create, when the library has none of that name: where it would go + /// (inside the default parent, if there is one). + pub create: Option<String>, +} + +/// The text being typed in the Add keyword box: what follows its last comma. +pub(crate) fn typing(text: &str) -> &str { + text.rsplit(',').next().unwrap_or("").trim() +} + +/// The keywords typed before the last comma, each where the library would put it. +pub(crate) fn typed_before(catalog: &Catalog, text: &str) -> Vec<String> { + let Some((before, _)) = text.rsplit_once(',') else { return Vec::new() }; + before.split(',').map(|s| catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() +} + +/// What the box offers for `typed`, for photos that all have the `current` keywords. +pub(crate) fn offers(catalog: &Catalog, current: &[String], typed: &str) -> Offers { + use lightcraft_catalog::keywords::{SEP, clean}; + let name = clean(typed); + if name.is_empty() { + return Offers::default(); + } + // (a keyword two photos spell differently is one offer, the first spelling) + let mut seen = std::collections::HashSet::new(); + let mut keywords = catalog.keyword_suggestions(current, typed, OFFERS); + keywords.retain(|k| seen.insert(k.to_lowercase())); + // where Return would put a name the library lacks, as `Catalog::typed_keyword` does, without + // looking through the library a second time + let create = (!catalog.has_keyword(&name)).then(|| match catalog.default_keyword_parent() { + Some(parent) if !name.contains(SEP) => format!("{parent}{SEP}{name}"), + _ => name, + }); + Offers { keywords, create } +} + +/// What was chosen from the offers under the Add keyword box. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Picked { + /// One of the library's keywords. + Keyword(String), + /// The new keyword, as typed. + Typed, +} + +/// The offers under the Add keyword box (`below` is the box): the library's keywords matching +/// what is typed, each by its own name with the keyword it is inside after it, the one at `at` +/// highlighted (↑ / ↓); then, for a name the library doesn't have, the keyword Return creates. +/// They go above the box where the window has no room for them under it. Returns the offer +/// clicked and where the offers were drawn. +pub(crate) fn offers_below(ui: &egui::Ui, below: egui::Rect, offers: &Offers, at: Option<&str>) -> (Option<Picked>, egui::Rect) { + use egui::{Align2, Sense, pos2, vec2}; + use lightcraft_catalog::keywords::SEP; + let t = Tokens::get(ui.ctx()); + let mut picked = None; + let width = (below.width() - 10.0).max(60.0); + // under the box, or over it where the window has more room there; as many lines as fit that + // side, so that the offers never cover the box (the highlighted one is always among them) + let window = ui.ctx().content_rect(); + let create = usize::from(offers.create.is_some()); + let fits = |room: f32| { + let lines = (room - 2.0 - if create > 0 && !offers.keywords.is_empty() { 17.0 } else { 10.0 }) / 24.0; + if lines.is_finite() && lines >= 1.0 { lines as usize } else { 0 } + }; + let wanted = offers.keywords.len().saturating_add(create); + let (under, above) = (fits(window.bottom() - below.bottom()), fits(below.top() - window.top())); + let over = under < wanted && above > under; + let room = (if over { above } else { under }).clamp(1, wanted.max(1)); + // the keywords shown: those up to the highlighted one when not all fit + let count = room.saturating_sub(create).min(offers.keywords.len()); + let lit = at.and_then(|k| offers.keywords.iter().position(|x| x == k)); + let first = lit.map_or(0, |a| a.saturating_add(1).saturating_sub(count)).min(offers.keywords.len().saturating_sub(count)); + let listed = offers.keywords.iter().skip(first).take(count); + let area = egui::Area::new(egui::Id::new("keyword-offers")).order(egui::Order::Foreground).constrain(true); + let area = if over { + area.pivot(Align2::LEFT_BOTTOM).fixed_pos(below.left_top() - vec2(0.0, 2.0)) + } else { + area.fixed_pos(below.left_bottom() + vec2(0.0, 2.0)) + }; + let shown = area.show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()).inner_margin(4.0).show(ui, |ui| { + ui.set_width(width); + ui.spacing_mut().item_spacing.y = 0.0; + // a line: the keyword's own name, then (dimmer, cut from the front) where it is + let line = |ui: &mut egui::Ui, id: egui::Id, name: &str, place: &str, said: &str, lit: bool| { + let (r, resp) = ui.allocate_exact_size(vec2(width, 24.0), Sense::hover()); + let resp = ui.interact(r, id, Sense::click()).union(resp); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, true, lit, said)); + if lit || resp.hovered() { + ui.painter().rect_filled(r, 4.0, if lit { t.pressed } else { t.hover }); + } + let font = t.font(13.0); + let measure = |s: &str, f: &egui::FontId| ui.painter().layout_no_wrap(s.to_string(), f.clone(), t.text).size().x; + let name = chip_label(name, false, width - 16.0, |s| measure(s, &font)); + let end = r.left() + 8.0 + measure(&name, &font); + ui.painter().text(pos2(r.left() + 8.0, r.center().y), Align2::LEFT_CENTER, &name, font, t.text); + let small = t.font(11.5); + let room = r.right() - 8.0 - (end + 10.0); + if !place.is_empty() && room >= 30.0 { + let place = chip_label(place, false, room, |s| measure(s, &small)); + ui.painter().text(pos2(r.right() - 8.0, r.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); + } + resp + }; + for k in listed { + let (place, name) = k.rsplit_once(SEP).unwrap_or(("", k)); + let whole = k.replace(SEP, " › "); + let lit = at == Some(k.as_str()); + let r = line(ui, egui::Id::new(("keyword-offer", k.to_lowercase())), name, place, &whole, lit); + register(ui.ctx(), format!("kwComplete:{k}"), r.rect); + if lit { + register(ui.ctx(), format!("kwCompleteAt:{k}"), r.rect); + } + if r.clicked() { + picked = Some(Picked::Keyword(k.clone())); + } + } + if let Some(new) = &offers.create { + if count > 0 { + ui.add_space(3.0); + let (r, _) = ui.allocate_exact_size(vec2(width, 1.0), Sense::hover()); + ui.painter().rect_filled(r, 0.0, t.field_border); + ui.add_space(3.0); + } + let (place, name) = new.rsplit_once(SEP).unwrap_or(("", new)); + let whole = new.replace(SEP, " › "); + let (place, said) = if place.is_empty() { + (crate::i18n::tr("new keyword").to_string(), crate::i18n::tr_format!("Create the keyword “{name}”", name = whole)) + } else { + let inside = place.replace(SEP, " › "); + ( + crate::i18n::tr_format!("new, inside {parent}", parent = inside), + crate::i18n::tr_format!("Create the keyword “{name}”", name = whole), + ) + }; + // (its place is said in words: not a path to cut from the front) + let r = line(ui, egui::Id::new("keyword-offer-create"), name, "", &said, false); + let small = t.font(11.5); + ui.painter().text(pos2(r.rect.right() - 8.0, r.rect.center().y), Align2::RIGHT_CENTER, place, small, t.text_label); + register(ui.ctx(), "kwCompleteCreate", r.rect); + // (Return gives the highlighted offer instead, while there is one) + let r = if at.is_none() { + r.on_hover_text(crate::i18n::tr("Return creates this keyword and gives it to the selected photos")) + } else { + r + }; + if r.clicked() { + picked = Some(Picked::Typed); + } + } + }); + }); + (picked, shown.response.rect) +} + +/// Keywords of the selection that are inside the same keyword, as the grouped box shows them. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct Group { + /// The keyword they are inside, spelled as the first of them spells it; empty for the keywords + /// that share none with another. + pub parent: String, + /// Each keyword's name below `parent`, and its chip. + pub chips: Vec<(String, Chip)>, +} + +/// The chips grouped by the keyword they are inside: the deepest one a chip shares with another +/// chip ("Places|Italy" for "Places|Italy|Rome" next to "Places|Italy|Venice"; "Transport" for +/// "Transport|Rail|Night Train" next to "Transport|Ferry"). Those that share none come first, in a group +/// without a parent; then the groups by name, each in the chips' order. +pub(crate) fn grouped(chips: &[Chip]) -> Vec<Group> { + use lightcraft_catalog::keywords::SEP; + let sep = SEP.to_string(); + // a path's first `depth` levels, and the rest + let head = |levels: &[&str], depth: usize| levels.get(..depth).unwrap_or_default().join(&sep); + let tail = |levels: &[&str], depth: usize| levels.get(depth..).unwrap_or_default().join(&sep); + // how many chips are inside each keyword (by its lower-case path) + let mut inside: std::collections::HashMap<String, usize> = Default::default(); + for c in chips { + let levels: Vec<&str> = c.path.split(SEP).collect(); + for depth in 1..levels.len() { + *inside.entry(head(&levels, depth).to_lowercase()).or_default() += 1; + } + } + let mut groups: std::collections::BTreeMap<String, Group> = Default::default(); + for c in chips { + let levels: Vec<&str> = c.path.split(SEP).collect(); + let shared = |depth: &usize| inside.get(&head(&levels, *depth).to_lowercase()).is_some_and(|n| *n > 1); + let depth = (1..levels.len()).rev().find(shared).unwrap_or(0); + let parent = head(&levels, depth); + // (ordered level by level: "Port › Docks" follows "Port", before "Portugal") + let key = parent.to_lowercase().replace(SEP, "\u{1}"); + groups.entry(key).or_insert_with(|| Group { parent, chips: Vec::new() }).chips.push((tail(&levels, depth), c.clone())); + } + groups.into_values().collect() +} + +/// Items `widths` wide laid out left to right in rows `avail` wide, `gap` apart: the items of each +/// row. An item wider than a row has a row to itself. +pub(crate) fn rows_of(widths: &[f32], avail: f32, gap: f32) -> Vec<std::ops::Range<usize>> { + let mut rows = Vec::new(); + let (mut start, mut used) = (0, 0.0_f32); + for (i, w) in widths.iter().enumerate() { + let w = if w.is_finite() { w.max(0.0) } else { 0.0 }; + if i > start && used + gap + w > avail { + rows.push(start..i); + (start, used) = (i, w); + } else { + used = if i == start { w } else { used + gap + w }; + } + } + if start < widths.len() { + rows.push(start..widths.len()); + } + rows +} + +/// A line of the grouped box. +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum Line { + /// The name of a group's parent. + Heading(usize), + /// Chips of a group (by their place in it). + Chips(usize, std::ops::Range<usize>), + /// "+N more": this many chips are not shown. + More(usize), + /// "Show fewer", under a list shown whole that would otherwise be shortened. + Fewer, +} + +/// The grouped box shows this many lines of a long list (then "+N more")… +pub(crate) const LINES: usize = 8; +/// …but a list only this many lines longer is shown whole: "+1 more" saves nothing. +pub(crate) const SLACK: usize = 2; + +/// The lines of the grouped box: for each group whether it has a heading and how wide its chips +/// are, in rows `avail` wide with chips `gap` apart. A list longer than [`LINES`] + [`SLACK`] +/// lines stops after [`LINES`] and says how many chips are left, unless `all` of it is asked for. +pub(crate) fn lines(groups: &[(bool, Vec<f32>)], avail: f32, gap: f32, all: bool) -> Vec<Line> { + let mut out = Vec::new(); + for (g, (heading, widths)) in groups.iter().enumerate() { + if widths.is_empty() { + continue; + } + if *heading { + out.push(Line::Heading(g)); + } + out.extend(rows_of(widths, avail, gap).into_iter().map(|row| Line::Chips(g, row))); + } + if out.len() <= LINES + SLACK { + return out; + } + if all { + out.push(Line::Fewer); + return out; + } + out.truncate(LINES); + if matches!(out.last(), Some(Line::Heading(_))) { + out.pop(); + } + let count = |lines: &[Line]| lines.iter().map(|l| if let Line::Chips(_, row) = l { row.len() } else { 0 }).sum::<usize>(); + let total: usize = groups.iter().map(|(_, widths)| widths.len()).sum(); + let left = total.saturating_sub(count(&out)); + out.push(Line::More(left)); + out +} + /// A keyword of the selection, as a chip shows it. #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct Chip { @@ -358,7 +814,8 @@ mod tests { for _ in 0..3 { let mut out = ctx.run_ui(egui::RawInput::default(), |ui| { ui.set_max_width(120.0); - let _ = chip(ui, "Places|Portugal|Lisbon|Belém tower", true, true); + let path = "Places|Portugal|Lisbon|Belém tower"; + let _ = chip(ui, path, "Belém tower", true, true, 120.0); }); out.textures_delta.clear(); if let Some(update) = out.platform_output.accesskit_update.take() { @@ -370,6 +827,179 @@ mod tests { assert!(has("Button", "Remove “Places › Portugal › Lisbon › Belém tower”"), "{found:?}"); } + fn chip_of(path: &str) -> Chip { + Chip { path: path.into(), have: 1, of: 1 } + } + + fn groups_of(paths: &[&str]) -> Vec<(String, Vec<String>)> { + let chips: Vec<Chip> = paths.iter().map(|p| chip_of(p)).collect(); + grouped(&chips).into_iter().map(|g| (g.parent, g.chips.into_iter().map(|(name, _)| name).collect())).collect() + } + + /// Given keywords inside the same keyword, when the box groups them, then they share a line + /// naming that keyword and each says only what is below it; a keyword alone below its parent + /// joins the nearest keyword it shares with another, and one that shares none comes first + /// under no name. + #[test] + fn keywords_group_under_the_keyword_they_share() { + let got = groups_of(&[ + "beach", + "Places|Italy|Florence", + "Places|Italy|Rome", + "Places|Portugal|Lisbon", + "Places|Portugal|Porto", + "Transport|Ferry", + "Transport|Rail|Night Train", + ]); + let want: Vec<(String, Vec<String>)> = [ + ("", vec!["beach"]), + ("Places|Italy", vec!["Florence", "Rome"]), + ("Places|Portugal", vec!["Lisbon", "Porto"]), + ("Transport", vec!["Ferry", "Rail|Night Train"]), + ] + .into_iter() + .map(|(p, c)| (p.to_string(), c.into_iter().map(str::to_string).collect())) + .collect(); + assert_eq!(got, want); + } + + /// A keyword and one inside it are both inside their shared parent; a hierarchy with one + /// keyword stays whole, under no name; spelling differences of the parent don't split a group. + #[test] + fn grouping_holds_at_the_edges() { + assert_eq!(groups_of(&["A|B", "A|B|C"]), [("A".to_string(), vec!["B".to_string(), "B|C".to_string()])]); + assert_eq!(groups_of(&["Places|Portugal|Lisbon"]), [(String::new(), vec!["Places|Portugal|Lisbon".to_string()])]); + assert_eq!(groups_of(&["Places|Rome", "places|Athens"]), [("Places".to_string(), vec!["Rome".to_string(), "Athens".to_string()])]); + assert_eq!(groups_of(&["İstanbul|Köprü", "i̇stanbul|Kule"]).len(), 1, "a parent whose lower case is longer"); + assert!(groups_of(&[]).is_empty()); + // every chip is in exactly one group + let paths = ["a", "a|b", "a|b|c", "a|d", "e|f", "g"]; + let n: usize = groups_of(&paths).iter().map(|(_, c)| c.len()).sum(); + assert_eq!(n, paths.len()); + } + + /// Chips fill a row until the next would not fit; one wider than the row has a row to itself, + /// and widths that are not numbers don't break the layout. + #[test] + fn chips_fill_rows() { + assert_eq!(rows_of(&[40.0, 40.0, 40.0], 100.0, 6.0), [0..2, 2..3]); + assert_eq!(rows_of(&[40.0, 54.0], 100.0, 6.0).len(), 1, "exactly fits"); + assert_eq!(rows_of(&[300.0, 20.0], 100.0, 6.0), [0..1, 1..2]); + assert_eq!(rows_of(&[20.0, 300.0, 20.0], 100.0, 6.0), [0..1, 1..2, 2..3]); + assert!(rows_of(&[], 100.0, 6.0).is_empty()); + assert_eq!(rows_of(&[f32::NAN, f32::INFINITY, 10.0], 100.0, 6.0).iter().map(|r| r.len()).sum::<usize>(), 3); + assert_eq!(rows_of(&[10.0, 10.0], 0.0, 6.0), [0..1, 1..2], "no room at all"); + } + + /// Given a list of more than LINES + SLACK lines, when it isn't asked for whole, then LINES + /// lines show and the rest is counted; a heading is never the last line shown. Asked for + /// whole, it ends in "Show fewer". A shorter list is shown as it is. + #[test] + fn a_long_list_is_shortened_and_says_how_much_is_left() { + // a heading and 3 chips, a heading and 9 (one chip a row): 14 lines + let groups = vec![(true, vec![90.0; 3]), (true, vec![90.0; 9])]; + let short = lines(&groups, 100.0, 6.0, false); + assert_eq!(short.len(), LINES + 1, "{short:?}"); + assert_eq!(short.last(), Some(&Line::More(6)), "3 + 3 chips shown: {short:?}"); + let whole = lines(&groups, 100.0, 6.0, true); + assert_eq!(whole.len(), 15); + assert_eq!(whole.last(), Some(&Line::Fewer)); + // the eighth line would be the second group's heading: it goes too + let cut = lines(&[(false, vec![90.0; 7]), (true, vec![90.0; 6])], 100.0, 6.0, false); + assert_eq!(cut.len(), LINES, "{cut:?}"); + assert_eq!(cut.last(), Some(&Line::More(6))); + assert!(cut.iter().all(|l| !matches!(l, Line::Heading(_))), "{cut:?}"); + // within the slack: whole, with neither button + let fits = lines(&[(true, vec![90.0; 9])], 100.0, 6.0, false); + assert_eq!(fits.len(), 10); + assert!(fits.iter().all(|l| matches!(l, Line::Heading(_) | Line::Chips(..)))); + assert_eq!(lines(&[(true, vec![90.0; 9])], 100.0, 6.0, true), fits, "nothing to show fewer of"); + assert!(lines(&[], 100.0, 6.0, false).is_empty()); + assert!(lines(&[(true, vec![])], 100.0, 6.0, false).is_empty(), "a group without chips has no heading"); + } + + /// Given a library with Places › Lisbon, the box offers it for "lis" (not for photos that all + /// have it), says a name the library lacks would be created (inside the default parent when + /// there is one), and offers nothing for nothing. + #[test] + fn the_box_offers_what_matches_and_says_what_is_new() { + use lightcraft_catalog::keywords::KeywordInfo; + let (mut c, _) = library(&[&["Places|Lisbon", "sea"], &["Places|Porto"]]); + let got = offers(&c, &[], "lis"); + assert_eq!(got.keywords, ["Places|Lisbon"]); + assert_eq!(got.create.as_deref(), Some("lis"), "“lis” itself is no keyword"); + assert!(offers(&c, &["Places|Lisbon".into()], "lis").keywords.is_empty(), "they all have it"); + assert_eq!(offers(&c, &[], " SEA ").create, None, "the library has sea"); + assert_eq!(offers(&c, &[], "places").create, None, "and Places, which holds keywords"); + assert_eq!(offers(&c, &[], " "), Offers::default()); + assert_eq!(offers(&c, &[], " | "), Offers::default()); + c.apply(Op::SetKeyword { path: "Inbox".into(), info: Some(KeywordInfo { new_keywords_inside: true, ..KeywordInfo::default() }) }).unwrap(); + assert_eq!(offers(&c, &[], "harbour").create.as_deref(), Some("Inbox|harbour")); + assert_eq!(offers(&c, &[], "a|b").create.as_deref(), Some("a|b"), "a path typed whole goes where it says"); + assert!(offers(&c, &[], "p").keywords.len() <= OFFERS); + } + + /// A keyword two photos spell differently is offered once. + #[test] + fn a_keyword_spelled_two_ways_is_one_offer() { + let (c, _) = library(&[&["Lisbon"], &["lisbon"], &["LISBON", "Lisboa"]]); + let got = offers(&c, &[], "lis").keywords; + assert_eq!(got.len(), 2, "{got:?}"); + assert!(got.iter().any(|k| k.eq_ignore_ascii_case("lisbon")) && got.contains(&"Lisboa".to_string())); + } + + /// Groups follow the tree: a keyword's groups come right after it, before a keyword whose name + /// merely starts the same. + #[test] + fn groups_are_in_the_trees_order() { + let parents: Vec<String> = + groups_of(&["Portugal|a", "Portugal|b", "Port|Docks|j", "Port|Docks|k", "Port|x", "Port|y"]).into_iter().map(|(p, _)| p).collect(); + assert_eq!(parents, ["Port", "Port|Docks", "Portugal"]); + } + + /// However wide the box, more chips are measured than a shortened list can show: the lines + /// shown are planned with their real widths. + #[test] + fn enough_chips_are_measured_for_the_lines_shown() { + for avail in [0.0, 60.0, 224.0, 474.0, 1500.0, 6000.0, f32::NAN, f32::INFINITY, -5.0] { + let limit = measured(avail); + assert!(limit > LINES + SLACK, "{avail}: {limit}"); + if avail.is_finite() && avail > 0.0 { + // the narrowest chips there can be, a gap apart: those of the lines shown + let narrowest = chip_edges(true) + 4.0; + let fit = (LINES + SLACK + 1) * ((avail / (narrowest + GAP)) as usize + 1); + assert!(limit >= fit, "{avail}: {limit} < {fit}"); + } + } + // a wide box: the lines shown are full rows, not one chip each past the 128th + let widths = vec![40.0; 300]; + let limit = measured(1500.0); + let planned: Vec<f32> = widths.iter().enumerate().map(|(i, w)| if i < limit { *w } else { 1500.0 }).collect(); + assert_eq!(lines(&[(false, planned)], 1500.0, GAP, false), lines(&[(false, widths)], 1500.0, GAP, false)); + } + + /// What is typed is what follows the last comma; what came before it goes along when an offer + /// is taken. + #[test] + fn typing_is_what_follows_the_last_comma() { + let (c, _) = library(&[&["sea"]]); + assert_eq!(typing("sea, lis "), "lis"); + assert_eq!(typing("lis"), "lis"); + assert_eq!(typing("sea,"), ""); + assert_eq!(typing(""), ""); + assert_eq!(typed_before(&c, "sea, , beach ,lis"), ["sea", "beach"]); + assert!(typed_before(&c, "lis").is_empty()); + } + + /// The grouped box's suggestions say a keyword's own name; two with the same name say their + /// whole paths, so they can be told apart. + #[test] + fn suggestions_are_named_apart() { + let paths: Vec<String> = ["Italy|Rome|Market", "Spain|Madrid|market", "Places|Lisbon", "sea"].iter().map(|s| s.to_string()).collect(); + assert_eq!(suggestion_names(&paths), ["Italy › Rome › Market", "Spain › Madrid › market", "Lisbon", "sea"]); + assert!(suggestion_names(&[]).is_empty()); + } + /// "Keywords & Containing Keywords": every level of the selection's keywords, flat, each /// counted once per photo. #[test] diff --git a/crates/ui-egui/src/panels/layout_overlay.rs b/crates/ui-egui/src/panels/layout_overlay.rs new file mode 100644 index 000000000..9b4800d0f --- /dev/null +++ b/crates/ui-egui/src/panels/layout_overlay.rs @@ -0,0 +1,223 @@ +//! View ▸ Loupe Overlay ▸ Layout Image, as in Lightroom Classic: a graphic with transparency (a +//! magazine cover's masthead and cover lines, a web banner's safe areas, a print layout's +//! margins) drawn over the photo in the loupe, so a shot can be judged against the page it is made +//! for. The graphic is fitted inside the photo and centred, and follows zoom and pan with it. +//! `opacity` fades it; `matte` darkens the photo outside its box, where the page would crop it. +//! +//! The file is read and decoded off the UI thread, once per path and Choose Layout Image… (an +//! offline drive never holds up a frame); until it arrives nothing is drawn, and a file that can't +//! be read says so at the canvas' bottom left. Graphics larger than [`MAX_SIDE`] are scaled down +//! once, premultiplied so transparent edges stay clean. + +use std::sync::{Arc, Mutex, PoisonError}; + +use egui::{Color32, Rect, pos2, vec2}; +use lightcraft_raster::Rgba8; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The longest side a layout image keeps (a screen never shows more of it). +pub const MAX_SIDE: usize = 4096; + +/// The decoded graphic once the worker is done, or why it couldn't be read. +type Loaded = Arc<Mutex<Option<Result<Rgba8, String>>>>; + +/// What the loupe keeps between frames (in egui's temporary memory). +#[derive(Clone)] +struct Cache { + /// The (path, revision) the file was read for. + file: (String, u32), + loaded: Loaded, + /// The texture made from the graphic, and the display profile it was made for. + tex: Option<(Option<u64>, egui::TextureHandle)>, +} + +/// The rectangle a `size` (pixels) graphic takes fitted inside `photo` and centred on it. +pub fn fit(photo: Rect, size: [usize; 2]) -> Rect { + let (w, h) = (size[0].max(1) as f32, size[1].max(1) as f32); + let scale = (photo.width() / w).min(photo.height() / h).max(0.0); + Rect::from_center_size(photo.center(), vec2(w * scale, h * scale)) +} + +/// The parts of `photo` outside `inner` (the matte): up to four strips. +pub fn outside(photo: Rect, inner: Rect) -> Vec<Rect> { + let inner = inner.intersect(photo); + let strips = [ + Rect::from_min_max(photo.min, pos2(inner.left(), photo.bottom())), + Rect::from_min_max(pos2(inner.right(), photo.top()), photo.max), + Rect::from_min_max(pos2(inner.left(), photo.top()), pos2(inner.right(), inner.top())), + Rect::from_min_max(pos2(inner.left(), inner.bottom()), pos2(inner.right(), photo.bottom())), + ]; + strips.into_iter().filter(|r| r.width() > 0.0 && r.height() > 0.0).collect() +} + +/// `img` with its longest side at most `max` pixels (scaled premultiplied, so transparent edges +/// keep their colour); an empty graphic is an error. +pub fn fit_within(img: Rgba8, max: usize) -> Result<Rgba8, String> { + let (w, h) = (img.width, img.height); + if w == 0 || h == 0 { + return Err("the layout image is empty".into()); + } + let max = max.max(1); + if w.max(h) <= max { + return Ok(img); + } + let s = max as f32 / w.max(h) as f32; + let (nw, nh) = (((w as f32 * s).round() as usize).clamp(1, max), ((h as f32 * s).round() as usize).clamp(1, max)); + let pre: lightcraft_raster::Image<[f32; 4]> = lightcraft_raster::Image::from_fn(w, h, |x, y| { + let p = img.get(x, y); + let a = p[3] as f32 / 255.0; + [p[0] as f32 * a, p[1] as f32 * a, p[2] as f32 * a, a] + }); + let scaled = lightcraft_raster::resample::resize(&pre, nw, nh, lightcraft_raster::resample::Filter::Mitchell); + Ok(Rgba8::from_fn(nw, nh, |x, y| { + let [r, g, b, a] = scaled.get(x, y); + let a = a.clamp(0.0, 1.0); + let c = |v: f32| if a <= 1e-4 { 0 } else { (v / a).round().clamp(0.0, 255.0) as u8 }; + [c(r), c(g), c(b), (a * 255.0).round() as u8] + })) +} + +/// Reads and decodes `path` on a worker thread (in place on the web, which has no threads and no +/// files to read: the error says so). +fn start(ctx: &egui::Context, path: &str) -> Loaded { + let loaded: Loaded = Default::default(); + let max = match ctx.input(|i| i.max_texture_side) { + 0 => MAX_SIDE, + m => m.min(MAX_SIDE), + }; + let (out, repaint, path) = (loaded.clone(), ctx.clone(), path.to_string()); + let work = move || { + let r = lightcraft_engine::export::read_graphic(&path).and_then(|img| fit_within(img, max)); + *out.lock().unwrap_or_else(PoisonError::into_inner) = Some(r); + repaint.request_repaint(); + }; + #[cfg(not(target_arch = "wasm32"))] + if let Err(e) = std::thread::Builder::new().name("lc-layout-image".into()).spawn(work) { + *loaded.lock().unwrap_or_else(PoisonError::into_inner) = Some(Err(format!("can't start reading the layout image: {e}"))); + } + #[cfg(target_arch = "wasm32")] + work(); + loaded +} + +/// Draws the layout image (when shown) over `photo`, the photo's rectangle on screen; `p` clips it +/// to the canvas. +pub fn draw(app: &LightcraftApp, p: &egui::Painter, photo: Rect) { + let li = &app.ui.layout_image; + let path = li.path.trim(); + if !li.show || path.is_empty() || photo.width() < 1.0 || photo.height() < 1.0 { + return; + } + let ctx = p.ctx().clone(); + let id = egui::Id::new("loupe-layout-image"); + let file = (path.to_string(), li.revision); + let mut cache = match ctx.data(|d| d.get_temp::<Cache>(id)) { + Some(c) if c.file == file => c, + _ => Cache { loaded: start(&ctx, path), file, tex: None }, + }; + let display_id = app.renderer.display_id(); + if cache.tex.as_ref().is_none_or(|(d, _)| *d != display_id) { + let made = match cache.loaded.lock().unwrap_or_else(PoisonError::into_inner).as_ref() { + // still reading: the worker repaints when it is done + None => None, + Some(Err(e)) => Some(Err(e.clone())), + Some(Ok(img)) => { + let mut img = img.clone(); + // shown through the monitor profile like every other preview + if let Some(d) = app.renderer.display() + && let Err(e) = d.profile.from_srgb(&mut img) + { + log::warn!("layout image, display profile: {e}"); + } + let color = egui::ColorImage::from_rgba_unmultiplied([img.width, img.height], &img.as_bytes()); + Some(Ok(ctx.load_texture("loupe-layout-image", color, egui::TextureOptions::LINEAR))) + } + }; + match made { + None => {} + Some(Ok(tex)) => cache.tex = Some((display_id, tex)), + Some(Err(e)) => note(p, &e), + } + } + if let Some((_, tex)) = &cache.tex { + let r = fit(photo, tex.size()); + let matte = li.matte.clamp(0.0, 1.0); + if matte > 0.0 { + let veil = Color32::from_black_alpha((matte * 255.0).round() as u8); + for strip in outside(photo, r) { + p.rect_filled(strip, 0.0, veil); + } + } + let alpha = (li.opacity.clamp(0.0, 1.0) * 255.0).round() as u8; + p.image(tex.id(), r, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::from_white_alpha(alpha)); + register(&ctx, "canvas:layoutImage", r.intersect(p.clip_rect())); + } + ctx.data_mut(|d| d.insert_temp(id, cache)); +} + +/// Why the layout image isn't shown, at the canvas' bottom left. +fn note(p: &egui::Painter, why: &str) { + let t = Tokens::get(p.ctx()); + let canvas = p.clip_rect(); + let lines = [crate::i18n::tr("The layout image can't be shown"), why]; + let mut y = canvas.bottom() - 14.0; + for (i, l) in lines.iter().enumerate().rev() { + let font = if i == 0 { t.semibold(13.0) } else { t.font(12.0) }; + let g = p.layout_no_wrap(l.to_string(), font, Color32::WHITE); + y -= g.size().y; + let at = pos2(canvas.left() + 14.0, y); + p.galley(at + vec2(1.0, 1.0), g.clone(), Color32::from_black_alpha(200)); + p.galley(at, g, Color32::WHITE); + y -= 3.0; + } + register(p.ctx(), "canvas:layoutImageError", Rect::from_min_max(pos2(canvas.left(), y), pos2(canvas.left() + 360.0, canvas.bottom()))); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_graphic_fits_inside_the_photo_centred() { + let photo = Rect::from_min_size(pos2(100.0, 50.0), vec2(600.0, 400.0)); + // a portrait cover over a landscape photo: as tall as the photo, centred across + let r = fit(photo, [800, 1000]); + assert!((r.height() - 400.0).abs() < 1e-3 && (r.width() - 320.0).abs() < 1e-3, "{r:?}"); + assert!((r.center().x - photo.center().x).abs() < 1e-3 && (r.center().y - photo.center().y).abs() < 1e-3); + // the same shape fills it + let same = fit(photo, [1200, 800]); + assert!((same.width() - 600.0).abs() < 1e-3 && (same.height() - 400.0).abs() < 1e-3); + // a zero-size graphic never divides by zero + assert!(fit(photo, [0, 0]).width().is_finite()); + } + + #[test] + fn the_matte_covers_the_photo_outside_the_graphic_only() { + let photo = Rect::from_min_size(pos2(0.0, 0.0), vec2(600.0, 400.0)); + let r = fit(photo, [800, 1000]); + let strips = outside(photo, r); + assert_eq!(strips.len(), 2, "{strips:?}"); + let area: f32 = strips.iter().map(|s| s.area()).sum(); + assert!((area - (photo.area() - r.area())).abs() < 1e-2); + assert!(strips.iter().all(|s| s.intersect(r).area() < 1e-3)); + // a graphic of the photo's own shape leaves nothing to darken + assert!(outside(photo, fit(photo, [3, 2])).is_empty()); + } + + #[test] + fn large_graphics_are_scaled_down_keeping_shape_and_transparency() { + // a transparent graphic with an opaque red bar across the middle + let img = Rgba8::from_fn(5000, 2500, |_, y| if (1000..1500).contains(&y) { [255, 0, 0, 255] } else { [0, 0, 0, 0] }); + let out = fit_within(img, 1000).unwrap(); + assert_eq!((out.width, out.height), (1000, 500)); + assert_eq!(out.get(500, 250), [255, 0, 0, 255]); + assert_eq!(out.get(500, 20)[3], 0); + // small graphics are kept as they are, empty ones are refused + let small = Rgba8::from_fn(10, 4, |x, _| [x as u8, 0, 0, 255]); + assert_eq!(fit_within(small.clone(), 1000).unwrap().data, small.data); + assert!(fit_within(Rgba8::new(0, 0), 1000).is_err()); + } +} diff --git a/crates/ui-egui/src/panels/left.rs b/crates/ui-egui/src/panels/left.rs index fcaab2926..3bb8deba2 100644 --- a/crates/ui-egui/src/panels/left.rs +++ b/crates/ui-egui/src/panels/left.rs @@ -173,17 +173,17 @@ fn row_need(indent: f32, name_w: f32, mark_w: f32, count_w: Option<f32>) -> f32 42.0 + indent + name_w.min(MAX_NAME_NEED) + 18.0 + mark_w + count_w.map_or(0.0, |w| w + 16.0) } -/// The disclosure triangle at the left of a tree row (`indent` is the row's): right-pointing +/// The disclosure chevron at the left of a tree row (`indent` is the row's): right-pointing /// when closed, down when open, registered as widget `widget` for the control channel. Returns /// its response: the caller flips the open state on `clicked()`. -fn disclosure_triangle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { +fn disclosure_chevron(ui: &mut egui::Ui, row: &egui::Response, indent: f32, open: bool, id: egui::Id, widget: String) -> egui::Response { let t = Tokens::get(ui.ctx()); let c = pos2(row.rect.left() + 10.0 + indent, row.rect.center().y); - let tri = Rect::from_center_size(c, vec2(14.0, 14.0)); + let tri = Rect::from_center_size(c, vec2(16.0, 16.0)); let tr = ui.interact(tri, id, Sense::click()); register(ui.ctx(), widget, tri); let col = if tr.hovered() { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); tr } @@ -197,11 +197,11 @@ fn set_tree_open(ui: &egui::Ui, key: egui::Id, open: bool) { ui.data_mut(|d| d.insert_temp(key, open)); } -/// The triangle of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, -/// Keywords): a click flips `open` and remembers it under `key`. Returns the triangle's response +/// The chevron of a tree row that has rows inside it (Albums' folders, Local, By Date, Folders, +/// Keywords): a click flips `open` and remembers it under `key`. Returns the chevron's response /// (it sits on the row and takes its clicks, so a row's context menu is attached to it too). fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui::Id, open: &mut bool, widget: String) -> egui::Response { - let tri = disclosure_triangle(ui, row, indent, *open, key.with("triangle"), widget); + let tri = disclosure_chevron(ui, row, indent, *open, key.with("triangle"), widget); if tri.clicked() { *open = !*open; set_tree_open(ui, key, *open); @@ -211,15 +211,6 @@ fn tree_toggle(ui: &mut egui::Ui, row: &egui::Response, indent: f32, key: egui:: tri } -/// A disclosure triangle around `c`: pointing down when open, right when closed. -fn triangle_points(c: egui::Pos2, open: bool) -> Vec<egui::Pos2> { - if open { - vec![c + vec2(-4.0, -2.0), c + vec2(4.0, -2.0), c + vec2(0.0, 3.0)] - } else { - vec![c + vec2(-2.0, -4.0), c + vec2(3.0, 0.0), c + vec2(-2.0, 4.0)] - } -} - /// Space above a section's header. const SECTION_GAP: f32 = 10.0; /// Height of a section's header. @@ -399,7 +390,7 @@ pub fn section_drag_feedback(app: &mut LightcraftApp, ctx: &egui::Context) { } /// A section's header in `r`: a disclosure chevron in the gutter (where tree rows have their -/// triangle), then the bold title; a click folds or unfolds the section (kept in the UI state, so +/// chevron), then the bold title; a click folds or unfolds the section (kept in the UI state, so /// it survives restarts), and what the section adds to its header (its button, see `header_button`) sits at /// the visible edge. A folded section says how many rows it holds (`rows`, with View ▸ Show Photo /// Counts). `pinned`: this is the copy kept at the top of the sidebar (see `pinned_header`). @@ -433,9 +424,9 @@ fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, section: SidebarSe ui.painter().rect_filled(bar, 4.0, t.hover.gamma_multiply(0.6)); } let c = pos2(r.left() + 10.0, r.center().y); - register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(14.0, 14.0))); + register(ui.ctx(), format!("{widget}Chevron:{id}"), Rect::from_center_size(c, vec2(16.0, 16.0))); let col = if hovered { t.text } else { t.text_dim }; - ui.painter().add(egui::Shape::convex_polygon(triangle_points(c, open), col, egui::Stroke::NONE)); + paint(ui.painter(), Rect::from_center_size(c, vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, col); ui.painter().text(pos2(r.left() + 18.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.5), t.text_label); let button = header_button(section); // (an album being dragged puts "Top Level" in the Albums header) @@ -602,6 +593,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.spacing_mut().item_spacing.y = 0.0; // the title offers the sections' menu too: where hidden sections come back from let (hr, title) = ui.allocate_exact_size(vec2(ui.available_width(), 44.0), Sense::click()); + crate::widgets::announce(&title, crate::i18n::tr("My Photos")); register(ui.ctx(), "sidebarTitle", hr); title.context_menu(|ui| sections_menu(app, ui, None)); // the open library's name under the title, so nobody edits in the wrong one (issue #902) @@ -1059,7 +1051,9 @@ fn folder_tree( ui.data_mut(|d| d.insert_temp(revealed_id, target)); } } - let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent + 12.0).on_hover_text(path); + // at the tree's own indent, as in Albums and Folders: the triangle sits in the gutter and the + // icon lines up with the rows above + let resp = row(app, ui, &format!("local:{path}"), Icon::Folder, name, None, sel, indent).on_hover_text(path); let tr = tree_toggle(ui, &resp, indent, open_id, &mut open, format!("folderToggle:{path}")); if !tr.clicked() && resp.clicked() @@ -1118,9 +1112,9 @@ fn folder_tree( synchronize_item(app, ui, &held.0, &held.1); } }); - if open && indent < 12.0 * 8.0 { + if open && indent < 16.0 * 8.0 { for (n, p) in subfolders(ui, path) { - folder_tree(app, ui, place, &n, &p, indent + 12.0, current, reveal, false); + folder_tree(app, ui, place, &n, &p, indent + 16.0, current, reveal, false); } } } @@ -1549,7 +1543,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { resp.context_menu(|ui| { if a.folder { // the same commands as File ▸ New…, aimed at this folder - ui.menu_button(crate::i18n::tr("New"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("New"), |ui| { for (label, command) in [ ("Create Album…", "dialog.newAlbum"), ("Create Smart Album…", "dialog.smartAlbum"), @@ -1571,7 +1565,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { ui.separator(); } if !a.folder && !a.is_smart() { - let local_only = super::grid::local_only(app, &app.session.targets(&serde_json::Value::Null)); + let local_only = super::grid::local_only(app, &app.session.selected()); let b = ui .add_enabled(!local_only, egui::Button::new(crate::i18n::tr("Add Selected Photos"))) .on_disabled_hover_text(super::grid::albums_hint()); @@ -1582,7 +1576,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { } } // File ▸ Import's ways in; the import review opens with this album chosen - let menu = ui.menu_button(crate::i18n::tr("Import"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Import"), |ui| { let mut entry = |ui: &mut egui::Ui, label: String, widget: String, command: &str, params: serde_json::Value| { let r = ui.button(label); crate::widgets::register(ui.ctx(), widget, r.rect); @@ -1600,7 +1594,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { entry(ui, crate::i18n::tr(label).into(), widget.into(), command, json!({"album": a.id.0})); } // like File ▸ Import from Device: one entry per camera or card found - let device = ui.menu_button(crate::i18n::tr("Import from Device"), |ui| { + let device = crate::widgets::menu_button(ui, crate::i18n::tr("Import from Device"), |ui| { let devices = lightcraft_engine::devices::devices(); if devices.is_empty() { ui.add_enabled(false, egui::Button::new(crate::i18n::tr("No Camera or Card Found"))); @@ -1649,7 +1643,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let _ = app.run("dialog.export", json!({})); } ui.add_enabled_ui(has_photos, |ui| { - ui.menu_button(crate::i18n::tr("Export Album with Preset"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Export Album with Preset"), |ui| { for (p, _) in app.session.all_export_presets() { if ui.button(&p.name).clicked() { show_all(app); @@ -1666,7 +1660,7 @@ fn folder_menu(app: &mut LightcraftApp, resp: &egui::Response, a: &Album) { let mut folders: Vec<(u64, String)> = app.session.catalog.albums().filter(|f| f.folder && !is_within(app, f.id, a.id)).map(|f| (f.id.0, f.name.clone())).collect(); folders.sort_by_key(|(_, n)| n.to_lowercase()); - ui.menu_button(crate::i18n::tr("Move to"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to"), |ui| { if ui.add_enabled(a.parent.is_some(), egui::Button::new(crate::i18n::tr("Top Level"))).clicked() { let _ = app.run("album.move", json!({"id": a.id.0, "parent": null})); } @@ -1771,6 +1765,7 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s }; let mark = n.label.map(crate::theme::label_color); let resp = row_sensed(app, ui, id, Icon::Folder, name, Some(&spoken), Some(n.count), sel, indent, Sense::click(), mark); + folder_drop_target(app, ui, &resp, n, open_id, open); let mut toggled = false; if !n.children.is_empty() { let tr = tree_toggle(ui, &resp, indent, open_id, open, format!("libraryFolderToggle:{}", n.path)); @@ -1792,6 +1787,134 @@ fn folder_row(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, id: &s row_menu(app, &resp, n); } +/// How long photos dragged over a closed Folders row wait before it opens, so a folder inside it +/// can be reached. +const SPRING_OPEN_SECONDS: f64 = 0.7; + +/// A Folders row while photos are dragged from the grid: a release moves their files there on +/// disk (`photo.moveToFolder`, one undo step). The row lights up and the dragged label says where +/// they will go, as the same gesture onto an album only adds them to it. Held over a closed row, +/// it opens. Photos that can't go there leave it dark (a row covering several disks; Local photos, +/// not in the library) and a release says why; photos already there say so. +fn folder_drop_target(app: &mut LightcraftApp, ui: &mut egui::Ui, resp: &egui::Response, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + let Some(ids) = app.ui.dragging_photos.clone() else { return }; + // only where the row can be seen: not scrolled out of the sidebar or beneath the pinned header + if !pointer_over_row(ui, resp.rect) { + return; + } + spring_open(ui, n, open_id, open); + let name = std::path::Path::new(&n.path).file_name().map(|f| f.to_string_lossy().to_string()).unwrap_or_else(|| n.name.clone()); + let photos: Vec<_> = ids.iter().filter_map(|id| app.session.catalog.photo(lightcraft_catalog::PhotoId(*id))).collect(); + let refused = if !n.selectable { + Some(crate::i18n::tr("This row covers more than one disk: drop on a folder inside it")) + } else if photos.iter().any(|p| p.local) { + Some(crate::i18n::tr("Local photos aren't in the library yet: Add to My Photos first, then move them")) + } else { + None + }; + let here = |p: &lightcraft_catalog::Photo| match &p.source { + lightcraft_catalog::Source::File { path } => std::path::Path::new(path).parent().is_some_and(|d| same_folder(&d.to_string_lossy(), &n.path)), + _ => false, + }; + let all_here = !photos.is_empty() && photos.iter().all(|p| here(p)); + let count = photos.len(); + let released = ui.input(|i| i.pointer.any_released()); + let hint = match (&refused, all_here) { + (Some(_), _) => crate::i18n::tr("Can't be moved here").to_string(), + (None, true) => crate::i18n::tr_format!("Already in “{}”", name), + (None, false) => crate::i18n::tr_format!("Move {n} photo{} to “{}”", if count == 1 { "" } else { "s" }, name, n = count), + }; + super::grid::drag_hint(ui.ctx(), hint); + if let Some(why) = refused { + ui.ctx().set_cursor_icon(egui::CursorIcon::NotAllowed); + if released { + app.toast(ui.ctx(), why); + app.ui.dragging_photos = None; + } + return; + } + if all_here { + if released { + app.ui.dragging_photos = None; + } + return; + } + let t = Tokens::get(ui.ctx()); + ui.painter().rect_stroke(resp.rect.shrink2(vec2(8.0, 1.0)), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + if released { + app.ui.dragging_photos = None; + // a drop moves files on disk: asked first (as Lightroom Classic does), unless the person + // said not to; the menu's Move Selected Photos Here is asked for by name and goes at once + if app.ui.settings.ask_before_move { + let library = app.session.library_generation(); + app.ui.dialog = Some(crate::state::Dialog::MovePhotos { ids, folder: n.path.clone(), name, dont_ask: false, library }); + } else { + move_photos_here(app, ui.ctx(), Some(&ids), &n.path, &name); + } + } +} + +/// Photos dragged over the closed row `n` for [`SPRING_OPEN_SECONDS`] open it (a row with folders +/// inside it). Restarts on each row the pointer comes to, and when it comes back to one after +/// leaving it (or after another drag). +fn spring_open(ui: &egui::Ui, n: &FolderNode, open_id: egui::Id, open: &mut bool) { + if *open || n.children.is_empty() { + return; + } + let key = egui::Id::new("folder-spring-open"); + let (now, frame) = (ui.input(|i| i.time), ui.ctx().cumulative_frame_nr()); + // held without a break: over this row last frame too + let since = match ui.data(|d| d.get_temp::<(String, f64, u64)>(key)) { + Some((path, since, seen)) if path == n.path && seen.saturating_add(1) >= frame => since, + _ => now, + }; + ui.data_mut(|d| d.insert_temp(key, (n.path.clone(), since, frame))); + if now - since >= SPRING_OPEN_SECONDS { + *open = true; + set_tree_open(ui, open_id, true); + ui.data_mut(|d| d.remove::<(String, f64, u64)>(key)); + } else { + // keep the clock running while the pointer rests + ui.ctx().request_repaint_after(std::time::Duration::from_millis(100)); + } +} + +/// Move photos (`ids`, else the selection) into the folder `path` on disk (`photo.moveToFolder`). +/// Photos that leave the view leave the selection, so nothing done next (Delete, an edit) reaches +/// photos no longer on screen. How many moved, or why none did. +pub(crate) fn move_photos(app: &mut LightcraftApp, ids: Option<&[u64]>, path: &str) -> Result<u64, String> { + let mut params = json!({"folder": path}); + if let Some(ids) = ids { + params["ids"] = json!(ids); + } + let r = app.run("photo.moveToFolder", params)?; + let visible: std::collections::HashSet<lightcraft_catalog::PhotoId> = app.session.visible_cloned().into_iter().collect(); + let sel = &mut app.session.selection; + sel.ids.retain(|id| visible.contains(id)); + if sel.active.is_some_and(|a| !visible.contains(&a)) { + sel.active = sel.ids.first().copied(); + } + Ok(r["moved"].as_u64().unwrap_or(0)) +} + +/// What a move of `n` photos into the folder `name` says: how many moved and that Undo moves them +/// back, or that they were there already. +pub(crate) fn moved_text(n: u64, name: &str) -> String { + if n == 0 { + crate::i18n::tr_format!("Already in “{}”", name) + } else { + crate::i18n::tr_format!("Moved {n} photo{} to “{}”. Edit → Undo moves them back.", if n == 1 { "" } else { "s" }, name, n = n) + } +} + +/// [`move_photos`], saying how it went in a toast. +fn move_photos_here(app: &mut LightcraftApp, ctx: &egui::Context, ids: Option<&[u64]>, path: &str, name: &str) { + match move_photos(app, ids, path) { + Ok(n) => app.toast(ctx, moved_text(n, name)), + Err(e) => app.toast(ctx, e), + } +} + /// The context menu of a Folders row: a folder's own, a disk's (remove it), none for the /// startup disk. fn row_menu(app: &mut LightcraftApp, resp: &egui::Response, n: &FolderNode) { @@ -1879,7 +2002,7 @@ fn synchronize_item(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode, /// Set Color Label ▸ of a Folders row (`folder.label`; the submenu is widget `folderLabelMenu`, /// its colours `folderLabel:<colour>`). fn folder_label_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, n: &FolderNode) { - let menu = ui.menu_button(crate::i18n::tr("Set Color Label"), |ui| { + let menu = crate::widgets::menu_button(ui, crate::i18n::tr("Set Color Label"), |ui| { if let Some(l) = crate::panels::grid::label_items(app, ui, n.label, "folderLabel") { if let Err(e) = app.run("folder.label", json!({"path": n.path, "label": crate::panels::grid::label_param(l)})) { app.toast(ui.ctx(), e); @@ -1937,10 +2060,7 @@ fn folder_menu_for_library(app: &mut LightcraftApp, resp: &egui::Response, n: &F .on_hover_text(crate::i18n::tr("Moves the files and their XMP sidecars into this folder; never overwrites")) .clicked() { - match app.run("photo.moveToFolder", json!({"folder": path})) { - Ok(r) => app.toast(ui.ctx(), crate::i18n::tr_format!("Moved {} photo(s)", r["moved"])), - Err(e) => app.toast(ui.ctx(), e), - } + move_photos_here(app, ui.ctx(), None, path, &name); ui.close(); } if app.services.reveal.is_some() @@ -2135,4 +2255,28 @@ mod tests { let l = local_places(builtin(), &[], Some("/home/example/Pictures/Trip"), None, &["/home/example".into()]); assert_eq!((names(&l.places), l.owner), (vec!["Pictures"], Some(0))); } + + #[test] + fn a_local_folder_lines_up_with_the_rows_above_and_its_triangle_stays_left_of_the_icon() { + use crate::Services; + use crate::headless::HeadlessView; + let mut app = crate::LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let path = "/lightcraft-test/Pictures"; + let mut widgets = vec![]; + for frame in 0..3 { + let raw = HeadlessView::raw_input(egui::vec2(300.0, 200.0), 1.0, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| { + super::row(&mut app, ui, "all", super::Icon::Photos, "All Photos", None, false, 0.0); + super::folder_tree(&mut app, ui, path, "Pictures", path, 0.0, None, false, false); + }); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (all, local) = (rect("label:all"), rect(&format!("label:local:{path}"))); + assert_eq!(local.left(), all.left(), "a top-level Local folder's name starts where All Photos' does"); + let triangle = rect(&format!("folderToggle:{path}")); + // the icon is 16 points wide and ends 8 points before the name + assert!(triangle.right() <= local.left() - 24.0, "triangle {triangle:?} overlaps the icon before {local:?}"); + } } diff --git a/crates/ui-egui/src/panels/mask_effects.rs b/crates/ui-egui/src/panels/mask_effects.rs new file mode 100644 index 000000000..505dde129 --- /dev/null +++ b/crates/ui-egui/src/panels/mask_effects.rs @@ -0,0 +1,703 @@ +//! Mask effect presets in the UI: the Effect menu above a mask's sliders (grouped, searchable, +//! keyboard-driven, previews the effect on the photo while the pointer rests on it), the +//! New Effect Preset dialog (which sliders, Amount, brush settings) and the Manage Effect Presets +//! window (search, rename, update, duplicate, stars, groups, delete). Every change runs a +//! `mask.*Effect` command. + +use egui::{Sense, vec2}; +use lightcraft_develop::Mask; +use lightcraft_engine::cmd::mask_effects::{self as fx, FIELDS, MaskEffect}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::state::Dialog; +use crate::theme::Tokens; +use crate::widgets::register; + +/// A slider's label in the effect lists (the colour tint's hue and saturation are one "Color"). +pub fn field_label(k: &str) -> &'static str { + match k { + "color_hue" | "color_sat" => "Color", + _ => super::masking::LOCAL.iter().find(|s| s.id == k).map_or("", |s| s.label), + } +} + +/// The choices of the New Effect Preset checklist: each slider once (Color = hue + saturation). +fn checklist() -> Vec<&'static str> { + FIELDS.iter().copied().filter(|k| *k != "color_hue").collect() +} + +/// "Exposure +0.50 · Clarity −20" — what an effect sets, for the lists. +pub fn summary(e: &MaskEffect) -> String { + let fields = e.field_list(); + let mut parts: Vec<String> = fx::changed_fields(&e.adjust) + .iter() + .filter(|k| fields.contains(&k.as_str()) && k.as_str() != "color_hue") + .map(|k| { + let v = fx::get(&e.adjust, k); + match k.as_str() { + "exposure" => format!("{} {:+.2}", crate::i18n::tr(field_label(k)), v), + "color_sat" => format!("{} {:.0}°/{:.0}", crate::i18n::tr("Color"), e.adjust.color_hue, v), + _ => format!("{} {:+.0}", crate::i18n::tr(field_label(k)), v), + } + }) + .collect(); + if e.with_amount { + parts.push(format!("{} {:.0}", crate::i18n::tr("Amount"), e.adjust.amount)); + } + if parts.is_empty() { + parts.push(crate::i18n::tr("No change").to_string()); + } + parts.join(" · ").replace('-', "−") +} + +/// An effect's name as shown: runs of spaces collapsed (Lightroom users pad names to line up +/// parts of them in its menu; the stored name keeps them). +pub fn display_name(name: &str) -> String { + name.split_whitespace().collect::<Vec<_>>().join(" ") +} + +/// The effect the selected mask's tool last applied (what Update / Rename in the menu act on). +fn last_applied(app: &LightcraftApp, mask: &Mask) -> Option<String> { + let tool = fx::mask_tool(mask)?; + let name = app.session.mask_tool_effects.get(&tool)?; + app.session.mask_effects.iter().find(|e| e.name.eq_ignore_ascii_case(name)).map(|e| e.name.clone()) +} + +/// Apply an effect to `mask` (and take the brush settings it carries). +pub fn apply(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, params: Value) { + let mut p = params; + p["name"] = json!(name); + match app.run("mask.applyEffect", p) { + Ok(v) => take_brush(app, &v["brush"]), + Err(e) => app.toast_error(ctx, e), + } +} + +fn take_brush(app: &mut LightcraftApp, b: &Value) { + let Some(b) = b.as_object() else { return }; + let f = |k: &str| b.get(k).and_then(Value::as_f64).filter(|v| v.is_finite()); + if let Some(v) = f("size") { + app.ui.brush_size = (v as f32).clamp(0.002, 0.5); + } + if let Some(v) = f("feather") { + app.ui.brush_feather = (v as f32).clamp(0.0, 100.0); + } + if let Some(v) = f("flow") { + app.ui.brush_flow = (v as f32).clamp(1.0, 100.0); + } + if let Some(v) = b.get("auto_mask").and_then(Value::as_bool) { + app.ui.brush_auto_mask = v; + } +} + +/// The brush settings in use, as an effect carries them. +fn brush_now(app: &LightcraftApp) -> Value { + json!({"size": app.ui.brush_size, "feather": app.ui.brush_feather, "flow": app.ui.brush_flow, "auto_mask": app.ui.brush_auto_mask}) +} + +/// Open the New Effect Preset dialog for the selected mask. +pub fn open_save(app: &mut LightcraftApp) { + let brush = app.ui.tool == "brush"; + app.ui.dialog = Some(Dialog::SaveEffect { + name: String::new(), + group: String::new(), + fields: checklist().iter().map(|k| k.to_string()).collect(), + amount: false, + brush, + }); +} + +/// Open the Manage Effect Presets window. +pub fn open_manage(app: &mut LightcraftApp) { + app.ui.dialog = + Some(Dialog::ManageEffects { search: String::new(), selected: Vec::new(), rename: None, confirm_delete: false, new_group: String::new() }); +} + +/// The menu's search text and keyboard highlight. +#[derive(Clone, Default)] +struct MenuState { + search: String, + highlight: Option<usize>, + focused: bool, +} + +fn state_id() -> egui::Id { + egui::Id::new("maskEffectMenuState") +} + +/// The effects the menu lists for `query`: (section heading, effect name, its row widget id). +fn menu_rows(effects: &[MaskEffect], query: &str) -> Vec<(String, String, String)> { + let q = query.trim().to_lowercase(); + let hit = |e: &MaskEffect| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q); + let mut rows = Vec::new(); + if q.is_empty() { + let mut favs: Vec<&MaskEffect> = effects.iter().filter(|e| e.favorite).collect(); + favs.sort_by(|a, b| fx::natural_cmp(&a.name, &b.name)); + rows.extend(favs.into_iter().map(|e| ("★ Favorites".to_string(), e.name.clone(), format!("maskEffectFav:{}", e.name)))); + } + for (group, ix) in fx::menu_order(effects) { + for e in ix.iter().filter_map(|i| effects.get(*i)).filter(|e| hit(e)) { + rows.push((group.clone(), e.name.clone(), format!("maskEffect:{}", e.name))); + } + } + rows +} + +/// The Effect menu above the mask's sliders. +pub fn effect_row(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask) { + let t = Tokens::get(ui.ctx()); + egui::Frame::NONE.inner_margin(egui::Margin { left: 24, right: 22, top: 2, bottom: 4 }).show(ui, |ui| { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Effect")).font(t.font(12.0)).color(t.text_label)); + let current = fx::matching_effect(&app.session, &mask.adjust); + let label = current.as_deref().map(display_name).unwrap_or_else(|| crate::i18n::tr("Custom").to_string()); + let r = crate::widgets::dropdown(ui, "maskEffect", &label, t.font(12.0), t.text); + if r.clicked() { + ui.data_mut(|d| d.insert_temp(state_id(), MenuState::default())); + } + egui::Popup::menu(&r).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| menu(app, ui, mask, current.as_deref())); + }); + }); +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, current: Option<&str>) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(260.0); + ui.set_max_width(320.0); + let mut st: MenuState = ui.data(|d| d.get_temp(state_id())).unwrap_or_default(); + let before = st.search.clone(); + let r = crate::text_field::TextField::singleline("maskEffectSearch", &mut st.search) + .hint(crate::i18n::tr("Search effects")) + .width(f32::INFINITY) + .show(ui); + if !st.focused { + r.response.request_focus(); + st.focused = true; + } + if st.search != before { + st.highlight = None; + } + let rows = menu_rows(&app.session.mask_effects, &st.search); + // ↑ ↓ move through the list, Return applies + let (down, up, enter) = ui.input_mut(|i| { + ( + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown), + i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp), + i.key_pressed(egui::Key::Enter) && !rows.is_empty(), + ) + }); + let moved = down || up; + if !rows.is_empty() { + let last = rows.len() - 1; + st.highlight = match (st.highlight, down, up) { + (None, true, _) => Some(0), + (None, _, true) => Some(last), + (Some(h), true, _) => Some((h + 1).min(last)), + (Some(h), _, true) => Some(h.saturating_sub(1)), + (h, _, _) => h.filter(|h| *h <= last), + }; + } + let mut chosen: Option<String> = None; + if enter { + chosen = rows.get(st.highlight.unwrap_or(0)).map(|(_, n, _)| n.clone()); + } + let mut hovered: Option<String> = None; + if app.session.mask_effects.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect presets yet")).color(t.text_dim)); + } else if rows.is_empty() { + ui.label(egui::RichText::new(crate::i18n::tr("No effect matches")).color(t.text_dim)); + } + egui::ScrollArea::vertical().id_salt("maskEffectList").max_height(420.0).auto_shrink([false, true]).show(ui, |ui| { + let mut section: Option<&str> = None; + for (i, (group, name, widget)) in rows.iter().enumerate() { + if section != Some(group.as_str()) { + section = Some(group.as_str()); + if !group.is_empty() { + ui.add_space(4.0); + let heading = if group == "★ Favorites" { crate::i18n::tr("★ Favorites").to_string() } else { group.clone() }; + ui.label(egui::RichText::new(heading).font(t.semibold(11.5)).color(t.text_dim)); + } + } + let fav = app.session.mask_effects.iter().any(|e| e.favorite && &e.name == name); + let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 22.0), Sense::click()); + // (rows scrolled out of view can't be clicked) + if ui.is_rect_visible(rect) { + register(ui.ctx(), widget.clone(), rect); + } + let lit = st.highlight == Some(i) || resp.hovered(); + if lit { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + if current == Some(name.as_str()) { + ui.painter().text(rect.left_center() + vec2(4.0, 0.0), egui::Align2::LEFT_CENTER, "✓", t.font(12.0), t.accent); + } + let room = rect.width() - 22.0 - if fav { 16.0 } else { 0.0 }; + let g = crate::widgets::one_line(ui.painter(), &display_name(name), t.font(12.5), t.text, room); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(18.0, -g.size().y / 2.0), g, t.text); + if fav { + ui.painter().text(rect.right_center() - vec2(4.0, 0.0), egui::Align2::RIGHT_CENTER, "★", t.font(11.0), t.text_dim); + } + if moved && st.highlight == Some(i) { + resp.scroll_to_me(None); + } + let resp = if elided { resp.on_hover_text(display_name(name)) } else { resp }; + if resp.hovered() || (st.highlight == Some(i) && moved) { + hovered = Some(name.clone()); + } + if resp.clicked() { + chosen = Some(name.clone()); + } + resp.context_menu(|ui| row_menu(app, ui, mask, name, fav)); + } + }); + // resting on an effect previews it on the photo (no history entry) + if let Some(name) = hovered.or_else(|| st.highlight.and_then(|h| rows.get(h)).map(|(_, n, _)| n.clone())) { + preview(app, mask, &name); + } + ui.data_mut(|d| d.insert_temp(state_id(), st)); + if let Some(name) = chosen { + apply(app, ui.ctx(), &name, json!({"id": mask.id})); + ui.close(); + return; + } + ui.separator(); + let item = |ui: &mut egui::Ui, id: &str, text: String, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(text).frame(false)); + register(ui.ctx(), id.to_string(), r.rect); + r.clicked() + }; + if item(ui, "maskEffectMenu:reset", crate::i18n::tr("Reset Sliders").to_string(), true) { + let zero: serde_json::Map<String, Value> = FIELDS.iter().map(|k| (k.to_string(), json!(0.0))).collect(); + let _ = app.run("mask.adjust", json!({"id": mask.id, "values": zero})); + if let Some(tool) = fx::mask_tool(mask) { + let _ = app.run("mask.toolEffect", json!({"tool": tool})); + } + ui.close(); + } + if item(ui, "maskEffectMenu:save", crate::i18n::tr("Save Current Settings as New Preset…").to_string(), true) { + open_save(app); + ui.close(); + } + if let Some(last) = last_applied(app, mask) { + let text = crate::i18n::tr_format!("Update “{name}” with Current Settings", name = last); + if item(ui, "maskEffectMenu:update", text, current != Some(last.as_str())) { + let _ = app.run("mask.updateEffect", json!({"name": last, "id": mask.id})); + ui.close(); + } + } + if item(ui, "maskEffectMenu:manage", crate::i18n::tr("Manage Presets…").to_string(), true) { + open_manage(app); + ui.close(); + } + if item(ui, "maskEffectMenu:import", crate::i18n::tr("Import Presets…").to_string(), app.services.pick_preset_files.is_some()) { + let _ = app.run("file.importPresets", json!({})); + ui.close(); + } + let any = !app.session.mask_effects.is_empty(); + if item(ui, "maskEffectMenu:export", crate::i18n::tr("Export Presets…").to_string(), any && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({})); + ui.close(); + } +} + +/// The photo with `name` applied to `mask`, shown while the pointer rests on it. +fn preview(app: &mut LightcraftApp, mask: &Mask, name: &str) { + let Some(id) = app.session.active() else { return }; + let Some(e) = app.session.mask_effects.iter().find(|e| e.name == name) else { return }; + let Some(d) = app.session.develop_of(id) else { return }; + let mut d = (*d).clone(); + let Some(m) = d.masks.iter_mut().find(|m| m.id == mask.id) else { return }; + m.adjust = e.apply(&m.adjust); + app.hover_preview = Some(crate::HoverPreview { label: crate::i18n::tr_format!("Effect: {name}", name = display_name(name)), settings: d }); +} + +/// The right-click menu of an effect in the Effect menu. +fn row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: &Mask, name: &str, fav: bool) { + let run = |app: &mut LightcraftApp, ui: &mut egui::Ui, text: &str, cmd: &str, p: Value| { + let r = ui.button(crate::i18n::tr(text)); + register(ui.ctx(), format!("maskEffectRow:{text}"), r.rect); + if r.clicked() { + if let Err(e) = app.run(cmd, p) { + app.toast_error(ui.ctx(), e); + } + ui.close(); + } + }; + let masks = app.session.active().and_then(|id| app.session.develop_of(id)).map_or(0, |d| d.masks.len()); + if ui.add_enabled(masks > 1, egui::Button::new(crate::i18n::tr("Apply to All Masks"))).clicked() { + apply(app, ui.ctx(), name, json!({"all": true})); + ui.close(); + } + if app.session.selection.ids.len() > 1 { + let text = crate::i18n::tr_format!("Apply to “{mask}” on Selected Photos", mask = mask.name); + if ui.button(text).on_hover_text(crate::i18n::tr("Also the masks of the same name on the other selected photos")).clicked() { + apply(app, ui.ctx(), name, json!({"id": mask.id, "photos": "selected"})); + ui.close(); + } + } + ui.separator(); + run(app, ui, "Update with Current Settings", "mask.updateEffect", json!({"name": name, "id": mask.id})); + if ui.button(crate::i18n::tr("Rename…")).clicked() { + crate::panels::dialogs::prompt(app, "Rename Effect Preset", "Preset name", name, "mask.renameEffect", json!({"name": name}), "to"); + ui.close(); + } + run(app, ui, "Duplicate", "mask.duplicateEffect", json!({"name": name})); + run(app, ui, if fav { "Remove from Favorites" } else { "Add to Favorites" }, "mask.favoriteEffect", json!({"name": name, "favorite": !fav})); + ui.menu_button(crate::i18n::tr("Move to Group"), |ui| group_menu(app, ui, &[name.to_string()])); + ui.separator(); + if ui.button(crate::i18n::tr("Delete…")).clicked() { + app.ui.dialog = Some(Dialog::DeleteEffects { names: vec![name.to_string()] }); + ui.close(); + } +} + +/// The groups to move effects `names` to, None, and New Group…. +fn group_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, names: &[String]) { + let groups: Vec<String> = fx::menu_order(&app.session.mask_effects).into_iter().map(|(g, _)| g).filter(|g| !g.is_empty()).collect(); + for g in groups.iter().map(String::as_str).chain(std::iter::once("")) { + let label = if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.to_string() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": g})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("New Group…")).clicked() { + let names = json!({"names": names}); + crate::panels::dialogs::prompt(app, "New Effect Group", "Group name", "", "mask.setEffectGroup", names, "group"); + ui.close(); + } +} + +/// The New Effect Preset dialog's body. +pub fn save_body( + app: &LightcraftApp, + ui: &mut egui::Ui, + name: &mut String, + group: &mut String, + fields: &mut Vec<String>, + amount: &mut bool, + brush: &mut bool, +) -> bool { + let t = Tokens::get(ui.ctx()); + let r = crate::text_field::TextField::singleline("field:effectName", name).hint(crate::i18n::tr("Preset name")).width(f32::INFINITY).show(ui); + if !r.editing && name.is_empty() && ui.memory(|m| m.focused().is_none()) { + r.response.request_focus(); + } + let enter = r.ending == Some(crate::text_field::Ending::Return); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectGroup", group) + .hint(crate::i18n::tr("Group (optional)")) + .width(ui.available_width() - 30.0) + .show(ui); + let pick = ui.small_button("…").on_hover_text(crate::i18n::tr("Choose a group")); + register(ui.ctx(), "button:effectGroupPick", pick.rect); + egui::Popup::menu(&pick).show(|ui| { + for (g, _) in fx::menu_order(&app.session.mask_effects) { + if ui.button(if g.is_empty() { crate::i18n::tr("None").to_string() } else { g.clone() }).clicked() { + *group = g; + } + } + }); + }); + ui.label(egui::RichText::new(crate::i18n::tr("Settings to include")).color(t.text_dim)); + let adjust = app + .session + .active() + .and_then(|id| app.session.develop_of(id)) + .and_then(|d| app.session.active_mask.and_then(|mid| d.masks.iter().find(|m| m.id == mid).map(|m| m.adjust))); + let changed = adjust.as_ref().map(fx::changed_fields).unwrap_or_default(); + let list = checklist(); + ui.columns(2, |cols| { + for (i, k) in list.iter().enumerate() { + let mut on = fields.iter().any(|f| f == k); + let text = egui::RichText::new(crate::i18n::tr(field_label(k))); + // the sliders this mask changes stand out; the others are dimmed + let text = if changed.iter().any(|c| c == k) { text.color(t.text).strong() } else { text.color(t.text_dim) }; + let r = cols[i % 2].checkbox(&mut on, text); + register(&r.ctx, format!("effectInclude:{k}"), r.rect); + if r.changed() { + fields.retain(|f| f != k); + if on { + fields.push(k.to_string()); + } + } + } + }); + ui.horizontal(|ui| { + for (id, text) in [("all", "All"), ("none", "None"), ("changed", "Changed Only")] { + let b = ui.small_button(crate::i18n::tr(text)); + register(ui.ctx(), format!("effectInclude:{id}"), b.rect); + if b.clicked() { + *fields = match id { + "all" => list.iter().map(|k| k.to_string()).collect(), + "none" => Vec::new(), + _ => changed.iter().filter(|k| k.as_str() != "color_hue").cloned().collect(), + }; + } + } + }); + ui.label( + egui::RichText::new(crate::i18n::tr("Highlighted: sliders this mask changes. Sliders left out stay as they are when the preset is applied.")) + .color(t.text_dim) + .size(11.0), + ); + let r = ui.checkbox(amount, crate::i18n::tr("Include Amount")); + register(ui.ctx(), "check:effectAmount", r.rect); + let r = ui.checkbox(brush, crate::i18n::tr("Include brush size, feather and flow")); + register(ui.ctx(), "check:effectBrush", r.rect); + enter +} + +/// Save from the New Effect Preset dialog. +pub fn save_confirm(app: &mut LightcraftApp, name: &str, group: &str, fields: &[String], amount: bool, brush: bool) -> Result<Value, String> { + if name.trim().is_empty() { + return Err(crate::i18n::tr("Name the preset").to_string()); + } + if fields.is_empty() { + return Err(crate::i18n::tr("Choose at least one setting to include").to_string()); + } + let mut keep: Vec<String> = fields.to_vec(); + if keep.iter().any(|f| f == "color_sat") { + keep.push("color_hue".into()); + } + let all = checklist().iter().all(|k| keep.iter().any(|f| f == k)); + let mut p = json!({"name": name.trim(), "group": group.trim(), "amount": amount}); + p["fields"] = if all { json!("all") } else { json!(keep) }; + if brush { + p["brush"] = brush_now(app); + } + app.run("mask.saveEffect", p) +} + +/// The Manage Effect Presets window's body. +pub fn manage_body( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + search: &mut String, + selected: &mut Vec<String>, + rename: &mut Option<(String, String)>, + confirm_delete: &mut bool, + new_group: &mut String, +) { + let t = Tokens::get(ui.ctx()); + ui.set_min_width(500.0); + // effects that went away (deleted, renamed) leave the selection + selected.retain(|n| app.session.mask_effects.iter().any(|e| &e.name == n)); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:effectSearch", search) + .hint(crate::i18n::tr("Search effects")) + .width(ui.available_width() - 120.0) + .show(ui); + let n = app.session.mask_effects.len(); + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} preset{}", if n == 1 { "" } else { "s" }, n = n)).color(t.text_dim)); + }); + let q = search.trim().to_lowercase(); + let effects = app.session.mask_effects.clone(); + let mut toggle_star: Option<(String, bool)> = None; + let mut commit_rename: Option<(String, String)> = None; + egui::ScrollArea::vertical().id_salt("manageEffects").max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + for (group, ix) in fx::menu_order(&effects) { + let list: Vec<&MaskEffect> = ix + .iter() + .filter_map(|i| effects.get(*i)) + .filter(|e| q.is_empty() || e.name.to_lowercase().contains(&q) || e.group.to_lowercase().contains(&q)) + .collect(); + if list.is_empty() { + continue; + } + ui.add_space(4.0); + let heading = if group.is_empty() { crate::i18n::tr("Ungrouped").to_string() } else { group.clone() }; + let h = ui.add( + egui::Label::new(egui::RichText::new(format!("{heading} ({})", list.len())).font(t.semibold(12.0)).color(t.text_label)) + .sense(Sense::click()), + ); + register(ui.ctx(), format!("effectGroup:{group}"), h.rect); + if h.clicked() { + // a click on the heading selects the group + *selected = list.iter().map(|e| e.name.clone()).collect(); + } + for e in list { + let sel = selected.contains(&e.name); + ui.horizontal(|ui| { + let star = ui.add(egui::Button::new(if e.favorite { "★" } else { "☆" }).frame(false)).on_hover_text(crate::i18n::tr("Favorite")); + if ui.is_rect_visible(star.rect) { + register(ui.ctx(), format!("effectStar:{}", e.name), star.rect); + } + if star.clicked() { + toggle_star = Some((e.name.clone(), !e.favorite)); + } + if let Some((old, text)) = rename.as_mut().filter(|(old, _)| *old == e.name) { + let r = crate::text_field::TextField::singleline("field:effectRename", text).width(220.0).select_on_focus(true).show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.cancelled() { + *rename = None; + } else if r.committed() { + commit_rename = Some((old.clone(), text.trim().to_string())); + } + return; + } + let (rect, r) = ui.allocate_exact_size(vec2(240.0, 22.0), Sense::click()); + if ui.is_rect_visible(rect) { + register(ui.ctx(), format!("effectRow:{}", e.name), rect); + } + if sel { + ui.painter().rect_filled(rect, 3.0, t.tool_active); + } else if r.hovered() { + ui.painter().rect_filled(rect, 3.0, t.hover); + } + let g = crate::widgets::one_line(ui.painter(), &display_name(&e.name), t.font(12.5), t.text, rect.width() - 8.0); + let elided = g.elided; + ui.painter().galley(rect.left_center() + vec2(4.0, -g.size().y / 2.0), g, t.text); + let r = if elided { r.on_hover_text(display_name(&e.name)) } else { r }; + if r.clicked() { + let add = ui.input(|i| i.modifiers.command || i.modifiers.shift); + if add { + if sel { + selected.retain(|n| n != &e.name); + } else { + selected.push(e.name.clone()); + } + } else { + *selected = vec![e.name.clone()]; + } + } + if r.double_clicked() { + *rename = Some((e.name.clone(), e.name.clone())); + } + let mut info = summary(e); + if !e.extra.is_empty() { + info += &crate::i18n::tr_format!(" · kept, not applied: {}", e.extra.keys().cloned().collect::<Vec<_>>().join(", ")); + } + ui.add(egui::Label::new(egui::RichText::new(info).color(t.text_dim).size(11.0)).truncate()); + }); + } + } + }); + if let Some((name, on)) = toggle_star { + let _ = app.run("mask.favoriteEffect", json!({"name": name, "favorite": on})); + } + if let Some((old, to)) = commit_rename { + *rename = None; + if !to.is_empty() && to != old { + match app.run("mask.renameEffect", json!({"name": old, "to": to})) { + Ok(_) => *selected = vec![to], + Err(e) => app.toast_error(ui.ctx(), e), + } + } + } + ui.separator(); + let n = selected.len(); + let one = (n == 1).then(|| selected.first().cloned()).flatten(); + let mask = app.session.active_mask.filter(|_| app.session.active().is_some()); + if *confirm_delete && n > 0 { + ui.label( + egui::RichText::new(crate::i18n::tr_format!("Delete {n} effect preset{}? This can't be undone.", if n == 1 { "" } else { "s" }, n = n)) + .color(t.text), + ); + ui.horizontal(|ui| { + let d = ui.button(crate::i18n::tr("Delete")); + register(ui.ctx(), "button:effectDeleteConfirm", d.rect); + if d.clicked() { + let _ = app.run("mask.deleteEffect", json!({"names": selected.clone()})); + selected.clear(); + *confirm_delete = false; + } + let c = ui.button(crate::i18n::tr("Cancel")); + register(ui.ctx(), "button:effectDeleteCancel", c.rect); + if c.clicked() { + *confirm_delete = false; + } + }); + return; + } + ui.horizontal_wrapped(|ui| { + let button = |ui: &mut egui::Ui, id: &str, text: &str, enabled: bool| { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(text))); + register(ui.ctx(), format!("button:effect{id}"), r.rect); + r.clicked() + }; + if button(ui, "Apply", "Apply", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + apply(app, ui.ctx(), name, json!({"id": mid})); + } + if button(ui, "Update", "Update with Mask", one.is_some() && mask.is_some()) + && let (Some(name), Some(mid)) = (&one, mask) + { + let _ = app.run("mask.updateEffect", json!({"name": name, "id": mid})); + } + if button(ui, "Rename", "Rename", one.is_some()) + && let Some(name) = &one + { + *rename = Some((name.clone(), name.clone())); + } + if button(ui, "Duplicate", "Duplicate", one.is_some()) + && let Some(name) = &one + && let Ok(v) = app.run("mask.duplicateEffect", json!({"name": name})) + && let Some(copy) = v["name"].as_str() + { + *selected = vec![copy.to_string()]; + } + if button(ui, "Favorite", "★ Favorite", n > 0) { + let all_fav = selected.iter().all(|s| effects.iter().any(|e| &e.name == s && e.favorite)); + let _ = app.run("mask.favoriteEffect", json!({"names": selected.clone(), "favorite": !all_fav})); + } + let g = ui.add_enabled(n > 0, egui::Button::new(crate::i18n::tr("Move to Group…"))); + register(ui.ctx(), "button:effectGroup", g.rect); + egui::Popup::menu(&g).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { + let names = selected.clone(); + for (grp, _) in fx::menu_order(&app.session.mask_effects) { + let label = if grp.is_empty() { crate::i18n::tr("None").to_string() } else { grp.clone() }; + if ui.button(label).clicked() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": grp})); + ui.close(); + } + } + ui.separator(); + let r = crate::text_field::TextField::singleline("field:effectNewGroup", new_group) + .hint(crate::i18n::tr("New group — Return")) + .width(180.0) + .show(ui); + if r.ending == Some(crate::text_field::Ending::Return) && !new_group.trim().is_empty() { + let _ = app.run("mask.setEffectGroup", json!({"names": names, "group": new_group.trim()})); + new_group.clear(); + ui.close(); + } + }); + if button(ui, "Export", "Export…", n > 0 && app.services.save_preset_file.is_some()) { + let _ = app.run("file.exportMaskEffects", json!({"names": selected.clone()})); + } + if button(ui, "Delete", "Delete…", n > 0) { + *confirm_delete = true; + } + }); + if mask.is_none() { + ui.label(egui::RichText::new(crate::i18n::tr("Select a mask to apply or update presets.")).color(t.text_dim).size(11.0)); + } +} + +/// The toast after an import (`preset.import` / `mask.importEffects` result): how many effects, +/// in how many groups, and the settings they carry that LightCraft can't apply. +pub fn import_toast(v: &Value) -> String { + let rows: Vec<&Value> = v["effects"].as_array().or_else(|| v["imported"].as_array()).into_iter().flatten().collect(); + let n = rows.len(); + let mut msg = crate::i18n::tr_format!("Imported {e} mask effect{} (Masking ▸ Effect)", if n == 1 { "" } else { "s" }, e = n); + let mut groups: Vec<&str> = rows.iter().filter_map(|r| r["group"].as_str()).filter(|g| !g.is_empty()).collect(); + groups.sort_unstable(); + groups.dedup(); + if !groups.is_empty() { + msg += &crate::i18n::tr_format!(" in {g} group{}", if groups.len() == 1 { "" } else { "s" }, g = groups.len()); + } + let mut skipped: Vec<&str> = rows.iter().flat_map(|r| r["unmapped"].as_array().into_iter().flatten()).filter_map(Value::as_str).collect(); + skipped.sort_unstable(); + skipped.dedup(); + if !skipped.is_empty() { + msg += &crate::i18n::tr_format!(" — kept but not applied: {}", skipped.join(", ")); + } + msg +} diff --git a/crates/ui-egui/src/panels/masking.rs b/crates/ui-egui/src/panels/masking.rs index 26577524a..e0e9d1a42 100644 --- a/crates/ui-egui/src/panels/masking.rs +++ b/crates/ui-egui/src/panels/masking.rs @@ -117,6 +117,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { egui::Grid::new("mask-tiles").spacing(vec2(TILE_GAP, TILE_GAP)).show(ui, |ui| { for (i, (kind, label, icon)) in tiles.iter().enumerate() { let (r, resp) = ui.allocate_exact_size(vec2(tile, 52.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(label)); register(ui.ctx(), format!("maskNew:{kind}"), r); ui.painter().rect_filled(r, 4.0, if resp.hovered() { t.hover } else { t.inset }); paint(ui.painter(), Rect::from_center_size(r.center() - vec2(0.0, 7.0), vec2(20.0, 20.0)), *icon, t.text_label); @@ -235,6 +236,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { continue; } let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click()); + crate::widgets::announce_selected(&resp, m.name.clone(), Some(m.id) == app.session.active_mask); register(ui.ctx(), format!("mask:{}", m.id), r); ui.painter().rect_filled( r, @@ -263,6 +265,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let er = Rect::from_center_size(pos2(r.right() - 16.0, r.center().y), vec2(22.0, 22.0)); register(ui.ctx(), format!("maskVisible:{}", m.id), er); let eye = ui.interact(er, egui::Id::new(("maskVisible", m.id)), Sense::click()); + crate::widgets::announce_selected(&eye, crate::i18n::tr("Show mask"), m.visible); paint( ui.painter(), er.shrink(3.0), @@ -378,6 +381,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { brush_settings(app, ui); } divider(ui); + super::mask_effects::effect_row(app, ui, &m); for s in LOCAL { let v = local_get(&m.adjust, s.id); let out = slider(ui, s, v, true, None); @@ -468,6 +472,7 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.horizontal(|ui| { for c in OVERLAY_COLORS { let (r, resp) = ui.allocate_exact_size(vec2(18.0, 18.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} #{:02x}{:02x}{:02x}", crate::i18n::tr("Overlay color"), c[0], c[1], c[2])); register(ui.ctx(), format!("maskOverlayColor:{:02x}{:02x}{:02x}", c[0], c[1], c[2]), r); ui.painter().rect_filled(r.shrink(2.0), 3.0, egui::Color32::from_rgb(c[0], c[1], c[2])); if app.ui.mask_overlay_color == c { @@ -498,6 +503,9 @@ fn overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.checkbox(&mut pins, crate::i18n::tr("Show Pins")).changed() { let _ = app.run("view.maskPins", json!({"show": pins})); } + if app.ui.mask_pins { + super::right::tool_overlay_options(app, ui); + } } /// Controls for a range component: the selected range (two handles over a dark → light bar), @@ -513,6 +521,7 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape let (lo, hi) = (*lo, *hi); let w = ui.available_width().min(240.0); let (rect, resp) = ui.allocate_exact_size(vec2(w, 18.0), Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Select Luminance Range")); register(ui.ctx(), format!("lumRange:{comp}"), rect); let p = ui.painter(); // dark → light ramp, the selected span outlined @@ -537,18 +546,27 @@ fn range_controls(app: &mut LightcraftApp, ui: &mut egui::Ui, comp: usize, shape p.circle_filled(pos2(x(v), rect.center().y), 5.0, egui::Color32::WHITE); p.circle_stroke(pos2(x(v), rect.center().y), 5.0, Stroke::new(1.0, egui::Color32::from_gray(40))); } - // drag the nearer handle; one undo step per drag + // Choose at the press position, then keep that handle for the entire drag. Testing + // proximity on every frame can move the opposite endpoint after a fast/crossing drag. + let handle_id = resp.id.with("luminance-handle"); + let nearer_low = |px: f32| (px - x(lo)).abs() <= (px - x(hi)).abs(); if resp.drag_started() { + if let Some(origin) = ui.input(|i| i.pointer.press_origin()) { + ui.data_mut(|d| d.insert_temp(handle_id, nearer_low(origin.x))); + } let _ = app.run("develop.beginInteraction", json!({"label": "Luminance Range"})); } if (resp.dragged() || resp.clicked()) && let Some(pos) = resp.interact_pointer_pos() { let v = (((pos.x - rect.left()) / w) as f64).clamp(0.0, 1.0); - let (nlo, nhi) = if (v - lo).abs() <= (v - hi).abs() { (v.min(hi - 0.01), hi) } else { (lo, v.max(lo + 0.01)) }; + let low = + if resp.dragged() { ui.data(|d| d.get_temp::<bool>(handle_id)).unwrap_or_else(|| nearer_low(pos.x)) } else { nearer_low(pos.x) }; + let (nlo, nhi) = if low { (v.min((hi - 0.01).max(0.0)), hi) } else { (lo, v.max((lo + 0.01).min(1.0))) }; update(app, MaskShape::LuminanceRange { lo: nlo, hi: nhi, lo_feather: *lo_feather, hi_feather: *hi_feather }); } if resp.drag_stopped() { + ui.data_mut(|d| d.remove::<bool>(handle_id)); let _ = app.run("develop.endInteraction", json!({})); } // Smoothness: both falloffs at once @@ -659,14 +677,14 @@ fn component_row_menu(app: &mut LightcraftApp, ui: &mut egui::Ui, mask: u32, k: run(ui, "Invert", json!({"action": "invert"})); run(ui, &crate::i18n::tr_format!("Duplicate \"{label}\"", label = label), json!({"action": "duplicate"})); if k > 0 { - ui.menu_button(crate::i18n::tr("Mode"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Mode"), |ui| { for (op, text) in [("add", "Add"), ("subtract", "Subtract"), ("intersect", "Intersect")] { run(ui, text, json!({"action": "op", "op": op})); } }); } - ui.menu_button(crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); - ui.menu_button(crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); + crate::widgets::menu_button(ui, crate::i18n::tr("Intersect with"), |ui| component_menu(app, ui, "intersect")); + crate::widgets::menu_button(ui, crate::i18n::tr("Subtract"), |ui| component_menu(app, ui, "subtract")); ui.separator(); if ui.button(crate::i18n::tr("Rename…")).clicked() { app.ui.renaming_component = Some((mask, k, label.to_string())); @@ -849,6 +867,14 @@ fn brush_settings(app: &mut LightcraftApp, ui: &mut egui::Ui) { { app.ui.brush_auto_mask = auto; } + let mut pressure = app.ui.brush_pressure; + if ui + .checkbox(&mut pressure, crate::i18n::tr("Pen Pressure")) + .on_hover_text(crate::i18n::tr("A pen's pressure sets the size and flow of the brush")) + .changed() + { + app.ui.brush_pressure = pressure; + } }); for (id, label, min, max, get) in [ ("ui.brushSize", "Size", 1.0, 100.0, (app.ui.brush_size * 400.0) as f64), diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index a8d99aed2..2fcb36663 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -9,14 +9,17 @@ pub mod denoise; pub mod detail; pub mod dialogs; pub mod edit; +pub mod empty_library; pub mod faces; pub mod filterbar; pub mod grid; pub mod keymap; pub mod keyword_list; pub mod keywording; +pub mod layout_overlay; pub mod left; pub mod library_problem; +pub mod mask_effects; pub mod masking; pub mod notices; pub mod people; @@ -27,6 +30,11 @@ pub mod right; pub mod rules_editor; pub mod second; pub mod settings; +pub mod slide_layout; +pub mod slide_overlays; +pub mod slide_templates; +pub mod slide_titles; +pub mod slideshow; pub mod strip; pub mod topbar; pub mod unnamed; @@ -86,7 +94,9 @@ pub fn toast(app: &mut LightcraftApp, ctx: &egui::Context) { let foreground = foreground.gamma_multiply(fade); let galley = painter.layout(text, t.font(14.0), foreground, wrap); let size = vec2(galley.size().x + 40.0, (galley.size().y + 22.0).max(40.0)); - let r = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 60.0), size); + // a slideshow's playback controls sit at the bottom: the toast goes to the top there, clear of them + let y = if app.ui.slideshow.is_some() { canvas.top() + 60.0 } else { canvas.bottom() - 60.0 }; + let r = Rect::from_center_size(pos2(canvas.center().x, y), size); painter.rect_filled(r, 6.0, background.gamma_multiply(fade)); painter.galley(r.center() - galley.size() / 2.0, galley, foreground); ctx.request_repaint_after(std::time::Duration::from_millis(30)); @@ -99,3 +109,72 @@ pub fn empty_message(ui: &egui::Ui, rect: Rect, title: &str, body: &str) { p.text(rect.center() - vec2(0.0, 12.0), Align2::CENTER_CENTER, crate::i18n::tr(title), t.semibold(18.0), t.text_label); p.text(rect.center() + vec2(0.0, 14.0), Align2::CENTER_CENTER, crate::i18n::tr(body), t.font(13.0), t.text_dim); } + +/// A button under an empty state's message: the command it runs, its label, and the id it is +/// registered under (`button:…`) for scripts and tests. +pub struct EmptyAction { + pub command: &'static str, + pub label: &'static str, + pub widget: &'static str, +} + +/// [`empty_message`] with the next step under it: a row of buttons (the first is the primary +/// one, filled with the accent) and an optional dimmed hint line below them. The block is +/// centred in `rect` as one. Returns the command of the button clicked this frame. +pub fn empty_state(ui: &mut egui::Ui, rect: Rect, title: &str, body: &str, actions: &[EmptyAction], hint: Option<&str>) -> Option<&'static str> { + let t = Tokens::get(ui.ctx()); + let wrap = (rect.width() - 48.0).max(80.0); + let centred = |ui: &egui::Ui, text: &str, font: egui::FontId, color: egui::Color32| { + let mut job = egui::text::LayoutJob::simple(text.to_string(), font, color, wrap); + job.halign = egui::Align::Center; + ui.painter().layout_job(job) + }; + let title = centred(ui, crate::i18n::tr(title), t.semibold(18.0), t.text_label); + let body = centred(ui, crate::i18n::tr(body), t.font(13.0), t.text_dim); + let hint = hint.map(|h| centred(ui, h, t.font(12.0), t.text_dim)); + // the buttons' widths, and whether they fit side by side (else one above the other) + const BUTTON_H: f32 = 30.0; + const GAP: f32 = 8.0; + let widths: Vec<f32> = + actions.iter().map(|a| ui.painter().layout_no_wrap(crate::i18n::tr(a.label).to_string(), t.semibold(13.0), t.text).size().x + 32.0).collect(); + let row_w = widths.iter().sum::<f32>() + GAP * widths.len().saturating_sub(1) as f32; + let side_by_side = row_w <= wrap; + let buttons_h = match (actions.len(), side_by_side) { + (0, _) => 0.0, + (_, true) => BUTTON_H, + (n, false) => n as f32 * BUTTON_H + (n - 1) as f32 * GAP, + }; + let gap_above_buttons = if actions.is_empty() { 0.0 } else { 20.0 }; + let hint_h = hint.as_ref().map_or(0.0, |g| 12.0 + g.size().y); + let block = title.size().y + 6.0 + body.size().y + gap_above_buttons + buttons_h + hint_h; + let top = rect.center().y - block / 2.0; + let p = ui.painter().clone(); + let body_top = top + title.size().y + 6.0; + let buttons_top = body_top + body.size().y + gap_above_buttons; + p.galley(pos2(rect.center().x, top), title, t.text_label); + p.galley(pos2(rect.center().x, body_top), body, t.text_dim); + let mut clicked = None; + let mut x = rect.center().x - row_w / 2.0; + for (i, (a, w)) in actions.iter().zip(&widths).enumerate() { + let r = if side_by_side { + let r = Rect::from_min_size(pos2(x, buttons_top), vec2(*w, BUTTON_H)); + x += w + GAP; + r + } else { + let w = widths.iter().copied().fold(0.0, f32::max).min(wrap); + Rect::from_min_size(pos2(rect.center().x - w / 2.0, buttons_top + i as f32 * (BUTTON_H + GAP)), vec2(w, BUTTON_H)) + }; + let label = egui::RichText::new(crate::i18n::tr(a.label)).font(t.semibold(13.0)); + let button = + if i == 0 { egui::Button::new(label.color(egui::Color32::WHITE)).fill(t.accent) } else { egui::Button::new(label.color(t.text)) }; + let resp = ui.put(r, button); + crate::widgets::register(ui.ctx(), a.widget, resp.rect); + if resp.clicked() { + clicked = Some(a.command); + } + } + if let Some(g) = hint { + p.galley(pos2(rect.center().x, buttons_top + buttons_h + 12.0), g, t.text_dim); + } + clicked +} diff --git a/crates/ui-egui/src/panels/people.rs b/crates/ui-egui/src/panels/people.rs index 6fdf032f3..0ec008c2c 100644 --- a/crates/ui-egui/src/panels/people.rs +++ b/crates/ui-egui/src/panels/people.rs @@ -69,10 +69,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(pos2(head.left() + PAD, head.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Named People"), t.semibold(15.0), t.text); // the person page last left (a face opened from it, or its back button): one click returns to it if let Some(last) = app.ui.last_person.clone().filter(|n| people.iter().any(|p| &p.name == n)) { - let label = fit(ui.painter(), &format!("{last} ›"), t.font(13.0), 240.0); - let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 24.0; + let label = fit(ui.painter(), &last, t.font(13.0), 224.0); + let width = ui.painter().layout_no_wrap(label.clone(), t.font(13.0), Color32::WHITE).size().x + 44.0; let r = Rect::from_min_size(pos2(title.right() + 14.0, head.center().y - 12.0), vec2(width, 24.0)); - let b = ui.put(r, egui::Button::new(RichText::new(label).font(t.font(13.0))).wrap_mode(egui::TextWrapMode::Extend)); + let mut child = ui.new_child(egui::UiBuilder::new().max_rect(r)); + let b = crate::widgets::arrow_button(&mut child, RichText::new(label).font(t.font(13.0)), crate::icons::Icon::ChevronRight, false); register(ui.ctx(), "people:last", b.rect); if b.on_hover_text(crate::i18n::tr("Back to this person")).clicked() { let _ = app.run("view.person", json!({"name": last})); @@ -156,6 +157,7 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed let t = Tokens::get(ui.ctx()); let face = Rect::from_min_size(r.min, vec2(edge, edge)); let resp = ui.interact(r, egui::Id::new(("person-card", &person.name)), Sense::click()); + crate::widgets::announce(&resp, person.name.clone()); register(ui.ctx(), format!("person:{}", person.name), r); let view = app.session.face_view(person.photo, person.face); let p = ui.painter(); @@ -181,11 +183,25 @@ fn card(app: &mut LightcraftApp, ui: &mut egui::Ui, person: &Person, r: Rect, ed p.text(pos2(r.left() + 2.0, face.bottom() + 14.0), Align2::LEFT_CENTER, name, t.semibold(13.0), t.text); let photos = crate::i18n::tr_format!("{n} photo{}", if person.count == 1 { "" } else { "s" }, n = person.count); p.text(pos2(r.left() + 2.0, face.bottom() + 32.0), Align2::LEFT_CENTER, photos, t.font(12.0), t.text_dim); - if resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)).clicked() { + let resp = resp.on_hover_text(crate::i18n::tr_format!("{} — show their photos", person.name)); + let full = person.name.clone(); + resp.context_menu(|ui| { + let r = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "personMenu:rename", r.rect); + if r.clicked() { + open_rename(app, &full); + } + }); + if resp.clicked() { let _ = app.run("view.person", json!({"name": person.name})); } } +/// The Rename Person dialog, prefilled with the name the card shows. +pub(crate) fn open_rename(app: &mut LightcraftApp, name: &str) { + app.ui.dialog = Some(crate::state::Dialog::RenamePerson { from: name.to_string(), to: name.to_string(), merge: false }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ui-egui/src/panels/person.rs b/crates/ui-egui/src/panels/person.rs index 9ebf09a64..f2c719d43 100644 --- a/crates/ui-egui/src/panels/person.rs +++ b/crates/ui-egui/src/panels/person.rs @@ -1,5 +1,6 @@ //! A person's page in the People view: only cropped faces, never whole photos. First the faces already named, then a -//! "More" section with the unnamed faces that look like them (best first), to be confirmed with a click. +//! "More" section with the unnamed faces that look like them (best first), to be confirmed one at a time or in a +//! user-chosen batch. //! //! The engine does the looking (`faces.person`); this draws it. Only the rows on screen ask for a face render. @@ -82,9 +83,62 @@ enum Hit { Nothing, Open, Confirm, + ConfirmAbove, Dismiss, } +/// The current, session-visible candidates at or above the selected score. The score, not the tile position, is the +/// boundary: a later tile with an equal score belongs to the batch too. +fn at_or_above(more: &[&Face], score: f32) -> Vec<(u64, usize)> { + more.iter().filter(|f| f.score >= score).map(|f| (f.photo, f.index)).collect() +} + +fn confirm_above(app: &mut LightcraftApp, ctx: &egui::Context, name: &str, more: &[&Face], score: f32) { + let faces: Vec<Value> = at_or_above(more, score).into_iter().map(|(photo, index)| json!({"photo": photo, "index": index})).collect(); + if !faces.is_empty() + && let Err(error) = app.run("faces.nameFaces", json!({"faces": faces, "name": name})) + { + app.toast(ctx, error); + } +} + +/// The page-scoped A key takes precedence over View ▸ Visualize Spots only for a visible More tile. Called before the +/// general shortcut dispatcher, using the previous frame's registered rectangles as the pointer's target. +pub(crate) fn handle_bulk_key(app: &mut LightcraftApp, ctx: &egui::Context) -> bool { + if app.ui.view != crate::state::ViewMode::People || app.ui.dialog.is_some() || ctx.text_edit_focused() { + return false; + } + let Some(name) = app.ui.person_page.clone() else { return false }; + if !ctx.input(|i| i.modifiers == egui::Modifiers::NONE && i.key_pressed(egui::Key::A)) { + return false; + } + let Some((cached_name, cached_revision, _, _, page)) = &app.caches.person_page else { return false }; + if *cached_name != name || *cached_revision != app.session.catalog.revision { + return false; + } + let page = page.clone(); + let pointer = ctx.pointer_hover_pos(); + let target = page + .more + .iter() + .filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))) + .find(|f| { + let widget = format!("more-face:{}:{}", f.photo, f.index); + app.widgets.iter().any(|(id, rect)| id == &widget && pointer.is_some_and(|p| rect.contains(p))) + }) + .or_else(|| { + page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).find(|f| { + ctx.memory(|m| { + m.has_focus(egui::Id::new(("more-face", f.photo, f.index))) || m.has_focus(egui::Id::new(("more-face-above", f.photo, f.index))) + }) + }) + }); + let Some(target) = target else { return false }; + let more: Vec<&Face> = page.more.iter().filter(|f| !app.ui.dismissed_faces.contains(&(f.photo, f.index))).collect(); + confirm_above(app, ctx, &name, &more, target.score); + true +} + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let t = Tokens::get(ui.ctx()); let now = ui.input(|i| i.time); @@ -93,17 +147,22 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.add_space(8.0); ui.horizontal(|ui| { ui.add_space(PAD - 4.0); - let back = ui.button("‹ People"); + let back = crate::widgets::arrow_button(ui, crate::i18n::tr("People"), crate::icons::Icon::ChevronLeft, true); register(ui.ctx(), "person:back", back.rect); if back.clicked() { let _ = app.run("view.people", json!({})); } ui.label(RichText::new(&shown_name).font(t.semibold(15.0)).color(t.text)); + let rename = ui.button(crate::i18n::tr("Rename Person…")); + register(ui.ctx(), "person:rename", rename.rect); + if rename.clicked() { + super::people::open_rename(app, &shown_name); + } let count = if page.total == 1 { "1 face".to_string() } else { format!("{} faces", page.total) }; ui.label(RichText::new(count).color(t.text_dim)); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { ui.add_space(PAD - 4.0); - let photos = ui.button("Show photos").on_hover_text("Their photos in the grid"); + let photos = ui.button(crate::i18n::tr("Show photos")).on_hover_text(crate::i18n::tr("Their photos in the grid")); register(ui.ctx(), "person:photos", photos.rect); if photos.clicked() { let _ = app.run("library.filter", json!({"person": shown_name})); @@ -161,7 +220,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = page.confirmed.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), PAD + row as f32 * row_h); - if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name) { + if let Hit::Open = tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, false, &shown_name, 0) { hit = Some((Hit::Open, f.clone())); } } @@ -171,7 +230,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { ui.painter().text(heading, Align2::LEFT_CENTER, "More", t.semibold(15.0), t.text); let sub = match &note { Some(n) => n.clone(), - None => format!("Faces that look like {shown_name}. Click one to confirm it, or × to hide it."), + None => format!("Faces that look like {shown_name}. Click to confirm one, ↑ for all above, or × to hide."), }; ui.painter().text(pos2(area.left() + PAD, area.top() + more_top + 36.0), Align2::LEFT_CENTER, sub, t.font(12.0), t.text_dim); if !recognising { @@ -193,8 +252,10 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let Some(f) = more.get(row * cols + col) else { break }; shown += 1; let min = area.min + vec2(PAD + col as f32 * (edge + super::people::TILE_GAP), grid_top + row as f32 * row_h); - match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name) { + let above_count = more.iter().filter(|candidate| candidate.score >= f.score).count(); + match tile(app, ui, Rect::from_min_size(min, vec2(edge, edge)), f, ppp, true, &shown_name, above_count) { Hit::Confirm => hit = Some((Hit::Confirm, (*f).clone())), + Hit::ConfirmAbove => hit = Some((Hit::ConfirmAbove, (*f).clone())), Hit::Dismiss => hit = Some((Hit::Dismiss, (*f).clone())), _ => {} } @@ -209,13 +270,14 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { let _ = app.run("library.select", json!({"ids": [f.photo], "active": f.photo})); let _ = app.run("view.detail", json!({})); // Escape comes back to this page; the People button to everyone, with this person next to its title - app.ui.person_from = Some((shown_name.clone(), f.photo)); + app.ui.person_from = Some((Some(shown_name.clone()), f.photo)); app.ui.last_person = Some(shown_name.clone()); app.ui.person_page = None; } Hit::Confirm => { let _ = app.run("faces.setName", json!({"id": f.photo, "index": f.index, "name": shown_name})); } + Hit::ConfirmAbove => confirm_above(app, ui.ctx(), &shown_name, &more, f.score), Hit::Dismiss => { app.ui.dismissed_faces.insert((f.photo, f.index)); } @@ -226,15 +288,21 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, name: &str) { /// One face tile. A confirmed one opens its photo when clicked; a "More" one is confirmed when clicked, and has a × that /// hides it. -fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str) -> Hit { +fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, more: bool, name: &str, above_count: usize) -> Hit { let t = Tokens::get(ui.ctx()); let salt = if more { "more-face" } else { "person-face" }; let resp = ui.interact(r, egui::Id::new((salt, f.photo, f.index)), Sense::click()); + crate::widgets::announce(&resp, name.to_string()); register(ui.ctx(), format!("{salt}:{}:{}", f.photo, f.index), r); // the × is added after the tile, so it wins where they overlap let x_rect = Rect::from_min_size(pos2(r.right() - 24.0, r.top() + 4.0), vec2(20.0, 20.0)); let x_resp = more.then(|| ui.interact(x_rect, egui::Id::new(("more-face-x", f.photo, f.index)), Sense::click())); - let hovered = resp.hovered() || x_resp.as_ref().is_some_and(|x| x.hovered()); + let above_rect = Rect::from_min_size(pos2(r.left() + 4.0, r.top() + 4.0), vec2(24.0, 20.0)); + let above_resp = more.then(|| ui.interact(above_rect, egui::Id::new(("more-face-above", f.photo, f.index)), Sense::click())); + if more { + register(ui.ctx(), format!("more-face-above:{}:{}", f.photo, f.index), above_rect); + } + let hovered = resp.hovered() || x_resp.as_ref().is_some_and(|x| x.hovered()) || above_resp.as_ref().is_some_and(|x| x.hovered()); let p = ui.painter(); p.rect_filled(r, 3.0, t.canvas); if let Some(job) = app.session.face_job(lightcraft_catalog::PhotoId(f.photo), f.rect, (r.width() * ppp).ceil() as usize) @@ -247,7 +315,7 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, } let mut hit = Hit::Nothing; if more && hovered { - // a check at the bottom right says a click confirms; the × at the top right hides the face + // a check at the bottom right says a click confirms; × hides and ↑ names all above this score let badge = Rect::from_min_size(pos2(r.right() - 28.0, r.bottom() - 28.0), vec2(24.0, 24.0)); p.circle_filled(badge.center(), 12.0, Color32::from_rgba_unmultiplied(30, 30, 30, 220)); let c = badge.center(); @@ -258,12 +326,25 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, let xc: Pos2 = x_rect.center(); p.line_segment([pos2(xc.x - 3.5, xc.y - 3.5), pos2(xc.x + 3.5, xc.y + 3.5)], Stroke::new(1.6, Color32::WHITE)); p.line_segment([pos2(xc.x - 3.5, xc.y + 3.5), pos2(xc.x + 3.5, xc.y - 3.5)], Stroke::new(1.6, Color32::WHITE)); + p.rect_filled(above_rect, 4.0, Color32::from_rgba_unmultiplied(30, 30, 30, 220)); + let ac = above_rect.center(); + let arrow = Stroke::new(2.0, Color32::WHITE); + p.line_segment([pos2(ac.x, ac.y + 5.0), pos2(ac.x, ac.y - 5.0)], arrow); + p.line_segment([pos2(ac.x - 4.0, ac.y - 1.0), pos2(ac.x, ac.y - 5.0)], arrow); + p.line_segment([pos2(ac.x, ac.y - 5.0), pos2(ac.x + 4.0, ac.y - 1.0)], arrow); } if more { let tip = format!("Looks like {name} (similarity {:.2}). Click to confirm.", f.score); let _ = resp.clone().on_hover_text(tip); + if let Some(above) = above_resp.as_ref() { + let _ = above.clone().on_hover_text(format!( + "All above: confirm {above_count} faces as {name} (this face and every shown face with equal or higher similarity). Press A." + )); + } if x_resp.as_ref().is_some_and(|x| x.clicked()) { hit = Hit::Dismiss; + } else if above_resp.as_ref().is_some_and(|x| x.clicked()) { + hit = Hit::ConfirmAbove; } else if resp.clicked() { hit = Hit::Confirm; } @@ -280,6 +361,18 @@ fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, r: Rect, f: &Face, ppp: f32, mod tests { use super::*; + #[test] + fn all_above_uses_score_including_later_ties_and_only_visible_faces() { + let face = |photo, score| Face { photo, index: 0, rect: lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 1.0, y1: 1.0 }, score }; + // The tie is deliberately after a lower-score tile: an index slice would miss it. + let candidates = [face(1, 0.95), face(2, 0.80), face(3, 0.70), face(4, 0.80), face(5, 0.60)]; + let dismissed = (2_u64, 0_usize); + let shown: Vec<&Face> = candidates.iter().filter(|f| (f.photo, f.index) != dismissed).collect(); + assert_eq!(at_or_above(&shown, 0.80), vec![(1, 0), (4, 0)], "the dismissed face is excluded and the late tie included"); + assert_eq!(at_or_above(&shown, 0.95), vec![(1, 0)], "the highest choice names only itself"); + assert_eq!(at_or_above(&shown, 0.60), vec![(1, 0), (3, 0), (4, 0), (5, 0)], "the lowest choice names the full displayed range"); + } + #[test] fn the_engines_answer_becomes_a_page_and_odd_entries_are_left_out() { let v = json!({ diff --git a/crates/ui-egui/src/panels/presets.rs b/crates/ui-egui/src/panels/presets.rs index 3d90c00c3..ca0243cc5 100644 --- a/crates/ui-egui/src/panels/presets.rs +++ b/crates/ui-egui/src/panels/presets.rs @@ -131,10 +131,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let open_id = egui::Id::new(("preset-group", &g)); let open: bool = ui.data(|d| d.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, g.to_string()); register(ui.ctx(), format!("presetGroup:{g}"), r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); @@ -164,6 +165,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let Some(pr) = app.session.presets.get(i).cloned() else { continue }; let (pid, name, fav) = (pr.id.clone(), crate::i18n::builtin_label(&pr.name, pr.builtin).to_string(), pr.favorite); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), row_h), Sense::click()); + crate::widgets::announce(&resp, name.clone()); register(ui.ctx(), format!("preset:{pid}"), r); let is_last = last.as_ref().is_some_and(|l| l.id == pid); if is_last { @@ -185,7 +187,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } ui.painter().text(pos2(text_x, r.center().y), Align2::LEFT_CENTER, &name, t.font(13.0), t.text_label); if fav { - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(12.0, 12.0)), Icon::StarFilled, t.star); + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 20.0, r.center().y), vec2(16.0, 16.0)), Icon::StarFilled, t.star); } // resting on a preset previews it in the loupe (no history entry) if resp.hovered() @@ -223,7 +225,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { if ui.button(crate::i18n::tr("Rename…")).clicked() { crate::panels::dialogs::prompt(app, "Rename Preset", "Preset name", &name, "preset.rename", json!({"id": pid}), "name"); } - ui.menu_button(crate::i18n::tr("Move to Group"), |ui| { + crate::widgets::menu_button(ui, crate::i18n::tr("Move to Group"), |ui| { for g in groups.iter().filter(|x| x.user && x.name != group).map(|x| &x.name) { if ui.button(g).clicked() { let _ = app.run("preset.move", json!({"id": pid, "group": g})); diff --git a/crates/ui-egui/src/panels/profiles.rs b/crates/ui-egui/src/panels/profiles.rs index 6294d1e64..8a4b08345 100644 --- a/crates/ui-egui/src/panels/profiles.rs +++ b/crates/ui-egui/src/panels/profiles.rs @@ -12,7 +12,7 @@ use super::presets::cover_uv; use crate::icons::{Icon, paint}; use crate::state::RightPanel; use crate::theme::Tokens; -use crate::widgets::{divider, icon_button, register}; +use crate::widgets::{divider, icon_button, one_line, register}; use crate::{HoverPreview, LightcraftApp}; /// Long edge of the variant thumbnails (px). @@ -35,13 +35,9 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let (cr, _) = ui.allocate_exact_size(vec2(ui.available_width(), 34.0), Sense::hover()); let name = app.session.profile_info(&d.profile.id).map(|(name, _)| name).unwrap_or("Color"); ui.painter().text(pos2(cr.left() + 24.0, cr.center().y), Align2::LEFT_CENTER, crate::i18n::tr("Profile"), t.font(13.0), t.text_dim); - ui.painter().text( - pos2(cr.left() + 84.0, cr.center().y), - Align2::LEFT_CENTER, - crate::i18n::profile_label(&d.profile.id, name), - t.font(14.0), - t.text_label, - ); + // a long name is cut short (with …) at the panel's edge + let g = one_line(ui.painter(), crate::i18n::profile_label(&d.profile.id, name), t.font(14.0), t.text_label, cr.width() - 84.0 - 24.0); + ui.painter().galley(pos2(cr.left() + 84.0, cr.center().y - g.size().y / 2.0), g, t.text_label); if d.profile.id != "lc.color" { super::edit::control(app, ui, &d, "profile.amount", true); ui.add_space(6.0); @@ -79,10 +75,11 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let open_id = egui::Id::new(("profile-group", imported, title)); let open: bool = ui.data(|m| m.get_temp(open_id)).unwrap_or(true); let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 32.0), Sense::click()); + crate::widgets::announce(&resp, title.to_string()); register(ui.ctx(), if imported { format!("profileGroup:lut:{title}") } else { format!("profileGroup:{title}") }, r); paint( ui.painter(), - Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(12.0, 12.0)), + Rect::from_center_size(pos2(r.left() + 22.0, r.center().y), vec2(16.0, 16.0)), if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label, ); @@ -157,6 +154,7 @@ fn cell_ui( let t = Tokens::get(ui.ctx()); let key = if tag.is_empty() { format!("profileCell:{p_id}") } else { format!("profileCell:{tag}:{p_id}") }; let resp = ui.interact(cell, egui::Id::new(&key), Sense::click()); + crate::widgets::announce(&resp, p_name.to_string()); register(ui.ctx(), key, cell); let thumb = Rect::from_min_size(cell.min, vec2(cell.width(), th)); let s = with_profile_id(d, p_id); @@ -174,13 +172,9 @@ fn cell_ui( painter.rect_stroke(thumb, 3.0, Stroke::new(1.0, t.text_dim), egui::StrokeKind::Outside); } let name_color = if applied { t.text } else { t.text_label }; - painter.text( - pos2(cell.left() + 2.0, thumb.bottom() + 12.0), - Align2::LEFT_CENTER, - crate::i18n::profile_label(p_id, p_name), - t.font(12.0), - name_color, - ); + // cut short (with …) to the cell, so it never runs into its neighbour; the full name on hover + let g = one_line(painter, crate::i18n::profile_label(p_id, p_name), t.font(12.0), name_color, cell.width() - 2.0); + painter.galley(pos2(cell.left() + 2.0, thumb.bottom() + 12.0 - g.size().y / 2.0), g, name_color); // favourite star (always shown when set, on hover otherwise) let fav = app.session.profile_favorites.iter().any(|f| f == p_id); let star = Rect::from_center_size(pos2(thumb.right() - 13.0, thumb.top() + 13.0), vec2(20.0, 20.0)); @@ -194,6 +188,9 @@ fn cell_ui( app.hover_preview = Some(HoverPreview { label: crate::i18n::tr_format!("Profile: {}", crate::i18n::profile_label(p_id, p_name)), settings: s.clone() }); ui.data_mut(|m| m.insert_temp(egui::Id::new("last-profile-hover"), (id, p_id.to_string()))); + } else if resp.hovered() && applied { + // resting on the applied profile shows it (no preview), not the last other profile hovered + ui.data_mut(|m| m.remove::<(PhotoId, String)>(egui::Id::new("last-profile-hover"))); } let resp = resp.on_hover_text(format!( "{} ({})", diff --git a/crates/ui-egui/src/panels/right.rs b/crates/ui-egui/src/panels/right.rs index 8d0cc0d3a..537330810 100644 --- a/crates/ui-egui/src/panels/right.rs +++ b/crates/ui-egui/src/panels/right.rs @@ -15,24 +15,45 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); let frame = egui::Frame::NONE.fill(t.chrome).stroke(egui::Stroke::new(1.0, t.divider)); frame.show(ui, |ui| { + // (taken on every pass the panel is drawn; one from earlier than the pass before is forgotten) + let held = held_offset(ui.ctx()); let Some(id) = app.session.active() else { // the Keyword List is the library's, not a photo's: it is there without a selection if app.ui.right == RightPanel::Keywords { egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; - header(ui, "Keywords"); - padded(ui, |ui| { - ui.label(egui::RichText::new(crate::i18n::tr("Select photos to give them keywords.")).color(t.text_dim)); - }); + if fold_header(app, ui, "keywording", "Keywords") { + padded(ui, |ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Select photos to give them keywords.")).color(t.text_dim)); + }); + } super::keyword_list::show(app, ui); }); return; } let r = ui.max_rect(); - super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + // every tab (Edit, Info, Versions…) says it: nothing to show until photos come in + if super::empty_library::shown(&app.session) { + super::empty_message(ui, r, "No photos yet", "Import photos to get started"); + } else { + super::empty_message(ui, r, "No photo selected", "Select a photo to edit"); + } return; }; - egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]).show(ui, |ui| { + let mut area = egui::ScrollArea::vertical().id_salt("right-scroll").auto_shrink([false, false]); + if let Some(offset) = held { + area = area.vertical_scroll_offset(offset); + } + // (where the pass starts: by its end egui may have pulled a shrunken panel's offset back) + let area_id = ui.data(|d| d.get_temp::<egui::Id>(egui::Id::new(AREA))); + let start = held.or_else(|| area_id.and_then(|a| egui::scroll_area::State::load(ui.ctx(), a)).map(|s| s.offset.y)); + ui.data_mut(|d| match start { + Some(o) => { + d.insert_temp(egui::Id::new(OFFSET), o); + } + None => d.remove::<f32>(egui::Id::new(OFFSET)), + }); + let scrolled = area.show(ui, |ui| { ui.spacing_mut().item_spacing.y = 0.0; match app.ui.right { RightPanel::Edit => super::edit::show(app, ui, id), @@ -48,15 +69,64 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { RightPanel::None => {} } }); + ui.data_mut(|d| d.insert_temp(egui::Id::new(AREA), scrolled.id)); }); } +/// The panel's scroll area, its offset as the last pass started, and how far +/// [`hold_list_in_place`] asks it to move. +const AREA: &str = "right-scroll-id"; +const OFFSET: &str = "right-scroll-offset"; +const HOLD: &str = "right-scroll-hold"; + +/// Where [`hold_list_in_place`] holds the panel: its offset as the pass before started, moved by as much as +/// the Keywords box grew. Set on the scroll area as it starts, so the pass drawn again shows the list +/// in place (a scroll asked for during a pass only lands a frame later). +fn held_offset(ctx: &egui::Context) -> Option<f32> { + let (grew, asked) = ctx.data_mut(|d| d.remove_temp::<(f32, u64)>(egui::Id::new(HOLD)))?; + if asked.checked_add(1) != Some(ctx.cumulative_pass_nr()) { + return None; + } + let offset = ctx.data(|d| d.get_temp::<f32>(egui::Id::new(OFFSET)))?; + Some((offset + grew).max(0.0)).filter(|o| o.is_finite()) +} + pub fn header(ui: &mut egui::Ui, title: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::hover()); ui.painter().text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, crate::i18n::tr(title), t.semibold(15.0), t.text); } +/// A panel title that folds its section shut and open again (`section:{id}`; kept in +/// [`crate::state::UiState::folded_sections`]). Says whether the section is open. +pub fn fold_header(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, title: &str) -> bool { + let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 46.0), Sense::click()); + let title = crate::i18n::tr(title); + fold_title(app, ui, id, r, &resp, title) +} + +/// [`fold_header`] for a title whose room is already taken (`r`, answering as `resp`). +pub fn fold_title(app: &mut LightcraftApp, ui: &egui::Ui, id: &str, r: Rect, resp: &egui::Response, title: &str) -> bool { + let t = Tokens::get(ui.ctx()); + let open = !app.ui.section_folded(id); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::CollapsingHeader, true, open, title)); + register(ui.ctx(), format!("section:{id}"), r); + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); + } + if resp.has_focus() { + p.rect_stroke(r.shrink(2.0), 4.0, egui::Stroke::new(1.5, t.accent), egui::StrokeKind::Inside); + } + p.text(pos2(r.left() + 24.0, r.center().y + 2.0), Align2::LEFT_CENTER, title, t.semibold(15.0), t.text); + let chevron = Rect::from_center_size(pos2(r.right() - 30.0, r.center().y + 2.0), vec2(14.0, 14.0)); + paint(p, chevron, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.icon); + if resp.clicked() { + app.ui.toggle_folded(id); + } + open +} + pub fn label_row(ui: &mut egui::Ui, label: &str, value: &str) { let t = Tokens::get(ui.ctx()); let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::hover()); @@ -454,6 +524,8 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { app.ui.spots_threshold = v as f32; } padded(ui, |ui| { + tool_overlay_options(app, ui); + ui.add_space(4.0); if !d.spots.is_empty() && text_button(ui, "removeClear", crate::i18n::tr("Delete all spots"), false).clicked() { for i in (0..d.spots.len()).rev() { let _ = app.run("spot.delete", json!({"index": i})); @@ -462,6 +534,28 @@ fn remove(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { }); } +/// Tool Overlay: when the spot and mask pins show on the photo (Auto / Always / Selected / Never; +/// Shift+H steps through them). Remove and Masking panels. +pub(crate) fn tool_overlay_options(app: &mut LightcraftApp, ui: &mut egui::Ui) { + use crate::state::ToolOverlay; + let t = Tokens::get(ui.ctx()); + ui.horizontal(|ui| { + ui.label(egui::RichText::new(crate::i18n::tr("Tool Overlay")).color(t.text_dim)); + let current = app.ui.tool_overlay; + let r = crate::widgets::dropdown(ui, "toolOverlay", crate::i18n::tr(current.label()), t.font(12.5), t.text_label) + .on_hover_text(crate::i18n::tr("When pins show on the photo (Shift+H cycles)")); + egui::Popup::menu(&r).show(|ui| { + for m in ToolOverlay::ALL { + let item = ui.selectable_label(m == current, crate::i18n::tr(m.label())); + register(ui.ctx(), format!("toolOverlay:{}", m.name()), item.rect); + if item.clicked() { + let _ = app.run("view.toolOverlay", json!({"mode": m.name()})); + } + } + }); + }); +} + fn red_eye(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let d = app.session.develop_of(id).unwrap_or_default(); header(ui, "Red Eye"); @@ -534,7 +628,9 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { camera_card(ui, &p); ui.add_space(6.0); if let Some(why) = &p.preview_only { - crate::widgets::preview_only_notice(ui, "info", why); + if crate::widgets::preview_only_notice(ui, "info", why) { + crate::panels::edit::reload_preview_only(app, ui.ctx(), id); + } ui.add_space(6.0); } if let Some(name) = &p.copy_name { @@ -554,6 +650,7 @@ fn info(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { ui.horizontal(|ui| { for l in lightcraft_catalog::ColorLabel::ALL { let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&format!("{l:?}"))); let key = format!("{l:?}").to_lowercase(); register(ui.ctx(), format!("label:{key}"), r); let on = p.label == Some(l); @@ -760,9 +857,28 @@ fn camera_card(ui: &mut egui::Ui, p: &lightcraft_catalog::Photo) { } }); } + // where the camera focused, and how (View ▸ Show Focus Point draws it): only when known; the + // whole card's width, so "AF-C · Expanded Flexible Spot · Face tracking" fits + if let Some(text) = focus_text(m.focus.as_ref()) { + ui.add_space(3.0); + let r = ui.horizontal_wrapped(|ui| { + ui.label(dim(crate::i18n::tr("Focus"))); + ui.add_space(12.0); + ui.label(egui::RichText::new(text).size(12.0).color(t.text)); + }); + register(ui.ctx(), "info:focus", r.response.rect); + } }); } +/// The camera's focus mode, AF area and tracking in the interface language ("AF-C · Wide · Animal Eye +/// Tracking"), or `None` when the photo has no focus point. +pub(crate) fn focus_text(f: Option<&lightcraft_meta::FocusPoint>) -> Option<String> { + let f = f?; + let words: Vec<&str> = f.words().into_iter().map(crate::i18n::tr).collect(); + Some(if words.is_empty() { crate::i18n::tr("Focus point").to_string() } else { words.join(" · ") }) +} + fn human_size(bytes: u64) -> String { match bytes { b if b >= 1 << 20 => format!("{:.1} MB", b as f64 / (1u64 << 20) as f64), @@ -777,7 +893,7 @@ fn human_size(bytes: u64) -> String { fn copyright_status(app: &mut LightcraftApp, ui: &mut egui::Ui, current: lightcraft_catalog::CopyrightStatus) { let t = Tokens::get(ui.ctx()); ui.label(egui::RichText::new(crate::i18n::tr("Copyright Status")).size(11.5).color(t.text_dim)); - let r = egui::ComboBox::from_id_salt("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { + let r = crate::widgets::combo_box("info-copyright-status").selected_text(crate::i18n::tr(current.label())).show_ui(ui, |ui| { for st in lightcraft_catalog::CopyrightStatus::ALL { if ui.selectable_label(st == current, crate::i18n::tr(st.label())).clicked() && st != current { let _ = app.run("photo.setMeta", json!({"copyrightStatus": st.id()})); @@ -818,27 +934,136 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { if app.session.catalog.photo(id).is_none() { return; } - header(ui, "Keywords"); + let top = ui.cursor().top(); + if fold_header(app, ui, "keywording", "Keywords") { + keywording(app, ui); + } + hold_list_in_place(ui, top); + super::keyword_list::show(app, ui); +} + +/// The Keywording box: the field that adds keywords, the selection's keywords, the keyword set, +/// the painter and the suggestions. +fn keywording(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); padded(ui, |ui| { let kid = egui::Id::new("kw-input"); let mut text = ui.data_mut(|d| d.get_temp::<String>(kid).unwrap_or_default()); - let r = - crate::text_field::TextField::singleline("field:keyword", &mut text).hint(crate::i18n::tr("Add keyword")).width(f32::INFINITY).show(ui); + // the keywords every selected photo has: those only some have are still offered and suggested + let selection = app.session.selected(); + let have: Vec<String> = + app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); + // while the box has the cursor it offers the library's keywords for what is being typed, + // right under itself; it stays for a click on one of them, which takes the cursor away + let field = crate::text_field::id("field:keyword"); + let oid = egui::Id::new("kw-offers"); + let pass = ui.ctx().cumulative_pass_nr(); + // (remembered from the last frame: the text the highlight was for and the keyword it was + // on, where the offers were drawn, and whether the box was in sight) + let (typed_then, at_then, drawn, drawn_pass, hidden): (String, Option<String>, Rect, u64, bool) = + ui.data(|d| d.get_temp(oid)).unwrap_or((String::new(), None, Rect::NOTHING, 0, false)); + let last_frame = drawn_pass.checked_add(1) == Some(pass); + let clicking = last_frame + && ui.input(|i| (i.pointer.any_down() || i.pointer.any_released()) && i.pointer.interact_pos().is_some_and(|p| drawn.contains(p))); + let typed = super::keywording::typing(&text).to_string(); + // (none while the box is scrolled out of the panel: they would float over what is there) + let offers = ((ui.memory(|m| m.has_focus(field)) || clicking) && !(last_frame && hidden)) + .then(|| app.caches.completions(&app.session.catalog, &have, &typed)) + .filter(|o| !o.keywords.is_empty() || o.create.is_some()); + // ↓ / ↑ move through the offers (none is highlighted at first, and ↑ from the first goes + // back to none: Return then gives what was typed, as without offers); Return gives the + // highlighted one. The highlight is on a keyword, not a line: it stays with it when the + // library changes under it, and goes when the keyword does. With Shift or Alt the keys + // are the text's own. + let mut at = at_then.filter(|k| typed_then == typed && offers.as_ref().is_some_and(|o| o.keywords.contains(k))); + let mut picked = None; + if let Some(o) = offers.as_ref().filter(|o| !o.keywords.is_empty()) { + let pressed = |ui: &mut egui::Ui, key: egui::Key| { + ui.input_mut(|i| { + let plain = + |e: &egui::Event| matches!(e, egui::Event::Key { key: k, pressed: true, modifiers, .. } if *k == key && modifiers.is_none()); + let found = i.events.iter().any(plain); + i.events.retain(|e| !plain(e)); + found + }) + }; + let line = at.as_ref().and_then(|k| o.keywords.iter().position(|x| x == k)); + if pressed(ui, egui::Key::ArrowDown) { + let next = line.map_or(0, |a| a.saturating_add(1)).min(o.keywords.len().saturating_sub(1)); + at = o.keywords.get(next).cloned(); + } else if pressed(ui, egui::Key::ArrowUp) { + at = line.and_then(|a| a.checked_sub(1)).and_then(|a| o.keywords.get(a).cloned()); + } + if let Some(k) = &at + && pressed(ui, egui::Key::Enter) + { + picked = Some(super::keywording::Picked::Keyword(k.clone())); + } + } + // (after an offer was taken the box starts a new edit, empty: Esc has nothing to bring back) + // (asked for on the frame before this one only: a box that wasn't drawn then, its panel + // switched away, doesn't claim the cursor whenever it is next drawn) + let bid = egui::Id::new("kw-begin"); + let begin = ui.data_mut(|d| d.remove_temp::<u64>(bid)).is_some_and(|asked| asked.checked_add(1) == Some(pass)); + let r = crate::text_field::TextField::singleline("field:keyword", &mut text) + .hint(crate::i18n::tr("Add keyword")) + .width(f32::INFINITY) + .begin_edit(begin) + .show(ui); + let in_sight = ui.clip_rect().contains(r.response.rect.center()); + let mut drawn = Rect::NOTHING; + // (what isn't drawn can't be taken: Return on the frame the box scrolls out gives nothing) + if !in_sight { + picked = None; + } + if offers.is_none() { + // (not over the offers) + r.response.clone().on_hover_text(crate::i18n::tr("Separate several keywords with commas. Return gives them to the selected photos.")); + } + if let Some(o) = offers.as_ref().filter(|_| in_sight) { + let (clicked, rect) = super::keywording::offers_below(ui, r.response.rect, o, at.as_deref()); + drawn = rect; + picked = picked.or(clicked); + } + ui.data_mut(|d| d.insert_temp(oid, (typed.clone(), at, drawn, pass, !in_sight))); // Return gives the keywords to the selected photos; Esc gives back what was there before // this edit (the shared field does) - if r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty() { - // a new name goes inside the default parent (Put New Keywords Inside This Keyword) - let kws: Vec<String> = text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect(); - let _ = app.run("photo.setMeta", json!({"addKeywords": kws})); - text.clear(); + let returned = r.ending == Some(crate::text_field::Ending::Return) && !text.trim().is_empty(); + // a new name goes inside the default parent (Put New Keywords Inside This Keyword) + let as_typed = |app: &LightcraftApp, text: &str| -> Vec<String> { + text.split(',').map(|s| app.session.catalog.typed_keyword(s)).filter(|s| !s.is_empty()).collect() + }; + let adding: Option<Vec<String>> = match picked { + // an offer: it, and the keywords typed before the last comma + Some(super::keywording::Picked::Keyword(k)) => { + let mut kws = super::keywording::typed_before(&app.session.catalog, &text); + kws.push(k); + Some(kws) + } + Some(super::keywording::Picked::Typed) => Some(as_typed(app, &text)), + None if returned => Some(as_typed(app, &text)), + None => None, + }; + if let Some(kws) = adding { + // (refused, the text stays to be put right, and the refusal is said) + match app.run("photo.setMeta", json!({"addKeywords": kws})) { + Ok(_) => { + text.clear(); + // an offer taken leaves the cursor in the box, for the next keyword + if !returned { + ui.data_mut(|d| d.insert_temp(bid, pass)); + } + } + Err(e) => app.toast_error(ui.ctx(), e), + } } ui.data_mut(|d| d.insert_temp(kid, text)); ui.add_space(8.0); super::keywording::view_switch(app, ui); ui.add_space(4.0); + super::keywording::scope(app, ui, &selection); if app.ui.keywording_view == crate::state::KeywordingView::Keywords { - super::keywording::chip_row(app, ui); + super::keywording::chip_row(app, ui, &selection); } else { super::keywording::names_row(app, ui); } @@ -871,47 +1096,74 @@ fn keywords(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { } }); ui.add_space(10.0); - // suggestions: completions of the typed text, else keywords used together with this - // photo's keywords, else the most used ones - let typed = ui.data(|d| d.get_temp::<String>(kid).unwrap_or_default()); - let last = typed.rsplit(',').next().unwrap_or("").trim().to_string(); - // the keywords every selected photo has: those only some have are still suggested - let selection = app.session.targets(&serde_json::Value::Null); - let have: Vec<String> = - app.caches.keyword_chips(&app.session.catalog, &selection).iter().filter(|c| c.on_all()).map(|c| c.path.clone()).collect(); - let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, &last, 12)).clone(); + // suggestions: keywords used together with the selection's keywords, else the most used + // ones (what matches the text being typed is offered under the box itself) + let suggestions = (*app.caches.suggestions(&app.session.catalog, &have, "", 12)).clone(); if !suggestions.is_empty() { ui.label(egui::RichText::new(crate::i18n::tr("Suggestions")).color(t.text_dim)); + // grouped: the keyword's own name, its whole path in the tooltip + let grouped = app.ui.settings.keyword_chips == crate::state::KeywordChips::Grouped; + let names = if grouped { super::keywording::suggestion_names(&suggestions) } else { Vec::new() }; ui.horizontal_wrapped(|ui| { - for k in suggestions { - let r = ui - .add(egui::Button::new(egui::RichText::new(format!("+ {}", k.replace('|', " › "))).color(t.text_label)).corner_radius(10.0)); + if grouped { + ui.spacing_mut().item_spacing = vec2(6.0, 4.0); + } + for (i, k) in suggestions.into_iter().enumerate() { + let whole = k.replace('|', " › "); + let name = names.get(i).unwrap_or(&whole); + let r = ui.add(egui::Button::new(egui::RichText::new(format!("+ {name}")).color(t.text_label)).corner_radius(10.0)); + let r = if grouped { r.on_hover_text(&whole) } else { r }; register(ui.ctx(), format!("kwSuggest:{k}"), r.rect); if r.clicked() { let _ = app.run("photo.setMeta", json!({"addKeywords": [k]})); - if !last.is_empty() { - ui.data_mut(|d| d.insert_temp(kid, String::new())); - } } } }); } }); - super::keyword_list::show(app, ui); +} + +/// Keeps the Keyword List where it is on screen when the Keywords box above it (starting at +/// `top`) grows or shrinks: a keyword ticked or unticked in the list, another selection. Without it +/// a long tree slides away from under the pointer by a row of chips each time. It holds only while +/// the box's top is scrolled out of sight (at the top of the panel the box is watched growing) and +/// while the change does not come from the box itself (the pointer on it, or typing in its field). +fn hold_list_in_place(ui: &mut egui::Ui, top: f32) { + let height = ui.cursor().top() - top; + let key = egui::Id::new("kw-box-height"); + let pass = ui.ctx().cumulative_pass_nr(); + // (the height drawn on the pass before: one from an earlier visit to the panel says nothing) + let before = ui.data(|d| d.get_temp::<(f32, u64)>(key)).filter(|&(_, drawn)| drawn.checked_add(1) == Some(pass)).map(|(h, _)| h); + ui.data_mut(|d| d.insert_temp(key, (height, pass))); + let Some(before) = before else { return }; + let grew = height - before; + if !grew.is_finite() || grew.abs() < 0.5 || top >= ui.clip_rect().top() - 0.5 { + return; + } + // (only the part of it in sight: the pointer on the top bar above a half-hidden box isn't on it) + let old_box = Rect::from_x_y_ranges(ui.max_rect().x_range(), top..=top + before).intersect(ui.clip_rect()); + let on_box = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| old_box.contains(p)); + let typing = ui.memory(|m| m.has_focus(crate::text_field::id("field:keyword"))); + if on_box || typing { + return; + } + ui.data_mut(|d| d.insert_temp(egui::Id::new(HOLD), (grew, pass))); + // (drawn again at once at the new offset, so the list never shows a frame out of place) + ui.ctx().request_discard("the Keyword List holds its place"); } /// The keyword set: pick a set, then nine buttons (⌥1–⌥9) that toggle its keywords on the /// selected photos; "Save as Set…" keeps the current nine under a name. fn keyword_set(app: &mut LightcraftApp, ui: &mut egui::Ui) { // on: every selected photo has it (⌥1–⌥9 then take it off them); partly on: only some do - let selection = app.session.targets(&serde_json::Value::Null); + let selection = app.session.selected(); let chips = app.caches.keyword_chips(&app.session.catalog, &selection); let t = Tokens::get(ui.ctx()); let sets = lightcraft_engine::cmd::keywords::keyword_sets_json(&app.session); let current = sets["current"].as_str().unwrap_or_default().to_string(); ui.horizontal(|ui| { ui.label(egui::RichText::new(crate::i18n::tr("Keyword Set")).color(t.text_dim)); - let combo = egui::ComboBox::from_id_salt("kw-set") + let combo = crate::widgets::combo_box("kw-set") .selected_text(crate::i18n::builtin_label(&current, current == lightcraft_engine::cmd::keywords::RECENT)) .show_ui(ui, |ui| { for set in sets["sets"].as_array().into_iter().flatten() { @@ -1030,6 +1282,7 @@ fn versions(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { // newest first for (i, v) in shown.into_iter().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 58.0), Sense::click()); + crate::widgets::announce(&resp, format!("{} {}", crate::i18n::tr("Version"), i + 1)); register(ui.ctx(), format!("version:{i}"), r); let current = *v.settings == *p.develop; if resp.hovered() { @@ -1136,6 +1389,7 @@ fn activity(app: &mut LightcraftApp, ui: &mut egui::Ui, id: PhotoId) { let cursor = p.history_index(); for (i, h) in p.history.iter().enumerate().rev() { let (r, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 24.0), Sense::click()); + crate::widgets::announce(&resp, crate::i18n::tr(&h.label)); register(ui.ctx(), format!("history:{i}"), r); let row = history_row(i, cursor); if row == HistoryRow::Current { diff --git a/crates/ui-egui/src/panels/rules_editor.rs b/crates/ui-egui/src/panels/rules_editor.rs index 006d42667..1ec211d51 100644 --- a/crates/ui-egui/src/panels/rules_editor.rs +++ b/crates/ui-egui/src/panels/rules_editor.rs @@ -51,7 +51,7 @@ fn match_combo(ui: &mut egui::Ui, salt: &str, m: &mut Match) { Match::Any => "any", Match::None => "none", }; - egui::ComboBox::from_id_salt(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-match")).width(64.0).selected_text(crate::i18n::tr(label(*m))).show_ui(ui, |ui| { for x in [Match::All, Match::Any, Match::None] { ui.selectable_value(m, x, crate::i18n::tr(label(x))); } @@ -149,7 +149,7 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & let mut unit = stored_unit.to_lowercase(); let mut changed = ui.add(egui::DragValue::new(&mut n).range(1.0..=10_000.0).clamp_existing_to_range(false).speed(0.2).fixed_decimals(0)).changed(); - egui::ComboBox::from_id_salt(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-unit")).width(70.0).selected_text(crate::i18n::tr(&unit)).show_ui(ui, |ui| { for u in ["hours", "days", "weeks", "months", "years"] { if ui.selectable_label(unit.trim_end_matches('s') == u.trim_end_matches('s'), crate::i18n::tr(u)).clicked() { unit = u.to_string(); @@ -198,23 +198,20 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & (Some(Kind::Number), _) => number_value(ui, v, field), (Some(Kind::Choice(c)), _) => { let cur = v.as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui( - ui, - |ui| { - for (id, _) in c.iter() { - if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { - *v = json!(id); - } + crate::widgets::combo_box(format!("{salt}-choice")).width(120.0).selected_text(crate::i18n::choice_text(field, &cur)).show_ui(ui, |ui| { + for (id, _) in c.iter() { + if ui.selectable_label(cur == *id, crate::i18n::choice_text(field, id)).clicked() { + *v = json!(id); } - }, - ); + } + }); } (Some(Kind::Bool), _) => { // the catalog's reading of the value ("false" is no); one that is neither yes nor no is // shown as written and kept until a choice replaces it, so the dialog reports it let cur = bool_value(v); let text = cur.map_or_else(|| v.to_string(), |b| crate::i18n::bool_text(b).to_string()); - egui::ComboBox::from_id_salt(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{salt}-bool")).width(60.0).selected_text(text).show_ui(ui, |ui| { for b in [true, false] { if ui.selectable_label(cur == Some(b), crate::i18n::bool_text(b)).clicked() { *v = json!(b); @@ -235,38 +232,37 @@ fn value_editor(ui: &mut egui::Ui, field: &str, op: &str, v: &mut Value, salt: & fn field_menu(ui: &mut egui::Ui, salt: &str, field: &mut String, op: &mut String, value: &mut Value) { let label = field_label(field).unwrap_or(field.as_str()).to_string(); let current = field.clone(); - let r = - egui::ComboBox::from_id_salt(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { - let mut pick = None; - let mut item = |ui: &mut egui::Ui, id: &'static str| { - let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); - register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); - if r.clicked() { - pick = Some(id); - ui.close(); + let r = crate::widgets::combo_box(format!("{salt}-field")).width(150.0).height(400.0).selected_text(crate::i18n::tr(&label)).show_ui(ui, |ui| { + let mut pick = None; + let mut item = |ui: &mut egui::Ui, id: &'static str| { + let r = ui.selectable_label(current == id, crate::i18n::tr(field_label(id).unwrap_or(id))); + register(ui.ctx(), format!("ruleFieldItem:{id}:{salt}"), r.rect); + if r.clicked() { + pick = Some(id); + ui.close(); + } + }; + for id in TOP_LEVEL_FIELDS { + item(ui, id); + } + ui.separator(); + // submenu rows look like the items above them; the one holding the current field stands out + // (a transparent frame, not none: a frameless button drops its padding and hover fill) + let inactive = &mut ui.visuals_mut().widgets.inactive; + inactive.weak_bg_fill = egui::Color32::TRANSPARENT; + inactive.bg_stroke = egui::Stroke::NONE; + for (group, ids) in FIELD_GROUPS { + let text = RichText::new(crate::i18n::tr(group)); + let text = if ids.contains(&current.as_str()) { text.strong() } else { text }; + let r = crate::widgets::menu_button(ui, text, |ui| { + for id in *ids { + item(ui, id); } - }; - for id in TOP_LEVEL_FIELDS { - item(ui, id); - } - ui.separator(); - // submenu rows look like the items above them; the one holding the current field stands out - // (a transparent frame, not none: a frameless button drops its padding and hover fill) - let inactive = &mut ui.visuals_mut().widgets.inactive; - inactive.weak_bg_fill = egui::Color32::TRANSPARENT; - inactive.bg_stroke = egui::Stroke::NONE; - for (group, ids) in FIELD_GROUPS { - let text = RichText::new(crate::i18n::tr(group)); - let text = if ids.contains(&current.as_str()) { text.strong() } else { text }; - let r = ui.menu_button(text, |ui| { - for id in *ids { - item(ui, id); - } - }); - register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); - } - pick - }); + }); + register(ui.ctx(), format!("ruleFieldGroup:{group}:{salt}"), r.response.rect); + } + pick + }); register(ui.ctx(), format!("ruleField:{salt}"), r.response.rect); let Some(Some(id)) = r.inner else { return }; if field == id { @@ -321,7 +317,7 @@ pub fn edit(ui: &mut egui::Ui, rs: &mut RuleSet, salt: &str, depth: usize, path: field_menu(ui, &rsalt, field, op, value); let kind = field_kind(field).unwrap_or(Kind::Text); let op_label = ops_for(kind).iter().find(|o| o.0 == op.as_str()).map_or(op.as_str(), |o| o.1).to_string(); - egui::ComboBox::from_id_salt(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { + crate::widgets::combo_box(format!("{rsalt}-op")).width(110.0).selected_text(crate::i18n::tr(&op_label)).show_ui(ui, |ui| { for (id, l) in ops_for(kind) { if ui.selectable_label(op == id, crate::i18n::tr(l)).clicked() && op != id { let was_special = matches!(op.as_str(), "between" | "inLast" | "notInLast" | "isEmpty" | "isNotEmpty"); diff --git a/crates/ui-egui/src/panels/settings.rs b/crates/ui-egui/src/panels/settings.rs index a0717ccfd..682e2acb2 100644 --- a/crates/ui-egui/src/panels/settings.rs +++ b/crates/ui-egui/src/panels/settings.rs @@ -1,12 +1,14 @@ -//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Faces, AI Denoise. +//! The Settings dialog (⌘,): General, Import, Performance, Display, Interface, Slideshow, Faces, +//! AI Denoise. //! //! Changes apply immediately (no OK/Cancel). Where they are stored: //! - **app settings** ([`crate::state::AppSettings`]: startup view, delete confirmation, GPU, //! preview size, filmstrip/grid badges, last library, monitor profile) and Auto Advance live in //! the UI state, //! saved by the host in its config folder (`ui.json`); -//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size) go through the -//! `library.preferences` / `library.xmpPreferences` commands into the library's `prefs.json`, +//! - **library settings** (import defaults, XMP sidecars, thumbnail cache size, slideshow +//! playback) go through the `library.preferences` / `library.xmpPreferences` / +//! `slideshow.playback` commands into the library's `prefs.json`, //! so they travel with the library. use egui::RichText; @@ -24,6 +26,7 @@ pub const TABS: &[(&str, &str)] = &[ ("performance", "Performance"), ("display", "Display"), ("interface", "Interface"), + ("slideshow", "Slideshow"), ("faces", "Faces"), ("denoise", "AI Denoise"), ]; @@ -52,6 +55,7 @@ pub fn body(app: &mut LightcraftApp, ui: &mut egui::Ui, tab: &mut String) { "performance" => performance_tab(app, ui, &t), "display" => display_tab(app, ui, &t), "interface" => interface_tab(app, ui, &t), + "slideshow" => slideshow_tab(app, ui, &t), "faces" => super::faces::settings_tab(app, ui, &t), "denoise" => super::denoise::settings_tab(app, ui, &t), _ => general_tab(app, ui, &t), @@ -63,7 +67,7 @@ pub(super) fn heading(ui: &mut egui::Ui, t: &Tokens, text: &str) { ui.label(RichText::new(crate::i18n::tr(text)).font(t.semibold(12.5)).color(t.text)); } -fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { +pub(super) fn row<R>(ui: &mut egui::Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut egui::Ui) -> R) -> R { ui.horizontal(|ui| { ui.allocate_ui_with_layout(egui::vec2(LABEL_W, 24.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { ui.set_min_width(LABEL_W); @@ -103,7 +107,10 @@ pub(super) fn choices<V: PartialEq + Copy>(ui: &mut egui::Ui, id: &str, options: fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Language"), |ui| { let languages: Vec<_> = crate::i18n::Locale::ALL.iter().map(|language| (*language, language.name())).collect(); - choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + // Thirteen language names do not fit one line in a narrow window: wrap them. + ui.horizontal_wrapped(|ui| { + choices(ui, "settingsLanguage", &languages, &mut app.ui.language); + }); crate::i18n::set_language(app.ui.language); }); heading(ui, t, crate::i18n::tr("Library")); @@ -142,6 +149,7 @@ fn general_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { heading(ui, t, crate::i18n::tr("Culling")); check(ui, "settings.autoAdvance", &mut app.ui.auto_advance, "Auto Advance: move to the next photo after rating or flagging"); check(ui, "settings.confirmDelete", &mut app.ui.settings.confirm_delete, "Confirm before moving photos to Recently Deleted"); + check(ui, "settings.askBeforeMove", &mut app.ui.settings.ask_before_move, "Confirm before moving photos to another folder on disk"); heading(ui, t, crate::i18n::tr("External Editing")); row(ui, t, crate::i18n::tr("PhotoCraft"), |ui| { crate::text_field::TextField::singleline("field:photocraftPath", &mut app.ui.settings.photocraft_path) @@ -206,7 +214,7 @@ fn preset_combo(app: &LightcraftApp, ui: &mut egui::Ui, id: &str, current: Optio }; let text = current.map(name).unwrap_or_else(|| crate::i18n::tr(none_label).to_string()); let mut out = None; - let r = egui::ComboBox::from_id_salt(id).width(240.0).selected_text(text).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(id).width(240.0).selected_text(text).show_ui(ui, |ui| { if ui.selectable_label(current.is_none(), crate::i18n::tr(none_label)).clicked() { out = Some(None); } @@ -276,7 +284,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { .unwrap_or_else(|| pid.to_string()), }; let id = format!("settingsCamera-{i}"); - let r = egui::ComboBox::from_id_salt(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { + let r = crate::widgets::combo_box(&id).width(240.0).selected_text(label).show_ui(ui, |ui| { if ui.selectable_label(current == Some(RAW_DEFAULT), crate::i18n::tr("Same as raw default")).clicked() { pick = Some(json!({"camera": cam, "remove": true})); } @@ -325,7 +333,7 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { row(ui, t, crate::i18n::tr("Metadata preset"), |ui| { let cur = d.metadata_preset.clone(); let mut pick = None; - let r = egui::ComboBox::from_id_salt("settingsMetaPreset") + let r = crate::widgets::combo_box("settingsMetaPreset") .width(240.0) .selected_text(cur.clone().unwrap_or_else(|| crate::i18n::tr("None").into())) .show_ui(ui, |ui| { @@ -358,6 +366,11 @@ fn import_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } }); if !cfg!(target_arch = "wasm32") { + heading(ui, t, crate::i18n::tr("Cameras and Cards")); + let mut on_card = d.show_on_card; + if check(ui, "settings.importOnCard", &mut on_card, "Show the import review when a camera or card is connected") { + let _ = app.run("library.preferences", json!({"import": {"showOnCard": on_card}})); + } heading(ui, t, crate::i18n::tr("Auto Import")); hint(ui, t, crate::i18n::tr("Photos that arrive in this folder (tethering, a scanner, a sync app) are added as soon as they're complete.")); row(ui, t, crate::i18n::tr("Watched folder"), |ui| { @@ -412,7 +425,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { } else { format!("GPU: {}", gpu::adapter_name().unwrap_or_else(|| "starting…".into())) }; - hint(ui, t, &status); + // One line, the whole text on hover: the reason can be a long driver message (no adapter + // among the backends …), and the GPU state is process-wide, so it may change between frames; + // a hint that wrapped would move every control below it under the pointer. + ui.add(egui::Label::new(RichText::new(crate::i18n::tr(&status)).size(11.0).color(t.text_dim)).truncate()).on_hover_text(&status); row(ui, t, crate::i18n::tr("Preview size"), |ui| { let opts: Vec<(u32, String)> = PREVIEW_LIMITS.iter().map(|e| (*e, if *e == 0 { crate::i18n::tr("Automatic").to_string() } else { format!("{e} px") })).collect(); @@ -442,7 +458,10 @@ fn performance_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); let used = app.session.media.rendered.disk().map(|d| d.size()); row(ui, t, crate::i18n::tr("In use"), |ui| { - ui.label(RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| "memory only".into())).color(t.text)); + ui.label( + RichText::new(used.map(|b| format!("{:.1} MB", b as f64 / 1048576.0)).unwrap_or_else(|| crate::i18n::tr("memory only").to_string())) + .color(t.text), + ); let r = ui.button(crate::i18n::tr("Clear Cache")); register(ui.ctx(), "button:settingsClearCache", r.rect); if r.clicked() { @@ -652,6 +671,16 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { ); }); check(ui, "settings.showFilenames", &mut app.ui.show_filenames, "Square Grid: show file names and formats"); + heading(ui, t, crate::i18n::tr("Keywords")); + row(ui, t, crate::i18n::tr("Keywords of the selection"), |ui| { + use crate::state::KeywordChips as K; + choices(ui, "settingsKeywordChips", &[(K::Paths, "Whole names"), (K::Grouped, "Grouped by parent")], &mut app.ui.settings.keyword_chips); + }); + hint( + ui, + t, + crate::i18n::tr("Grouped: keywords inside the same keyword share a line naming it, and long lists and the suggestions are shortened."), + ); heading(ui, t, crate::i18n::tr("Detail")); check(ui, "settings.navigator", &mut app.ui.navigator, "Show the Navigator while zoomed in"); row(ui, t, crate::i18n::tr("Info overlay"), |ui| { @@ -660,6 +689,148 @@ fn interface_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { }); } +// ----------------------------------------------------------------------------------- Slideshow + +/// A playback duration slider (`slider:{id}`). While it is dragged the value changes in memory +/// (the slideshow reads it); letting go saves it through `slideshow.playback`. +fn duration_slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, range: std::ops::RangeInclusive<f64>) { + let mut v = value; + let r = ui.add(egui::Slider::new(&mut v, range).logarithmic(key == "slideDuration").suffix(" s").max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + if r.changed() + && let Ok(next) = app.session.slideshow.merged(&json!({ key: v })) + { + app.session.slideshow = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.playback", json!({ key: v })); + } +} + +/// Width of the Slideshow tab's settings column (the slide preview is beside it). +const SLIDESHOW_COLUMN_W: f32 = 480.0; + +fn slideshow_tab(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + // the settings scroll on the left, the composed slide stays in view on the right + let top_down = egui::Layout::top_down(egui::Align::Min); + // as tall as the window allows (the dialog's title, tabs and Close button take ~260 pt) + let tall = (ui.ctx().content_rect().height() - 260.0).clamp(300.0, 760.0); + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(egui::vec2(SLIDESHOW_COLUMN_W, tall), top_down, |ui| { + ui.set_width(SLIDESHOW_COLUMN_W); + egui::ScrollArea::vertical().id_salt("settingsSlideshowScroll").max_height(tall).auto_shrink([false, true]).show(ui, |ui| { + super::slide_templates::section(app, ui, t); + ui.add_space(6.0); + playback_section(app, ui, t); + ui.add_space(6.0); + super::slide_layout::sections(app, ui, t); + ui.add_space(6.0); + super::slide_overlays::sections(app, ui, t); + ui.add_space(6.0); + super::slide_titles::sections(app, ui, t); + ui.add_space(6.0); + export_section(app, ui, t); + }); + }); + ui.add_space(12.0); + ui.allocate_ui_with_layout(egui::vec2(super::slide_layout::PREVIEW_W, tall), top_down, |ui| { + ui.set_width(super::slide_layout::PREVIEW_W); + super::slide_layout::preview(app, ui, t) + }); + }); +} + +/// Export: PDF, JPEG sequence or video of the slideshow (View ▸ Export Slideshow…). +fn export_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, crate::i18n::tr("Export")); + let running = app.session.slideshow_export.as_ref().is_some_and(|r| r.running()); + ui.horizontal(|ui| { + for (id, label, format) in [ + ("slideshowExportPdf", "Export PDF…", "pdf"), + ("slideshowExportJpeg", "Export JPEG…", "jpeg"), + ("slideshowExportVideo", "Export Video…", "video"), + ] { + let r = ui.add_enabled(!running, egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.clicked() { + let _ = app.run("dialog.slideshowExport", json!({"format": format})); + } + } + }); + hint(ui, t, crate::i18n::tr("The photos in view as slides, with the settings above. Progress shows in the activity stack.")); +} + +fn playback_section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + use lightcraft_engine::slideshow::{FADE_MAX, SLIDE_MAX, SLIDE_MIN}; + heading(ui, t, crate::i18n::tr("Playback")); + let s = app.session.slideshow.clone(); + row(ui, t, crate::i18n::tr("Slide duration"), |ui| { + duration_slider(app, ui, "slideshowSlide", "slideDuration", s.slide_duration, SLIDE_MIN..=SLIDE_MAX) + }); + row(ui, t, crate::i18n::tr("Fade duration"), |ui| { + duration_slider(app, ui, "slideshowFade", "fadeDuration", s.fade_duration, 0.0..=FADE_MAX.min(s.slide_duration)) + }); + hint(ui, t, crate::i18n::tr("A slide stays up for its duration, its fade in included. A fade of 0 cuts.")); + row(ui, t, crate::i18n::tr("Fade"), |ui| { + #[derive(Clone, Copy, PartialEq)] + enum Fade { + Cross, + Black, + White, + Color, + } + let mut f = match s.fade_color { + None => Fade::Cross, + Some([0, 0, 0]) => Fade::Black, + Some([255, 255, 255]) => Fade::White, + Some(_) => Fade::Color, + }; + let opts = [(Fade::Cross, "Crossfade"), (Fade::Black, "Through black"), (Fade::White, "Through white"), (Fade::Color, "Through a colour")]; + if choices(ui, "settingsSlideshowFade", &opts, &mut f) { + let c = match f { + Fade::Cross => Value::Null, + Fade::Black => json!("#000000"), + Fade::White => json!("#ffffff"), + Fade::Color => json!([64, 64, 64]), + }; + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + if f == Fade::Color + && let Some(mut c) = s.fade_color + { + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), "button:slideshowFadeColor", r.rect); + if r.changed() { + let _ = app.run("slideshow.playback", json!({"fadeColor": c})); + } + } + }); + let mut v = s.random_order; + if check(ui, "settings.slideshowRandom", &mut v, "Random order") { + let _ = app.run("slideshow.playback", json!({"randomOrder": v})); + } + let mut v = s.repeat; + if check(ui, "settings.slideshowRepeat", &mut v, "Repeat (off: the slideshow ends after the last photo)") { + let _ = app.run("slideshow.playback", json!({"repeat": v})); + } + let mut v = s.prepare_previews; + if check(ui, "settings.slideshowPrepare", &mut v, "Prepare previews in advance") { + let _ = app.run("slideshow.playback", json!({"preparePreviews": v})); + } + hint( + ui, + t, + crate::i18n::tr( + "Renders the next slides while the current one is up, so fades never wait. Off: each slide renders when it is due (less memory and work).", + ), + ); + hint( + ui, + t, + crate::i18n::tr("Slideshow in the View menu plays the photos in view from the selected one. These settings are saved with the library."), + ); +} + /// Appearance Mode (`button:settingsAppearance-{0,1,2}`: Auto, Dark, Light) above the light and /// dark theme cards (`radio:theme-{id}`), each with a preview of the chosen theme. The card in /// use is outlined. Applied on the next frame ([`LightcraftApp::sync_theme`]). @@ -953,6 +1124,10 @@ fn switch_library(app: &mut LightcraftApp, path: &str) -> Result<Value, String> app.session.close_library().map_err(|e| e.to_string())?; let opened = app.session.open_library(&dir, false).map(|_| ()).map_err(|e| format!("{path}: {e}")); app.lightroom_last = None; + app.lightroom_result = None; + if matches!(app.ui.dialog.as_ref(), Some(crate::state::Dialog::LightroomResult { .. })) { + app.ui.dialog = None; + } app.renderer.forget_all(); app.ui.compare = None; if let Err(err) = opened { diff --git a/crates/ui-egui/src/panels/slide_layout.rs b/crates/ui-egui/src/panels/slide_layout.rs new file mode 100644 index 000000000..62f7626c3 --- /dev/null +++ b/crates/ui-egui/src/panels/slide_layout.rs @@ -0,0 +1,296 @@ +//! Settings ▸ Slideshow: the slide's Layout, Options and Backdrop sections (Lightroom Classic's +//! Slideshow panels of the same names) and a small live preview of the composed slide (with the +//! overlays of [`super::slide_overlays`]). +//! +//! Every control goes through `slideshow.layout` (validated there, saved with the library). +//! Sliders change the layout in memory while dragged (the preview follows) and save it when let +//! go. The preview is the active photo's slide rendered off the UI thread ([`Slot::SlidePreview`]) +//! by the same composer playback uses; it is requested once the layout has been still for +//! [`DEBOUNCE`] seconds, so a drag doesn't queue a render per frame. + +use egui::{Align2, Color32, Rect, Sense, Stroke, pos2, vec2}; +use lightcraft_engine::slide::{Aspect, SlideSettings}; +use lightcraft_engine::slide_template::SlideLook; +use lightcraft_slideshow::{MARGIN_MAX, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STROKE_MAX}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::render::Slot; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Seconds the layout must stay unchanged before the preview is rendered again. +pub const DEBOUNCE: f64 = 0.15; +/// The preview's width in points. +pub const PREVIEW_W: f32 = 300.0; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.layout", p); +} + +/// A layout slider (`slider:{id}`): `key`'s value, 0–`max` (percent) or −180–180° (degrees). +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: f64, max: f64, unit: Unit) { + let (mut v, range, suffix) = match unit { + Unit::Percent => (value * 100.0, 0.0..=max * 100.0, " %"), + Unit::Degrees => (value, -180.0..=180.0, "°"), + }; + let r = ui.add(egui::Slider::new(&mut v, range).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let value = match unit { + Unit::Percent => v / 100.0, + Unit::Degrees => v, + }; + if r.changed() + && let Ok(next) = app.session.slide_layout.merged(&json!({ key: value })) + { + app.session.slide_layout = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, json!({ key: value })); + } +} + +/// A colour button (`button:{id}`) for `key`. +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: [u8; 3]) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, json!({ key: c })); + } +} + +/// A checkbox (`check:settings.{id}`) for `key`; true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, key: &str, value: bool, label: &str) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, json!({ key: v })); + } + v +} + +/// The Layout, Options and Backdrop sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s = app.session.slide_layout.clone(); + + heading(ui, t, "Layout"); + let linked = toggle(app, ui, "slideshowLinkMargins", "linkMargins", s.link_margins, "Link all margins"); + if linked { + row(ui, t, "Margins", |ui| slider(app, ui, "slideshowMargins", "marginLeft", s.margin_left, MARGIN_MAX, Unit::Percent)); + } else { + for (id, key, label, v) in [ + ("slideshowMarginLeft", "marginLeft", "Left", s.margin_left), + ("slideshowMarginRight", "marginRight", "Right", s.margin_right), + ("slideshowMarginTop", "marginTop", "Top", s.margin_top), + ("slideshowMarginBottom", "marginBottom", "Bottom", s.margin_bottom), + ] { + row(ui, t, label, |ui| slider(app, ui, id, key, v, MARGIN_MAX, Unit::Percent)); + } + } + hint(ui, t, "Margins are a share of the slide's shorter side, so slides look the same on any screen."); + toggle(app, ui, "slideshowGuides", "showGuides", s.show_guides, "Show guides"); + row(ui, t, "Aspect preview", |ui| { + let mut a = s.aspect; + let opts = [(Aspect::Screen, "Screen"), (Aspect::Wide, "16:9"), (Aspect::Standard, "4:3")]; + if choices(ui, "settingsSlideshowAspect", &opts, &mut a) { + set(app, json!({"aspect": a.id()})); + } + }); + + ui.add_space(6.0); + heading(ui, t, "Options"); + toggle(app, ui, "slideshowZoomToFill", "zoomToFill", s.zoom_to_fill, "Zoom to fill frame"); + if toggle(app, ui, "slideshowStroke", "strokeBorder", s.stroke_border, "Stroke border") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStrokeColor", "strokeColor", s.stroke_color)); + row(ui, t, "Width", |ui| slider(app, ui, "slideshowStrokeWidth", "strokeWidth", s.stroke_width, STROKE_MAX, Unit::Percent)); + } + if toggle(app, ui, "slideshowShadow", "castShadow", s.cast_shadow, "Cast shadow") { + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowShadowOpacity", "shadowOpacity", s.shadow_opacity, 1.0, Unit::Percent)); + row(ui, t, "Offset", |ui| slider(app, ui, "slideshowShadowOffset", "shadowOffset", s.shadow_offset, SHADOW_OFFSET_MAX, Unit::Percent)); + row(ui, t, "Radius", |ui| slider(app, ui, "slideshowShadowRadius", "shadowRadius", s.shadow_radius, SHADOW_RADIUS_MAX, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowShadowAngle", "shadowAngle", s.shadow_angle, 180.0, Unit::Degrees)); + } + + ui.add_space(6.0); + heading(ui, t, "Backdrop"); + row(ui, t, "Background colour", |ui| colour(app, ui, "slideshowBackdropColor", "backdropColor", s.backdrop_color)); + if toggle(app, ui, "slideshowWash", "colorWash", s.color_wash, "Colour wash") { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowWashColor", "washColor", s.wash_color)); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowWashOpacity", "washOpacity", s.wash_opacity, 1.0, Unit::Percent)); + row(ui, t, "Angle", |ui| slider(app, ui, "slideshowWashAngle", "washAngle", s.wash_angle, 180.0, Unit::Degrees)); + } + let active = app.session.active(); + let mut on = s.background_image.is_some(); + let r = ui.add_enabled(on || active.is_some(), egui::Checkbox::new(&mut on, crate::i18n::tr("Background image"))); + register(ui.ctx(), "check:settings.slideshowBackgroundImage", r.rect); + if r.changed() { + set(app, json!({"backgroundImage": if on { active.map(|a| a.0) } else { None }})); + } + if let Some(bid) = s.background_image { + let name = app.session.catalog.photo(lightcraft_catalog::PhotoId(bid)).map(|p| p.file_name.clone()); + row(ui, t, "Photo", |ui| { + let text = name.unwrap_or_else(|| crate::i18n::tr("(not in the library)").to_string()); + ui.label(egui::RichText::new(text).color(t.text)); + if let Some(a) = active.filter(|a| a.0 != bid) + && crate::widgets::text_button(ui, "slideshowUseSelected", "Use selected photo", false).clicked() + { + set(app, json!({"backgroundImage": a.0})); + } + }); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowBackgroundOpacity", "backgroundOpacity", s.background_opacity, 1.0, Unit::Percent)); + } else { + hint(ui, t, "Select a photo, then turn this on to fill the slide behind every photo with it."); + } + ui.add_space(4.0); + if crate::widgets::text_button(ui, "slideshowResetLayout", "Reset layout", false).clicked() { + reset(app); + } +} + +/// The aspect of the screen a slideshow plays on (the monitor, else the window). +fn screen_aspect(ctx: &egui::Context) -> f64 { + let monitor = ctx.input(|i| i.viewport().monitor_size).filter(|m| m.x > 0.0 && m.y > 0.0); + let r = monitor.unwrap_or_else(|| ctx.content_rect().size()); + if r.y > 0.0 { (r.x / r.y) as f64 } else { 1.6 } +} + +/// The small composed slide of the active photo, with the margin guides when they are on. +pub fn preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + heading(ui, t, "Preview"); + // the template the pointer is over in the Template Browser, else the settings + let template = super::slide_templates::hovered(ui.ctx()).and_then(|id| app.session.slide_template(&id).ok()); + let look = template.as_ref().map_or_else(|| SlideLook::of(&app.session), |t| t.look.clone()); + let s = look.layout.clone(); + let overlays = look.overlays.clone(); + let ratio = s.aspect.ratio(screen_aspect(ui.ctx())) as f32; + let h = (PREVIEW_W / ratio).clamp(80.0, 320.0); + let (rect, _) = ui.allocate_exact_size(vec2(PREVIEW_W, h), Sense::hover()); + register(ui.ctx(), "slideshow:preview", rect); + let p = ui.painter_at(rect.expand(1.0)); + p.rect_stroke(rect, 0.0, Stroke::new(1.0, t.text_dim.gamma_multiply(0.5)), egui::StrokeKind::Outside); + super::slide_titles::preview_choice(ui); + if let Some(tpl) = &template { + let name = if tpl.builtin { crate::i18n::tr(&tpl.name).to_string() } else { tpl.name.clone() }; + ui.label(egui::RichText::new(crate::i18n::tr_format!("Template preview: {name}", name = name)).size(11.0).color(t.accent)); + if !tpl.description.is_empty() { + hint(ui, t, &tpl.description); + } + } + if let Some(title) = super::slide_titles::preview_shows(ui.ctx()) { + title_preview(app, ui, t, rect, title, &look); + return; + } + let Some(id) = app.session.active() else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Select a photo to preview its slide"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = (((PREVIEW_W * ppp).round() as usize).max(16), ((h * ppp).round() as usize).max(16)); + // the overlays and what they show of the photo (its rating, its metadata) change it too + let shown = app.session.catalog.photo(id).filter(|_| overlays.any()).map_or(0, |p| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + p.rating.hash(&mut h); + serde_json::to_string(&p.meta).unwrap_or_default().hash(&mut h); + h.finish() + }); + let overlay_key = if overlays.any() { overlays.pixel_key() ^ shown.rotate_left(5) } else { 0 }; + let key = s.pixel_key() ^ id.0.rotate_left(17) ^ ((pw as u64) << 32 | ph as u64) ^ overlay_key.rotate_left(29); + let now = ui.input(|i| i.time); + let mem = egui::Id::new("settingsSlideshowPreviewKey"); + let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); + let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id); + if last != key { + ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); + } + let still = if last == key { now - since } else { 0.0 }; + // the first preview (or another photo's) at once, a changing layout once it has settled + if current.is_none() || still >= DEBOUNCE { + let side = super::detail::texture_side(ui.ctx()); + let edge = app.ui.settings.loupe_edge(pw.max(ph) as f32, usize::MAX, side); + if let Some(job) = app.session.slide_job_look(&s, &overlays, id, pw, ph, Some(edge), false) { + app.renderer.request(Slot::SlidePreview, job, 90); + } + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE))); + } + let Some(tex) = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == id) else { + p.rect_filled(rect, 0.0, Color32::from_rgb(s.backdrop_color[0], s.backdrop_color[1], s.backdrop_color[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); + return; + }; + let drawn = super::detail::fit_texture_rect(rect, tex.size); + p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + if s.show_guides { + // the margins, as lines across the slide (where the image cell's edges are) + let aspect = app.session.slide_photo_aspect(id).unwrap_or(1.5); + let l = lightcraft_slideshow::layout(&s, aspect, tex.size[0], tex.size[1]); + let (kx, ky) = (drawn.width() / tex.size[0].max(1) as f32, drawn.height() / tex.size[1].max(1) as f32); + let stroke = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 170, 255, 200)); + let x = |v: f64| drawn.left() + v as f32 * kx; + let y = |v: f64| drawn.top() + v as f32 * ky; + for gx in [x(l.cell.x0), x(l.cell.x1)] { + p.line_segment([pos2(gx, drawn.top()), pos2(gx, drawn.bottom())], stroke); + } + for gy in [y(l.cell.y0), y(l.cell.y1)] { + p.line_segment([pos2(drawn.left(), gy), pos2(drawn.right(), gy)], stroke); + } + } + hint(ui, t, "The selected photo as a slide. Playback fills the screen; the aspect preview only changes this preview."); +} + +/// The preview showing title screen `title` (composed off the UI thread like the photo's slide, +/// once its settings have been still for [`DEBOUNCE`] seconds). +fn title_preview(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, rect: Rect, title: lightcraft_engine::slideshow::Title, look: &SlideLook) { + let ppp = ui.ctx().pixels_per_point(); + let (pw, ph) = (((rect.width() * ppp).round() as usize).max(16), ((rect.height() * ppp).round() as usize).max(16)); + let compose = std::sync::Arc::new(lightcraft_engine::slide_title::TitleCompose::new(&look.titles, &look.overlays, title, pw, ph)); + let key = compose.key(); + let now = ui.input(|i| i.time); + let mem = egui::Id::new("settingsSlideshowTitlePreviewKey"); + let (last, since) = ui.ctx().data(|d| d.get_temp::<(u64, f64)>(mem)).unwrap_or((0, f64::NEG_INFINITY)); + if last != key { + ui.ctx().data_mut(|d| d.insert_temp(mem, (key, now))); + } + let still = if last == key { now - since } else { 0.0 }; + // a title render in the slot (a photo's has its id): shown until the new one lands + let current = app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)); + if current.is_none() || still >= DEBOUNCE { + app.renderer.request_title(Slot::SlidePreview, compose, 90); + } else { + ui.ctx().request_repaint_after(std::time::Duration::from_secs_f64((DEBOUNCE - still).clamp(0.01, DEBOUNCE))); + } + let p = ui.painter_at(rect.expand(1.0)); + match app.renderer.textures.get(&Slot::SlidePreview).filter(|tex| tex.photo == lightcraft_catalog::PhotoId(0)) { + Some(tex) => { + let drawn = super::detail::fit_texture_rect(rect, tex.size); + p.image(tex.tex.id(), drawn, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + } + None => { + let c = look.titles.screen(title).color; + p.rect_filled(rect, 0.0, Color32::from_rgb(c[0], c[1], c[2])); + p.text(rect.center(), Align2::CENTER_CENTER, crate::i18n::tr("Rendering…"), t.font(11.5), Color32::from_gray(200)); + } + } + let hint_text = match (title, look.titles.screen(title).enabled) { + (_, false) => "This screen is off: turn it on under Titles to play it.", + (lightcraft_engine::slideshow::Title::Intro, true) => "The intro screen, before the first photo.", + (lightcraft_engine::slideshow::Title::Ending, true) => "The ending screen, after the last photo.", + }; + hint(ui, t, hint_text); +} + +/// The default layout (for the Reset button). +pub fn reset(app: &mut LightcraftApp) { + let d = SlideSettings::default().to_json(); + set(app, d); +} diff --git a/crates/ui-egui/src/panels/slide_overlays.rs b/crates/ui-egui/src/panels/slide_overlays.rs new file mode 100644 index 000000000..3824eeb0d --- /dev/null +++ b/crates/ui-egui/src/panels/slide_overlays.rs @@ -0,0 +1,265 @@ +//! Settings ▸ Slideshow ▸ Overlays (Lightroom Classic's Slideshow Overlays panel): the identity +//! plate (text or an image file), rating stars, text overlays with tokens and the watermark. +//! +//! Every control goes through `slideshow.overlays` (validated there, saved with the library), like +//! the Layout sections beside it ([`super::slide_layout`]); the live preview next to them shows the +//! overlays on the active photo's slide. Sliders change the settings in memory while dragged and +//! save them when let go; text fields save when the edit ends (Return or leaving the field). + +use egui::RichText; +use lightcraft_engine::export::Anchor; +use lightcraft_engine::slide_overlay::{ + ANCHORS, OFFSET_MAX, OverlaySettings, PLATE_SIZE_MAX, Placement, RelativeTo, SHADOW_OFFSET_MAX, SHADOW_RADIUS_MAX, STAR_SIZE_MAX, TEXT_SIZE_MAX, +}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +#[derive(Clone, Copy)] +enum Unit { + /// A fraction shown as a percentage (of the slide's shorter side, or an opacity). + Percent, + Degrees, +} + +fn set(app: &mut LightcraftApp, p: Value) { + let _ = app.run("slideshow.overlays", p); +} + +/// A slider (`slider:{id}`) over `lo..=hi`; `wrap(value)` makes the `slideshow.overlays` request. +fn slider(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: f64, lo: f64, hi: f64, unit: Unit, wrap: &dyn Fn(f64) -> Value) { + let (k, suffix) = match unit { + Unit::Percent => (100.0, " %"), + Unit::Degrees => (1.0, "°"), + }; + let mut v = value * k; + let r = ui.add(egui::Slider::new(&mut v, lo * k..=hi * k).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let p = wrap(v / k); + if r.changed() + && let Ok(next) = app.session.slide_overlays.merged(&p) + { + app.session.slide_overlays = next; + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + set(app, p); + } +} + +/// A colour button (`button:{id}`). +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: [u8; 3], wrap: &dyn Fn([u8; 3]) -> Value) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, wrap(c)); + } +} + +/// A checkbox (`check:settings.{id}`); true when it is on. +fn toggle(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: bool, label: &str, wrap: &dyn Fn(bool) -> Value) -> bool { + let mut v = value; + if check(ui, &format!("settings.{id}"), &mut v, label) { + set(app, wrap(v)); + } + v +} + +/// A one-line text field (`field:{id}`), saved when the edit ends. +fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, value: &str, hint: &str, wrap: &dyn Fn(&str) -> Value) { + let mem = egui::Id::new(("slideshow-overlay-field", id)); + let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); + let widget = format!("field:{id}"); + let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr(hint)).width(ui.available_width().clamp(80.0, 260.0)).show(ui); + if r.editing { + ui.data_mut(|d| d.insert_temp(mem, s.clone())); + } else { + ui.data_mut(|d| d.remove::<String>(mem)); + } + if r.committed() && s != value { + set(app, wrap(&s)); + } +} + +/// The nine anchors as a 3 × 3 grid of buttons (`button:{id}-{0..8}`); true when one is picked. +fn anchor_grid(ui: &mut egui::Ui, id: &str, value: &mut Anchor) -> bool { + const GLYPHS: [&str; 9] = ["↖", "↑", "↗", "←", "•", "→", "↙", "↓", "↘"]; + let mut changed = false; + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(2.0, 2.0); + for r in 0..3 { + ui.horizontal(|ui| { + for c in 0..3 { + let i = r * 3 + c; + let (Some(a), Some(g)) = (ANCHORS.get(i), GLYPHS.get(i)) else { continue }; + if crate::widgets::text_button(ui, &format!("{id}-{i}"), g, *value == *a).clicked() && *value != *a { + *value = *a; + changed = true; + } + } + }); + } + }); + changed +} + +/// Anchor, offsets and what the overlay is anchored to; `wrap` turns placement keys into the request. +fn placement(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, id: &str, p: &Placement, photo_too: bool, wrap: &dyn Fn(Value) -> Value) { + row(ui, t, "Position", |ui| { + let mut a = p.anchor; + if anchor_grid(ui, &format!("{id}Anchor"), &mut a) { + set(app, wrap(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + if photo_too { + row(ui, t, "Anchored to", |ui| { + let mut r = p.relative_to; + if choices(ui, &format!("{id}RelativeTo"), &[(RelativeTo::Slide, "Slide"), (RelativeTo::Photo, "Photo")], &mut r) { + let v = if r == RelativeTo::Photo { "photo" } else { "slide" }; + set(app, wrap(json!({"relativeTo": v}))); + } + }); + } + row(ui, t, "Offset X", |ui| { + slider(app, ui, &format!("{id}OffsetX"), p.offset_x, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetX": v}))) + }); + row(ui, t, "Offset Y", |ui| { + slider(app, ui, &format!("{id}OffsetY"), p.offset_y, -OFFSET_MAX / 2.0, OFFSET_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"offsetY": v}))) + }); +} + +/// The request changing text overlay `i` of `n` by `change` (the others kept). +fn text_change(i: usize, n: usize, change: Value) -> Value { + let list: Vec<Value> = (0..n).map(|k| if k == i { change.clone() } else { json!({}) }).collect(); + json!({ "texts": list }) +} + +/// The Overlays sections. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s: OverlaySettings = app.session.slide_overlays.clone(); + heading(ui, t, "Overlays"); + + // identity plate + let p = s.identity_plate.clone(); + let plate = |v: Value| json!({ "identityPlate": v }); + if toggle(app, ui, "slideshowPlate", p.enabled, "Identity plate", &|v| plate(json!({"enabled": v}))) { + let mem = egui::Id::new("slideshow-plate-graphic"); + let mut graphic = !p.image.is_empty() || ui.data(|d| d.get_temp::<bool>(mem)).unwrap_or(false); + let before = graphic; + row(ui, t, "Style", |ui| choices(ui, "slideshowPlateStyle", &[(false, "Text"), (true, "Image")], &mut graphic)); + if graphic != before { + ui.data_mut(|d| d.insert_temp(mem, graphic)); + if !graphic && !p.image.is_empty() { + set(app, plate(json!({"image": ""}))); + } + } + if graphic { + row(ui, t, "Image", |ui| { + if app.services.pick_files.is_some() + && crate::widgets::text_button(ui, "slideshowPlateChoose", "Choose…", false).clicked() + && let Some(f) = app.services.pick_files.as_mut().and_then(|pick| pick().into_iter().next()) + { + set(app, plate(json!({"image": f}))); + } + text(app, ui, "slideshowPlateImage", &p.image, "logo.png", &|v| plate(json!({"image": v}))); + }); + } else { + row(ui, t, "Text", |ui| text(app, ui, "slideshowPlateText", &p.text, "Your name or studio", &|v| plate(json!({"text": v})))); + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowPlateColor", p.color, &|c| plate(json!({"color": c})))); + } + row(ui, t, "Size", |ui| slider(app, ui, "slideshowPlateSize", p.size, 0.01, PLATE_SIZE_MAX, Unit::Percent, &|v| plate(json!({"size": v})))); + row(ui, t, "Opacity", |ui| slider(app, ui, "slideshowPlateOpacity", p.opacity, 0.0, 1.0, Unit::Percent, &|v| plate(json!({"opacity": v})))); + placement(app, ui, t, "slideshowPlate", &p.place, true, &plate); + toggle(app, ui, "slideshowPlateBehind", p.behind, "Render behind image", &|v| plate(json!({"behind": v}))); + } + + // rating stars + ui.add_space(4.0); + let r = s.rating_stars.clone(); + let stars = |v: Value| json!({ "ratingStars": v }); + if toggle(app, ui, "slideshowStars", r.enabled, "Rating stars", &|v| stars(json!({"enabled": v}))) { + row(ui, t, "Colour", |ui| colour(app, ui, "slideshowStarsColor", r.color, &|c| stars(json!({"color": c})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowStarsSize", r.size, 0.01, STAR_SIZE_MAX, Unit::Percent, &|v| stars(json!({"size": v})))); + placement(app, ui, t, "slideshowStars", &r.place, true, &stars); + } + + // text overlays + ui.add_space(6.0); + ui.label(RichText::new(crate::i18n::tr("Text overlays")).font(t.semibold(11.5)).color(t.text)); + let n = s.texts.len(); + for (i, o) in s.texts.iter().enumerate() { + let id = format!("slideshowText{i}"); + let wrap = |v: Value| text_change(i, n, v); + ui.add_space(2.0); + row(ui, t, &format!("{} {}", crate::i18n::tr("Text"), i + 1), |ui| { + text(app, ui, &id, &o.text, "{title} · {exposure}", &|v| wrap(json!({"text": v}))); + if crate::widgets::text_button(ui, &format!("{id}Remove"), "Remove", false).clicked() { + set(app, json!({"removeText": i})); + } + }); + if app.session.slide_overlays.texts.len() != n { + return; // removed: the indices moved; drawn again next frame + } + row(ui, t, "Size", |ui| { + slider(app, ui, &format!("{id}Size"), o.size, 0.01, TEXT_SIZE_MAX / 2.0, Unit::Percent, &|v| wrap(json!({"size": v}))) + }); + row(ui, t, "Colour", |ui| colour(app, ui, &format!("{id}Color"), o.color, &|c| wrap(json!({"color": c})))); + row(ui, t, "Opacity", |ui| slider(app, ui, &format!("{id}Opacity"), o.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"opacity": v})))); + placement(app, ui, t, &id, &o.place, true, &wrap); + let sh = o.shadow; + if toggle(app, ui, &format!("{id}Shadow"), sh.on, "Shadow", &|v| wrap(json!({"shadow": v}))) { + row(ui, t, "Opacity", |ui| { + slider(app, ui, &format!("{id}ShadowOpacity"), sh.opacity, 0.0, 1.0, Unit::Percent, &|v| wrap(json!({"shadowOpacity": v}))) + }); + row(ui, t, "Offset", |ui| { + slider(app, ui, &format!("{id}ShadowOffset"), sh.offset, 0.0, SHADOW_OFFSET_MAX, Unit::Percent, &|v| wrap(json!({"shadowOffset": v}))) + }); + row(ui, t, "Radius", |ui| { + slider(app, ui, &format!("{id}ShadowRadius"), sh.radius, 0.0, SHADOW_RADIUS_MAX, Unit::Percent, &|v| wrap(json!({"shadowRadius": v}))) + }); + row(ui, t, "Angle", |ui| { + slider(app, ui, &format!("{id}ShadowAngle"), sh.angle, -180.0, 180.0, Unit::Degrees, &|v| wrap(json!({"shadowAngle": v}))) + }); + } + } + if n < lightcraft_engine::slide_overlay::MAX_TEXTS && crate::widgets::text_button(ui, "slideshowAddText", "Add text overlay", false).clicked() { + set(app, json!({"addText": {}})); + } + hint( + ui, + t, + "Tokens: {file}, {name}, {title}, {caption}, {date}, {exposure}, {iso}, {focal}, {camera}, {lens}, {rating}, {creator}, {copyright}. Other text shows as typed.", + ); + + // watermark + ui.add_space(4.0); + let w = s.watermark.clone(); + let wm = |v: Value| json!({ "watermark": v }); + if toggle(app, ui, "slideshowWatermark", s.show_watermark, "Watermark", &|v| json!({"showWatermark": v})) { + row(ui, t, "Text", |ui| text(app, ui, "slideshowWatermarkText", &w.text, "© Your Name", &|v| wm(json!({"text": v})))); + row(ui, t, "Size", |ui| slider(app, ui, "slideshowWatermarkSize", w.size as f64, 0.01, 0.15, Unit::Percent, &|v| wm(json!({"size": v})))); + row(ui, t, "Opacity", |ui| { + slider(app, ui, "slideshowWatermarkOpacity", w.opacity as f64, 0.05, 1.0, Unit::Percent, &|v| wm(json!({"opacity": v}))) + }); + row(ui, t, "Position", |ui| { + let mut a = w.anchor; + if anchor_grid(ui, "slideshowWatermarkAnchor", &mut a) { + set(app, wm(json!({"anchor": lightcraft_engine::slide_overlay::anchor_id(a)}))); + } + }); + let export_wm = app.session.last_export.as_ref().and_then(|e| e.get("watermark")).filter(|v| v.is_object() || v.is_string()).cloned(); + if let Some(e) = export_wm + && crate::widgets::text_button(ui, "slideshowUseExportWatermark", "Use export watermark", false).clicked() + { + set(app, wm(e)); + } + hint(ui, t, "The export watermark, drawn on the photo and sized relative to it."); + } + for warning in app.session.slide_overlays.warnings() { + ui.label(RichText::new(warning).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slide_templates.rs b/crates/ui-egui/src/panels/slide_templates.rs new file mode 100644 index 000000000..591e3c82a --- /dev/null +++ b/crates/ui-egui/src/panels/slide_templates.rs @@ -0,0 +1,274 @@ +//! Settings ▸ Slideshow ▸ Templates: the Template Browser (Lightroom Classic's, at the top of the +//! Slideshow tab until the Slideshow module view exists). Built-in templates (our own designs) and +//! the user's (files in `<settings>/slideshow-templates`), and "Previous settings" once one has been +//! applied (library preferences have no undo). +//! +//! Clicking a template applies it (`slideshow.templates.apply`); hovering one shows it on the +//! selected photo in the live preview beside it ([`hovered`], read by +//! [`super::slide_layout::preview`]) without changing anything. Right-click a user template to +//! update it with the current settings, rename, export or delete it; a built-in to export it. +//! Every action goes through a `slideshow.templates.*` command. + +use egui::{RichText, Sense, Stroke, vec2}; +use lightcraft_engine::slide_template::{PREVIOUS, SlideLook, Template}; +use serde_json::json; + +use super::settings::{heading, hint}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +/// The user templates list is at most this tall (it scrolls past it). +const USER_LIST_H: f32 = 180.0; + +fn hovered_key() -> egui::Id { + egui::Id::new("settingsSlideshowTemplateHovered") +} + +/// The template the pointer is over in the browser (its id), which the preview shows. +pub fn hovered(ctx: &egui::Context) -> Option<String> { + ctx.data(|d| d.get_temp::<Option<String>>(hovered_key())).flatten() +} + +fn set_hovered(ctx: &egui::Context, id: Option<String>) { + ctx.data_mut(|d| d.insert_temp(hovered_key(), id)); +} + +/// What a row is doing: being renamed (with the text so far), or waiting for a delete to be confirmed. +#[derive(Clone, Debug, PartialEq)] +enum Pending { + Rename(String, String), + Delete(String), +} + +fn pending_key() -> egui::Id { + egui::Id::new("settingsSlideshowTemplatePending") +} + +fn pending(ctx: &egui::Context) -> Option<Pending> { + ctx.data(|d| d.get_temp::<Option<Pending>>(pending_key())).flatten() +} + +fn set_pending(ctx: &egui::Context, p: Option<Pending>) { + ctx.data_mut(|d| d.insert_temp(pending_key(), p)); +} + +/// The name shown for a template: a built-in's in the interface language. +fn shown_name(t: &Template) -> String { + if t.builtin || t.id == PREVIOUS { crate::i18n::tr(&t.name).to_string() } else { t.name.clone() } +} + +/// Is `look` what the settings are now (prepare previews aside: templates don't set it)? +fn is_current(look: &SlideLook, now: &SlideLook) -> bool { + look.layout == now.layout && look.overlays == now.overlays && look.titles == now.titles && { + let mut p = look.playback.clone(); + p.prepare_previews = now.playback.prepare_previews; + p == now.playback + } +} + +/// Run a template command; tell the user how it went. +fn act(app: &mut LightcraftApp, ctx: &egui::Context, cmd: &str, p: serde_json::Value, done: impl FnOnce(&serde_json::Value) -> String) { + match app.run(cmd, p) { + Ok(r) => { + let mut msg = done(&r); + let warnings: Vec<&str> = r["warnings"].as_array().map(|w| w.iter().filter_map(|w| w.as_str()).collect()).unwrap_or_default(); + if !warnings.is_empty() { + msg = format!("{msg} · {}", warnings.join(" · ")); + } + if !msg.is_empty() { + app.toast(ctx, msg); + } + } + Err(e) => app.toast(ctx, e), + } +} + +fn apply(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, name: &str) { + let name = name.to_string(); + act(app, ctx, "slideshow.templates.apply", json!({"id": id}), |_| crate::i18n::tr_format!("Applied {name}", name = name)); +} + +/// One template row (`template:{id}`): click applies, hover previews, right-click for its menu. +fn template_row(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, tpl: &Template, current: bool, hovered: &mut Option<String>) { + let ctx = ui.ctx().clone(); + let name = shown_name(tpl); + if let Some(Pending::Rename(id, mut text)) = pending(&ctx).filter(|p| matches!(p, Pending::Rename(id, _) if *id == tpl.id)) { + let r = TextField::singleline("field:slideshowTemplateRename", &mut text) + .width(ui.available_width().clamp(80.0, 260.0)) + .select_on_focus(true) + .show(ui); + if !r.editing && r.ending.is_none() { + r.response.request_focus(); + } + if r.committed() { + set_pending(&ctx, None); + if text.trim() != tpl.name { + act(app, &ctx, "slideshow.templates.rename", json!({"id": id, "name": text}), |_| String::new()); + } + } else if r.cancelled() { + set_pending(&ctx, None); + } else { + set_pending(&ctx, Some(Pending::Rename(id, text))); + } + return; + } + if pending(&ctx) == Some(Pending::Delete(tpl.id.clone())) { + ui.horizontal(|ui| { + ui.label(RichText::new(crate::i18n::tr_format!("Delete “{name}”?", name = name)).color(t.caution)); + if crate::widgets::text_button(ui, "slideshowTemplateDeleteConfirm", "Delete", false).clicked() { + set_pending(&ctx, None); + act(app, &ctx, "slideshow.templates.delete", json!({"id": tpl.id}), |_| String::new()); + } + if crate::widgets::text_button(ui, "slideshowTemplateDeleteCancel", "Cancel", false).clicked() { + set_pending(&ctx, None); + } + }); + return; + } + let w = ui.available_width(); + let (rect, resp) = ui.allocate_exact_size(vec2(w, 22.0), Sense::click()); + register(&ctx, format!("template:{}", tpl.id), rect); + if resp.hovered() || resp.context_menu_opened() { + *hovered = Some(tpl.id.clone()); + } + let p = ui.painter(); + if resp.hovered() { + p.rect_filled(rect, 3.0, t.hover); + } + if current { + p.rect_stroke(rect.shrink(0.5), 3.0, Stroke::new(1.0, t.accent), egui::StrokeKind::Inside); + } + let colour = if current { t.text } else { t.text_label }; + p.text(rect.left_center() + vec2(8.0, 0.0), egui::Align2::LEFT_CENTER, &name, t.font(12.0), colour); + if resp.clicked() { + apply(app, &ctx, &tpl.id, &name); + } + resp.context_menu(|ui| menu(app, ui, tpl, &name)); +} + +/// A menu entry registered as `button:{id}`. +fn item(ui: &mut egui::Ui, id: &str, label: &str, enabled: bool) -> bool { + let r = ui.add_enabled(enabled, egui::Button::new(crate::i18n::tr(label))); + register(ui.ctx(), format!("button:{id}"), r.rect); + r.clicked() +} + +fn menu(app: &mut LightcraftApp, ui: &mut egui::Ui, tpl: &Template, name: &str) { + let ctx = ui.ctx().clone(); + if item(ui, "slideshowTemplateApply", "Apply", true) { + apply(app, &ctx, &tpl.id, name); + ui.close(); + } + let user = !tpl.builtin && tpl.id != PREVIOUS; + if user { + if item(ui, "slideshowTemplateUpdate", "Update with Current Settings", true) { + let n = name.to_string(); + act(app, &ctx, "slideshow.templates.update", json!({"id": tpl.id}), |_| crate::i18n::tr_format!("Updated {name}", name = n)); + ui.close(); + } + if item(ui, "slideshowTemplateRename", "Rename…", true) { + set_pending(&ctx, Some(Pending::Rename(tpl.id.clone(), tpl.name.clone()))); + ui.close(); + } + } + if tpl.id != PREVIOUS && item(ui, "slideshowTemplateExport", "Export…", app.services.save_slideshow_template.is_some()) { + let file = format!("{}.json", lightcraft_engine::slide_template::file_stem(&tpl.name).unwrap_or_else(|| "Slideshow Template".into())); + if let Some(path) = app.services.save_slideshow_template.as_mut().and_then(|save| save(&file)) { + act(app, &ctx, "slideshow.templates.export", json!({"id": tpl.id, "path": path}), |r| { + crate::i18n::tr_format!("Exported to {path}", path = r["path"].as_str().unwrap_or_default()) + }); + } + ui.close(); + } + if user { + ui.separator(); + if item(ui, "slideshowTemplateDelete", "Delete", true) { + set_pending(&ctx, Some(Pending::Delete(tpl.id.clone()))); + ui.close(); + } + } +} + +/// A group heading inside the browser. +fn group(ui: &mut egui::Ui, t: &Tokens, text: &str) { + ui.add_space(2.0); + ui.label(RichText::new(crate::i18n::tr(text)).size(11.0).color(t.text_dim)); +} + +/// The Templates section. +pub fn section(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + heading(ui, t, "Templates"); + let listing = app.session.slide_templates(); + let now = SlideLook::of(&app.session); + let mut over: Option<String> = None; + group(ui, t, "Built-in"); + for tpl in listing.templates.iter().filter(|t| t.builtin) { + template_row(app, ui, t, tpl, is_current(&tpl.look, &now), &mut over); + } + group(ui, t, "Your templates"); + let user: Vec<_> = listing.templates.iter().filter(|t| !t.builtin).cloned().collect(); + if user.is_empty() { + hint(ui, t, "None yet: save the current settings as a template below."); + } else { + egui::ScrollArea::vertical().id_salt("settingsSlideshowTemplatesUser").max_height(USER_LIST_H).auto_shrink([false, true]).show(ui, |ui| { + for tpl in &user { + template_row(app, ui, t, tpl, is_current(&tpl.look, &now), &mut over); + } + }); + } + if let Ok(prev) = app.session.slide_template(PREVIOUS) { + group(ui, t, "Before the last template"); + template_row(app, ui, t, &prev, false, &mut over); + } + set_hovered(&ctx, over); + + // save the current settings as a new template + ui.add_space(4.0); + ui.horizontal(|ui| { + let mem = egui::Id::new("settingsSlideshowTemplateNewName"); + let mut name: String = ui.data(|d| d.get_temp(mem)).unwrap_or_default(); + let r = TextField::singleline("field:slideshowTemplateName", &mut name).hint(crate::i18n::tr("New template name")).width(180.0).show(ui); + let save = crate::widgets::text_button(ui, "slideshowTemplateSave", "Save as Template", false).clicked() + || (r.ending == Some(crate::text_field::Ending::Return) && !name.trim().is_empty()); + if save { + let n = name.trim().to_string(); + match app.run("slideshow.templates.save", json!({"name": n})) { + Ok(_) => { + app.toast(&ctx, crate::i18n::tr_format!("Saved template {name}", name = n)); + name.clear(); + } + Err(e) => app.toast(&ctx, e), + } + } + ui.data_mut(|d| d.insert_temp(mem, name)); + if app.services.pick_slideshow_templates.is_some() && crate::widgets::text_button(ui, "slideshowTemplateImport", "Import…", false).clicked() + { + let paths = app.services.pick_slideshow_templates.as_mut().map(|pick| pick()).unwrap_or_default(); + for path in paths { + act(app, &ctx, "slideshow.templates.import", json!({"path": path}), |r| { + crate::i18n::tr_format!("Imported {name}", name = r["name"].as_str().unwrap_or_default()) + }); + } + } + }); + if app.session.slide_templates_dir.is_none() { + hint(ui, t, "Your own templates need a settings folder, which this build doesn't have; the built-in ones work."); + } else { + hint( + ui, + t, + "Click a template to apply it; point at one to preview it on the selected photo. Right-click your own to update, rename, export or delete it.", + ); + } + // files that couldn't be read + for p in listing.problems.iter().take(4) { + ui.label(RichText::new(crate::i18n::tr_format!("Skipped {problem}", problem = p)).size(11.0).color(t.caution)); + } + if listing.problems.len() > 4 { + let more = listing.problems.len() - 4; + ui.label(RichText::new(crate::i18n::tr_format!("…and {more} more", more = more)).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slide_titles.rs b/crates/ui-egui/src/panels/slide_titles.rs new file mode 100644 index 000000000..257790995 --- /dev/null +++ b/crates/ui-egui/src/panels/slide_titles.rs @@ -0,0 +1,168 @@ +//! Settings ▸ Slideshow ▸ Titles (Lightroom Classic's Slideshow Titles panel): the intro and +//! ending screens, each on or off, with a background colour and, centred on it, the identity plate +//! of the Overlays section or a line of text, at a scale, for its own duration. +//! +//! Every control goes through `slideshow.titles` (validated there, saved with the library), like +//! the Layout and Overlays sections beside it. Changing a screen's settings switches the live +//! preview ([`super::slide_layout::preview`]) to that screen; its Photo / Intro / Ending buttons +//! pick what it shows ([`preview_shows`]). + +use lightcraft_engine::slide_overlay::{PLATE_SIZE_MAX, SIZE_MIN}; +use lightcraft_engine::slide_title::{TitleContent, TitleScreen}; +use lightcraft_engine::slideshow::{SLIDE_MAX, SLIDE_MIN, Title}; +use serde_json::{Value, json}; + +use super::settings::{check, choices, heading, hint, row}; +use crate::LightcraftApp; +use crate::text_field::TextField; +use crate::theme::Tokens; +use crate::widgets::register; + +/// Where the preview's choice lives (egui memory): `None` = the photo's slide. +fn preview_key() -> egui::Id { + egui::Id::new("settingsSlideshowPreviewShows") +} + +/// What the Settings preview shows: a title screen, or (`None`) the active photo's slide. +pub fn preview_shows(ctx: &egui::Context) -> Option<Title> { + ctx.data(|d| d.get_temp::<Option<Title>>(preview_key())).flatten() +} + +pub fn set_preview_shows(ctx: &egui::Context, t: Option<Title>) { + ctx.data_mut(|d| d.insert_temp(preview_key(), t)); +} + +/// The Photo / Intro / Ending buttons over the preview (`button:settingsSlideshowPreviewShows-{0,1,2}`). +pub fn preview_choice(ui: &mut egui::Ui) { + let mut v = preview_shows(ui.ctx()); + let opts = [(None, "Photo"), (Some(Title::Intro), "Intro"), (Some(Title::Ending), "Ending")]; + ui.horizontal(|ui| { + if choices(ui, "settingsSlideshowPreviewShows", &opts, &mut v) { + set_preview_shows(ui.ctx(), v); + } + }); +} + +/// Change `key` of screen `t` (and show that screen in the preview). +fn set(app: &mut LightcraftApp, ctx: &egui::Context, t: Title, key: &str, v: Value) { + let _ = app.run("slideshow.titles", json!({ t.id(): { key: v } })); + set_preview_shows(ctx, Some(t)); +} + +/// A slider (`slider:{id}`) for `key` of screen `t`; `k` scales the value for display. +#[allow(clippy::too_many_arguments)] +fn slider( + app: &mut LightcraftApp, + ui: &mut egui::Ui, + id: &str, + t: Title, + key: &str, + value: f64, + range: std::ops::RangeInclusive<f64>, + k: f64, + suffix: &str, +) { + let mut v = value * k; + let r = ui.add(egui::Slider::new(&mut v, (range.start() * k)..=(range.end() * k)).suffix(suffix).max_decimals(1)); + register(ui.ctx(), format!("slider:{id}"), r.rect); + let p = json!({ t.id(): { key: v / k } }); + if r.changed() { + if let Ok(next) = app.session.slide_titles.merged(&p) { + app.session.slide_titles = next; + } + set_preview_shows(ui.ctx(), Some(t)); + } + if r.drag_stopped() || (r.changed() && !r.dragged()) { + let _ = app.run("slideshow.titles", p); + } +} + +/// A colour button (`button:{id}`) for `key` of screen `t`. +fn colour(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, key: &str, value: [u8; 3]) { + let mut c = value; + let r = egui::color_picker::color_edit_button_srgb(ui, &mut c); + register(ui.ctx(), format!("button:{id}"), r.rect); + if r.changed() { + set(app, ui.ctx(), t, key, json!(c)); + } +} + +/// The screen's text field (`field:{id}`), saved when the edit ends. +fn text(app: &mut LightcraftApp, ui: &mut egui::Ui, id: &str, t: Title, value: &str) { + let mem = egui::Id::new(("slideshow-title-field", id)); + let mut s: String = ui.data(|d| d.get_temp(mem)).unwrap_or_else(|| value.to_string()); + let widget = format!("field:{id}"); + let r = TextField::singleline(&widget, &mut s).hint(crate::i18n::tr("Title text")).width(ui.available_width().clamp(80.0, 260.0)).show(ui); + if r.editing { + ui.data_mut(|d| d.insert_temp(mem, s.clone())); + } else { + ui.data_mut(|d| d.remove::<String>(mem)); + } + if r.committed() && s != value { + set(app, ui.ctx(), t, "text", json!(s)); + } +} + +/// One screen's controls; `name` prefixes the widget ids (`slideshowIntro…`). +fn screen(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens, title: Title, s: &TitleScreen) { + let (name, content_id, label) = match title { + Title::Intro => ("slideshowIntro", "settingsSlideshowIntroContent", "Intro screen"), + Title::Ending => ("slideshowEnding", "settingsSlideshowEndingContent", "Ending screen"), + }; + let mut on = s.enabled; + if check(ui, &format!("settings.{name}"), &mut on, label) { + set(app, ui.ctx(), title, "enabled", json!(on)); + } + if !on { + return; + } + row(ui, t, "Colour", |ui| colour(app, ui, &format!("{name}Color"), title, "color", s.color)); + row(ui, t, "Shows", |ui| { + let mut c = s.content; + let opts = [(TitleContent::IdentityPlate, "Identity plate"), (TitleContent::Text, "Text"), (TitleContent::None, "Colour only")]; + if choices(ui, content_id, &opts, &mut c) { + set(app, ui.ctx(), title, "content", json!(c.id())); + } + }); + if s.content == TitleContent::None { + return; + } + if s.content == TitleContent::Text { + row(ui, t, "Text", |ui| text(app, ui, &format!("{name}Text"), title, &s.text)); + } + row(ui, t, "Scale", |ui| slider(app, ui, &format!("{name}Scale"), title, "scale", s.scale, SIZE_MIN..=PLATE_SIZE_MAX, 100.0, " %")); + ui.horizontal(|ui| { + let mut over = s.override_color; + if check(ui, &format!("settings.{name}Override"), &mut over, "Override colour") { + set(app, ui.ctx(), title, "overrideColor", json!(over)); + } + if over { + colour(app, ui, &format!("{name}TextColor"), title, "textColor", s.text_color); + } + }); + let mut own = s.duration.is_some(); + if check(ui, &format!("settings.{name}OwnDuration"), &mut own, "Own duration (off: the slide duration)") { + let d = if own { json!(app.session.slideshow.slide_duration) } else { Value::Null }; + set(app, ui.ctx(), title, "duration", d); + } + if let Some(d) = s.duration { + row(ui, t, "Duration", |ui| slider(app, ui, &format!("{name}Duration"), title, "duration", d, SLIDE_MIN..=SLIDE_MAX, 1.0, " s")); + } +} + +/// The Titles section. +pub fn sections(app: &mut LightcraftApp, ui: &mut egui::Ui, t: &Tokens) { + let s = app.session.slide_titles.clone(); + heading(ui, t, "Titles"); + screen(app, ui, t, Title::Intro, &s.intro); + screen(app, ui, t, Title::Ending, &s.ending); + hint( + ui, + t, + "The intro plays once before the first photo; the ending after the last photo when Repeat is off. The identity plate is the one set under Overlays. Titles fade like slides and random order never moves them.", + ); + let warnings = s.warnings(&app.session.slide_overlays); + for w in warnings { + ui.label(egui::RichText::new(w).size(11.0).color(t.caution)); + } +} diff --git a/crates/ui-egui/src/panels/slideshow.rs b/crates/ui-egui/src/panels/slideshow.rs new file mode 100644 index 000000000..a0f7ba09f --- /dev/null +++ b/crates/ui-egui/src/panels/slideshow.rs @@ -0,0 +1,444 @@ +//! The slideshow (View ▸ Slideshow, ⌥⌘↩): the photos in view, full screen, played with the +//! library's Playback settings (`slideshow.playback`: slide and fade durations, fade colour, +//! random order, repeat, preparing previews in advance). +//! +//! The timeline is [`lightcraft_engine::slideshow::Playback`] (no egui, tested on its own); this +//! module renders the slides, draws the frame it asks for and the playback controls. +//! +//! Smooth transitions: every slide is rendered into its own texture ([`Slot::Slide`]) off the UI +//! thread. With "prepare previews" on, the next [`AHEAD`] slides are requested as soon as a slide +//! comes up, so they are ready long before they are due; a fade then only draws two textures +//! that already exist (no render, no upload inside the fade). The timeline does not start a +//! transition until the next slide's texture exists ([`ready`]): the current slide is held +//! instead of flashing a stand-in. With it off, a slide is rendered when it is due. +//! +//! Which picture a slide shows comes from one place, [`slide_job`]: the photo itself with the +//! plain layout, else the slide composed with the library's layout (`slideshow.layout`: margins, +//! zoom to fill, stroke, shadow, backdrop) by `lightcraft-slideshow` on the render worker. A +//! composed slide is a screen-sized texture; the playback (fades, controls) is the same. +//! +//! Title screens (`slideshow.titles`: the intro before the first photo, the ending after the last +//! one) are slides of the same timeline ([`Item::Title`]): composed at the screen's pixels on a +//! render worker ([`crate::render::Renderer::request_title`]), prepared ahead, waited for and +//! faded like photo slides. They never move the selection, and the controls show "Intro" / "End" +//! in place of "n / N" while one is up (titles aren't photos, so N stays the number of photos). + +use egui::{Align2, Color32, Pos2, Rect, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::slideshow::{Item, Playback, Tick, Title}; +use lightcraft_pipeline::geometry::Frame; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::icons::Icon; +use crate::render::Slot; +use crate::widgets::{icon_button, register}; + +/// Slides prepared ahead of the one up. +pub const AHEAD: usize = 2; +/// Seconds the playback controls stay after the pointer last moved. +pub const CONTROLS_SECS: f64 = 2.5; + +/// A running slideshow. +#[derive(Clone, Debug)] +pub struct Run { + pub play: Playback, + /// The playback controls show until this time (egui time). + pub controls_until: f64, + /// Where the pointer was last frame (the controls appear when it moves). + pub pointer: Option<Pos2>, + /// Slides no render can be made for (gone from the catalog, nothing to read): shown with + /// whatever stand-in there is, never waited for. + pub unrenderable: Vec<Item>, +} + +/// `view.slideshow {interval?, seed?, slideDuration?, fadeDuration?, fadeColor?, randomOrder?, +/// repeat?, preparePreviews?}`: play the photos in view from the selected one with the library's +/// playback settings; the keys given change them for this run only (`interval` = slideDuration, +/// `seed` fixes the random order). The intro and ending screens that are on (`slideshow.titles`) +/// play before the first photo and after the last one. With no photos in view there is nothing +/// to show (titles alone don't make a slideshow). +pub fn start(app: &mut LightcraftApp, p: &Value, now: f64) -> Result<Value, String> { + let mut overrides = p.as_object().cloned().unwrap_or_default(); + if let Some(i) = overrides.remove("interval") { + overrides.insert("slideDuration".into(), i); + } + let seed = match overrides.remove("seed") { + None | Some(Value::Null) => (now.abs() * 1000.0) as u64 ^ 0x5eed_5eed, + Some(v) => v.as_u64().ok_or_else(|| format!("view.slideshow: `seed` must be a whole number, not {v}"))?, + }; + let settings = app.session.slideshow.merged(&Value::Object(overrides)).map_err(|e| format!("view.slideshow: {e}"))?; + let photos = app.session.visible_cloned(); + let (intro, ending) = app.session.slide_titles.durations(settings.slide_duration); + let Some(play) = Playback::new(photos, app.session.active(), settings, seed, now) else { + return Err("no photos to show".into()); + }; + let play = play.with_titles(intro, ending); + let first = play.current_photo().or_else(|| play.upcoming(1).first().and_then(|i| i.photo())); + if let Some(first) = first.filter(|f| app.session.active() != Some(*f)) { + let _ = app.run("library.select", json!({"ids": [first.0]})); + } + let (_, slides) = play.position(); + let out = json!({"slides": slides, "seed": seed, "settings": play.settings().to_json()}); + app.ui.slideshow = Some(Box::new(Run { play, controls_until: 0.0, pointer: None, unrenderable: Vec::new() })); + app.ui.fullscreen = true; + app.ui.zoom = crate::state::Zoom::Fit; + app.ui.tool.clear(); + let _ = app.session.end_interaction(); + Ok(out) +} + +/// End the slideshow (back to the view it started from, on the last photo shown). +pub fn end(app: &mut LightcraftApp) { + app.ui.slideshow = None; + app.ui.fullscreen = false; + app.renderer.release_slides(&[]); +} + +/// The slideshow's own commands (the playback controls and their keys). +pub fn command(app: &mut LightcraftApp, id: &str, now: f64) -> Result<Value, String> { + let Some(run) = app.ui.slideshow.as_mut() else { return Err("no slideshow is playing".into()) }; + let t = match id { + "slideshow.togglePause" => { + run.play.toggle_pause(now); + return Ok(state(&run.play, now)); + } + "slideshow.next" => run.play.step(1, now), + "slideshow.previous" => run.play.step(-1, now), + _ => { + end(app); + return Ok(Value::Null); + } + }; + after(app, t); + Ok(app.ui.slideshow.as_ref().map(|r| state(&r.play, now)).unwrap_or(json!({"ended": true}))) +} + +/// What the slideshow is doing (for `slideshow.*` replies and tests). +pub fn state(play: &Playback, now: f64) -> Value { + let (n, of) = play.position(); + let f = play.frame(now); + json!({ + "current": play.current_photo().map(|p| p.0), + "title": play.title().map(Title::id), + "slide": n, + "of": of, + "paused": play.is_paused(), + "fading": f.from.is_some(), + "progress": f.progress, + "waitingFor": play.waiting_for().map(|i| i.photo().map_or_else(|| json!(i.title().map(Title::id)), |p| json!(p.0))), + "remaining": play.remaining(now), + }) +} + +/// Act on what the timeline did: follow the slide with the selection, or end. +fn after(app: &mut LightcraftApp, t: Tick) { + match t { + Tick::Advanced(Item::Photo(id)) if app.session.active() != Some(id) => { + let _ = app.run("library.select", json!({"ids": [id.0]})); + } + Tick::Ended => end(app), + _ => {} + } +} + +/// Can `item` be shown as a slide now: its texture exists (or it can't be rendered at all, and +/// its stand-in is all there will be: playback goes on rather than waiting forever). +pub fn ready(app: &LightcraftApp, item: Item) -> bool { + let slot = Slot::Slide(item); + let rendered = match item { + Item::Photo(id) => app.renderer.textures.get(&slot).is_some_and(|t| t.photo == id) || app.session.catalog.photo(id).is_none(), + Item::Title(_) => app.renderer.textures.contains_key(&slot), + }; + rendered || app.renderer.failure(slot).is_some() || app.ui.slideshow.as_ref().is_some_and(|r| r.unrenderable.contains(&item)) +} + +/// Advance a running slideshow (every frame, before drawing). +pub fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + let Some(run) = app.ui.slideshow.as_ref() else { + app.renderer.release_slides(&[]); + return; + }; + if !app.ui.fullscreen { + end(app); + return; + } + let now = ctx.input(|i| i.time); + let mut play = run.play.clone(); + let t = play.tick(now, |id| ready(app, id)); + if let Some(run) = app.ui.slideshow.as_mut() { + run.play = play; + } + after(app, t); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let f = run.play.frame(now); + if f.from.is_some() { + ctx.request_repaint(); // a fade draws every frame + } else if !run.play.is_paused() { + ctx.request_repaint_after(std::time::Duration::from_secs_f64(run.play.remaining(now).clamp(0.016, 120.0))); + } + if run.controls_until > now { + ctx.request_repaint_after(std::time::Duration::from_secs_f64((run.controls_until - now).clamp(0.016, CONTROLS_SECS))); + } +} + +/// The render that shows slide `id` in `area` (logical points at `ppp`). The one place that +/// decides what a slide's picture is: +/// - with the plain layout (no margins, border, shadow or backdrop: [`SlideSettings::is_plain`]) +/// the photo as developed, fitted to the screen (drawn over black); +/// - otherwise the slide composed with the library's layout (`slideshow.layout`) at the screen's +/// pixels: the photo rendered at its image cell's size, then composed on the render worker, so +/// a slide is ready (and fades) like any other texture. +/// +/// [`SlideSettings::is_plain`]: lightcraft_engine::slide::SlideSettings::is_plain +fn slide_job(app: &mut LightcraftApp, id: PhotoId, area: Rect, ppp: f32, texture_side: usize) -> Option<lightcraft_engine::RenderJob> { + let photo = app.session.catalog.photo(id)?.clone(); + let frame = Frame::with_lens(photo.width.max(1) as usize, photo.height.max(1) as usize, &photo.develop, true, photo.embedded_lens.as_ref()); + let native = super::detail::output_px(&frame); + if !app.session.slides_plain() { + let (w, h) = slide_px(area, ppp, texture_side); + let edge = app.ui.settings.loupe_edge(w.max(h) as f32, native[0].max(native[1]), texture_side); + return app.session.slide_job(id, w, h, Some(edge)); + } + let aspect = frame.aspect() as f32; + let aspect = if aspect.is_finite() && aspect > 0.0 { aspect } else { 1.5 }; + let fit = (area.width() / aspect).min(area.height()); + let drawn_long = fit.max(fit * aspect) * ppp; + let edge = app.ui.settings.loupe_edge(drawn_long, native[0].max(native[1]), texture_side); + let (w, h) = if aspect >= 1.0 { (edge, (edge as f32 / aspect) as usize) } else { ((edge as f32 * aspect) as usize, edge) }; + app.session.loupe_job(id, w.max(8), h.max(8), true) +} + +/// A composed slide's size in pixels: `area` at `ppp`, its long side at most the texture side +/// (and the composer's limit), at least 16 × 16. +pub fn slide_px(area: Rect, ppp: f32, texture_side: usize) -> (usize, usize) { + let px = |v: f32| if v.is_finite() { (v * ppp).round().max(16.0) as usize } else { 16 }; + let (w, h) = (px(area.width()), px(area.height())); + let cap = texture_side.clamp(16, lightcraft_slideshow::MAX_SIDE); + let long = w.max(h); + if long <= cap { + return (w, h); + } + let k = cap as f64 / long as f64; + (((w as f64 * k).round() as usize).max(16), ((h as f64 * k).round() as usize).max(16)) +} + +/// The slides to keep rendered now, most wanted first, with their render priority. +fn wanted(play: &Playback, now: f64) -> Vec<(Item, u32)> { + let f = play.frame(now); + let mut v = vec![(f.current, 100)]; + if let Some(from) = f.from { + v.push((from, 99)); + } + if let Some(next) = play.waiting_for() { + v.push((next, 98)); + } + if play.settings().prepare_previews { + for (k, id) in play.upcoming(AHEAD).into_iter().enumerate() { + v.push((id, 97u32.saturating_sub(k as u32))); + } + } + let mut seen = Vec::new(); + v.retain(|(id, _)| { + let fresh = !seen.contains(id); + seen.push(*id); + fresh + }); + v +} + +/// The texture to draw for slide `item`: its own render, else (a photo) the loupe's or a +/// thumbnail (a slide stepped to with ←/→ shows at once, sharp when its render lands). A title +/// screen has nothing to stand in for it: the black canvas shows until it lands. +fn texture(app: &LightcraftApp, item: Item) -> Option<&crate::render::Tex> { + let Item::Photo(id) = item else { return app.renderer.textures.get(&Slot::Slide(item)) }; + app.renderer + .textures + .get(&Slot::Slide(item)) + .filter(|t| t.photo == id) + .or_else(|| app.renderer.textures.get(&Slot::Main).filter(|t| t.photo == id)) + .or_else(|| app.renderer.thumb(id)) +} + +fn full_uv() -> Rect { + Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)) +} + +/// The parts of `outer` outside `inner` (up to four bands). +fn outside(outer: Rect, inner: Rect) -> Vec<Rect> { + let i = inner.intersect(outer); + if !i.is_positive() { + return vec![outer]; + } + let mut v = Vec::new(); + if i.top() > outer.top() { + v.push(Rect::from_min_max(outer.min, pos2(outer.right(), i.top()))); + } + if i.bottom() < outer.bottom() { + v.push(Rect::from_min_max(pos2(outer.left(), i.bottom()), outer.max)); + } + if i.left() > outer.left() { + v.push(Rect::from_min_max(pos2(outer.left(), i.top()), pos2(i.left(), i.bottom()))); + } + if i.right() < outer.right() { + v.push(Rect::from_min_max(pos2(i.right(), i.top()), pos2(outer.right(), i.bottom()))); + } + v +} + +/// Draw the slideshow (full screen) and its controls. +pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { + let canvas = ui.max_rect(); + app.canvas_rect = Some(canvas); + // (`ui.pointer` positions are relative to it) + app.image_rect = Some(canvas); + let Some(run) = app.ui.slideshow.as_ref() else { return }; + let now = ui.input(|i| i.time); + let ppp = ui.ctx().pixels_per_point(); + let side = super::detail::texture_side(ui.ctx()); + let want = wanted(&run.play, now); + let frame = run.play.frame(now); + let mut unrenderable = Vec::new(); + for (item, priority) in &want { + match *item { + Item::Title(t) => { + let (w, h) = slide_px(canvas, ppp, side); + let compose = app.session.title_compose(t, w, h); + app.renderer.request_title(Slot::Slide(*item), compose, *priority); + } + Item::Photo(id) => match slide_job(app, id, canvas, ppp, side) { + Some(job) => app.renderer.request(Slot::Slide(*item), job, *priority), + None => unrenderable.push(*item), + }, + } + } + if let Some(run) = app.ui.slideshow.as_mut() { + for id in unrenderable { + if !run.unrenderable.contains(&id) { + run.unrenderable.push(id); + } + } + } + let keep: Vec<Item> = want.iter().map(|(id, _)| *id).collect(); + app.renderer.release_slides(&keep); + let p = ui.painter_at(canvas); + p.rect_filled(canvas, 0.0, Color32::BLACK); + let rect_of = |t: &crate::render::Tex| super::detail::fit_texture_rect(canvas, t.size); + let draw = |id: Item, alpha: f32| -> Option<Rect> { + let t = texture(app, id)?; + let r = rect_of(t); + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(alpha.clamp(0.0, 1.0))); + Some(r) + }; + let k = frame.progress.clamp(0.0, 1.0); + match (frame.from, frame.fade_color) { + // fade through a colour: out to it, then in from it + (Some(from), Some(c)) => { + let veil = if k < 0.5 { + draw(from, 1.0); + k * 2.0 + } else { + draw(frame.current, 1.0); + 2.0 - k * 2.0 + }; + p.rect_filled(canvas, 0.0, Color32::from_rgb(c[0], c[1], c[2]).gamma_multiply(veil.clamp(0.0, 1.0))); + } + // crossfade: where the slides overlap the new one goes over the old at `k`; where only + // the old one is, it fades to the backdrop + (Some(from), None) => { + let new_rect = texture(app, frame.current).map(rect_of); + if let Some(t) = texture(app, from) { + let r = rect_of(t); + match new_rect { + Some(n) => { + p.with_clip_rect(n.intersect(canvas)).image(t.tex.id(), r, full_uv(), Color32::WHITE); + for band in outside(r, n) { + p.with_clip_rect(band).image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + None => { + p.image(t.tex.id(), r, full_uv(), Color32::WHITE.gamma_multiply(1.0 - k)); + } + } + } + draw(frame.current, k); + } + _ => { + draw(frame.current, 1.0); + } + } + register(ui.ctx(), "slideshow:canvas", canvas); + controls(app, ui, canvas, now); +} + +/// The playback controls: previous, play / pause, next, slide n of N, end. They appear when the +/// pointer moves and fade out [`CONTROLS_SECS`] after it stops (or stay while it is over them). +fn controls(app: &mut LightcraftApp, ui: &mut egui::Ui, canvas: Rect, now: f64) { + let t = crate::theme::Tokens::get(ui.ctx()); + let pos = ui.input(|i| i.pointer.latest_pos()); + let pressed = ui.input(|i| i.pointer.any_pressed()); + let bar = Rect::from_center_size(pos2(canvas.center().x, canvas.bottom() - 56.0), vec2(300.0, 44.0)); + let Some(run) = app.ui.slideshow.as_mut() else { return }; + let moved = pos.is_some() && run.pointer.is_some() && pos != run.pointer; + let over = pos.is_some_and(|p| bar.expand(8.0).contains(p)); + if moved || pressed || (over && run.controls_until > now) { + run.controls_until = now + CONTROLS_SECS; + } + run.pointer = pos.or(run.pointer); + let left = run.controls_until - now; + if left <= 0.0 { + return; + } + let paused = run.play.is_paused(); + let (n, of) = run.play.position(); + let title = run.play.title(); + // fade out over the last half second + let alpha = (left / 0.5).clamp(0.0, 1.0) as f32; + let p = ui.painter_at(canvas); + p.rect_filled(bar, 10.0, Color32::from_black_alpha((200.0 * alpha) as u8)); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(bar.shrink2(vec2(10.0, 4.0))).layout(egui::Layout::left_to_right(egui::Align::Center))); + child.set_opacity(alpha); + child.spacing_mut().item_spacing.x = 4.0; + let size = vec2(36.0, 36.0); + let mut fire = None; + if icon_button(&mut child, "slideshowPrevious", Icon::ChevronLeft, size, false, true, "Previous slide (←)").clicked() { + fire = Some("slideshow.previous"); + } + let (icon, tip) = if paused { (Icon::Play, "Play (Space)") } else { (Icon::Pause, "Pause (Space)") }; + if icon_button(&mut child, "slideshowPlayPause", icon, size, false, true, tip).clicked() { + fire = Some("slideshow.togglePause"); + } + if icon_button(&mut child, "slideshowNext", Icon::ChevronRight, size, false, true, "Next slide (→)").clicked() { + fire = Some("slideshow.next"); + } + let (r, _) = child.allocate_exact_size(vec2(118.0, 36.0), egui::Sense::hover()); + let label = match title { + Some(Title::Intro) => crate::i18n::tr("Intro").to_string(), + Some(Title::Ending) => crate::i18n::tr("End").to_string(), + None => format!("{n} / {of}"), + }; + child.painter().text(r.center(), Align2::CENTER_CENTER, label, t.font(13.0), Color32::from_gray(230).gamma_multiply(alpha)); + if icon_button(&mut child, "slideshowEnd", Icon::Close, size, false, true, "End slideshow (Esc)").clicked() { + fire = Some("slideshow.end"); + } + if let Some(id) = fire { + let _ = app.run(id, json!({})); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn crossfade_bands_cover_the_old_slide_outside_the_new_one() { + let outer = Rect::from_min_max(pos2(0.0, 0.0), pos2(100.0, 60.0)); + let inner = Rect::from_min_max(pos2(30.0, 0.0), pos2(70.0, 60.0)); + let bands = outside(outer, inner); + let area: f32 = bands.iter().map(|b| b.area()).sum(); + assert_eq!(area, 100.0 * 60.0 - 40.0 * 60.0); + assert_eq!(outside(outer, Rect::from_min_max(pos2(200.0, 0.0), pos2(300.0, 10.0))), vec![outer], "no overlap: all of it"); + assert!(outside(outer, outer.expand(5.0)).is_empty(), "covered"); + } +} diff --git a/crates/ui-egui/src/panels/strip.rs b/crates/ui-egui/src/panels/strip.rs index ae1d6e557..164204b86 100644 --- a/crates/ui-egui/src/panels/strip.rs +++ b/crates/ui-egui/src/panels/strip.rs @@ -9,6 +9,11 @@ use crate::state::RightPanel; use crate::theme::Tokens; use crate::widgets::icon_button; +/// Height of one tool button. +const BUTTON_H: f32 = 40.0; +/// Space between tool buttons. +const GAP: f32 = 6.0; + pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); egui::Panel::right("tool_strip") @@ -23,8 +28,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { .show(ui, |ui| { let has_photo = app.session.active().is_some(); ui.vertical_centered(|ui| { - ui.spacing_mut().item_spacing.y = 6.0; - let sz = vec2(t.strip_w, 40.0); + ui.spacing_mut().item_spacing.y = GAP; + let sz = vec2(t.strip_w, BUTTON_H); if icon_button(ui, "presets", Icon::Presets, sz, app.ui.presets, has_photo, "Presets (Shift+P)").clicked() { let _ = app.run("panel.presets", json!({})); } @@ -54,14 +59,17 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { app.ui.dialog = Some(crate::state::Dialog::About); } }); - // bottom icons + // bottom icons: two buttons and one gap tall, so Info ends at the bottom margin as + // Presets starts at the top one let bottom = ui.max_rect().bottom(); - let mut child = ui.new_child( - egui::UiBuilder::new() - .max_rect(egui::Rect::from_min_max(egui::pos2(ui.max_rect().left(), bottom - 96.0), ui.max_rect().right_bottom())), - ); + let mut child = + ui.new_child(egui::UiBuilder::new().max_rect(egui::Rect::from_min_max( + egui::pos2(ui.max_rect().left(), bottom - (2.0 * BUTTON_H + GAP)), + ui.max_rect().right_bottom(), + ))); child.vertical_centered(|ui| { - let sz = vec2(t.strip_w, 40.0); + ui.spacing_mut().item_spacing.y = GAP; + let sz = vec2(t.strip_w, BUTTON_H); if icon_button(ui, "keywords", Icon::Tag, sz, app.ui.right == RightPanel::Keywords, has_photo, "Keywords (K)").clicked() { let _ = app.run("panel.keywords", json!({})); } @@ -76,3 +84,27 @@ fn separator(ui: &mut egui::Ui, t: &Tokens) { let (r, _) = ui.allocate_exact_size(vec2(t.strip_w - 16.0, 1.0), egui::Sense::hover()); ui.painter().rect_filled(r, 0.0, t.button_border.gamma_multiply(0.7)); } + +#[cfg(test)] +mod tests { + use super::*; + use crate::Services; + use crate::headless::HeadlessView; + + #[test] + fn info_ends_as_far_above_the_bottom_as_presets_starts_below_the_top() { + let mut app = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + let mut view = HeadlessView::new(); + let mut widgets = vec![]; + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(300.0, 700.0), 1.0, frame as f64 / 60.0, vec![]); + view.run(raw, |ui| show(&mut app, ui)); + widgets = crate::widgets::take_registry(&view.ctx); + } + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not registered")).1; + let (presets, keywords, info) = (rect("icon:presets"), rect("icon:keywords"), rect("icon:info")); + let (top, bottom) = (presets.top(), 700.0 - info.bottom()); + assert!((top - bottom).abs() < 0.5, "Presets starts {top} below the top, Info ends {bottom} above the bottom"); + assert_eq!(info.top() - keywords.bottom(), GAP, "the bottom buttons are spaced like the top ones"); + } +} diff --git a/crates/ui-egui/src/panels/topbar.rs b/crates/ui-egui/src/panels/topbar.rs index 03810aca9..7e40af7d5 100644 --- a/crates/ui-egui/src/panels/topbar.rs +++ b/crates/ui-egui/src/panels/topbar.rs @@ -10,12 +10,13 @@ use crate::widgets::{icon_button, register}; /// The bar doubles as the window's title bar: dragging its empty space moves the window and a /// double-click zooms or restores it. Registered before the bar's widgets so they win the click. -fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32) { +fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_left: f32, margin_right: f32, search_rect: Rect) { use crate::titlebar::{Gesture, WindowState, command_for}; let bar = Rect::from_min_max(pos2(content.left() - margin_left, content.top()), pos2(content.right() + margin_right, content.bottom())); register(ui.ctx(), "region:titlebar", bar); let handle = egui::Id::new("titlebar-handle"); let resp = ui.interact(bar, handle, Sense::click_and_drag()); + crate::widgets::announce(&resp, crate::i18n::tr("Title bar")); // a press that began on a button or the search field is theirs: dragging off it must not move the window // (while pressing, egui hovers every widget under the pointer; once the drag starts, only the dragged one) let ctx = ui.ctx().clone(); @@ -27,9 +28,12 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l ctx.data_mut(|d| d.insert_temp(memo, on_widget)); } let on_widget = ctx.data(|d| d.get_temp::<bool>(memo)).unwrap_or(false); - let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) { + // The search field is drawn after this handle. Its hit-test response may not exist + // yet when the press is recorded; the press origin still identifies text selection. + let on_search = ui.input(|i| i.pointer.press_origin().is_some_and(|p| search_rect.contains(p))); + let gesture = if resp.double_clicked_by(egui::PointerButton::Primary) && !on_search { Some(Gesture::DoubleClicked) - } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget { + } else if resp.drag_started_by(egui::PointerButton::Primary) && !on_widget && !on_search { Some(Gesture::DragStarted) } else { None @@ -44,23 +48,31 @@ fn window_handle(app: &LightcraftApp, ui: &mut egui::Ui, content: Rect, margin_l pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let left = if app.integrated_titlebar { 78 } else { 10 }; + // room for the traffic lights; in full screen they are gone, and the sidebar button sits as far + // from the left edge as the Discord button from the right (12 margin + 4) + let left = match (app.integrated_titlebar, app.window_is_fullscreen) { + (true, false) => 78, + (true, true) => 16, + (false, _) => 10, + }; egui::Panel::top("top_bar") .exact_size(t.top_bar_h) .frame(egui::Frame::NONE.fill(t.chrome).inner_margin(egui::Margin { left, right: 12, top: 0, bottom: 0 })) .show(ui, |ui| { let full = ui.max_rect(); - if app.integrated_titlebar { - window_handle(app, ui, full, left as f32, 12.0); - } - // room for the filter icon and the six icons on the right - let mut sw = 640.0f32.min(full.width() - 540.0).max(200.0); + // room for the filter icon and the seven icons on the right + // (the field is centred, so each icon on the right takes its width from both sides) + let mut sw = 640.0f32.min(full.width() - 620.0).max(190.0); if !app.native_menu { // leave room for the in-window menus left of the (centred) search field let menus_right = full.left() + 140.0 + crate::menubar::bar_width(ui) + 24.0; - sw = sw.min(2.0 * (full.center().x - menus_right)).max(200.0); + sw = sw.min(2.0 * (full.center().x - menus_right)).max(190.0); } let search_left = full.center().x - sw / 2.0; + let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); + if app.integrated_titlebar { + window_handle(app, ui, full, left as f32, 12.0, sr); + } ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 4.0; if icon_button(ui, "sidebar", Icon::Sidebar, vec2(30.0, 30.0), app.ui.left_panel, true, crate::i18n::tr("Show/hide My Photos panel")) @@ -81,7 +93,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { } }); // search field (centred on the window) - let sr = Rect::from_center_size(pos2(full.center().x, full.center().y), vec2(sw, 28.0)); let id = crate::text_field::id("field:search"); if std::mem::take(&mut app.ui.focus_search) { ui.memory_mut(|m| m.request_focus(id)); @@ -119,6 +130,7 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { // filter icon right of the search field let fr = Rect::from_center_size(pos2(sr.right() + 22.0, sr.center().y), vec2(28.0, 28.0)); let fresp = ui.interact(fr, egui::Id::new("filter-btn"), Sense::click()); + crate::widgets::announce(&fresp, crate::i18n::tr("Filters")); register(ui.ctx(), "icon:filter", fr); let filtering = app.session.filter != Default::default() || app.ui.filter_bar; paint( @@ -161,15 +173,23 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ) }); let cloud_tip = unsaved.as_deref().unwrap_or("Local library — no cloud account needed"); + // Import Photos… beside Export, always one click away, with the key it is bound to now + let mac = ui.ctx().os() == egui::os::OperatingSystem::Mac; + let import_tip = match crate::shortcuts::shortcut_of(&app.ui.settings.keymap, "file.addPhotos") { + Some(sc) => format!("{} ({})", crate::i18n::tr("Import Photos…"), crate::menubar::shortcut_text(sc, mac)), + None => crate::i18n::tr("Import Photos…").to_string(), + }; for (id, icon, tip, cmd) in [ ("discord", Icon::Chat, "Join the ArtCraft community on Discord", "app.discord"), ("cloud", Icon::Cloud, cloud_tip, ""), ("help", Icon::Help, "Keyboard shortcuts", "app.shortcuts"), ("share", Icon::Share, "Export", "dialog.export"), + ("import", Icon::Plus, import_tip.as_str(), "file.addPhotos"), ("bell", Icon::Bell, "Activity", "panel.activity"), ] { let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let resp = ui.interact(r, egui::Id::new(("top", id)), Sense::click()).on_hover_text(crate::i18n::tr(tip)); + crate::widgets::announce(&resp, crate::i18n::tr(tip)); register(ui.ctx(), format!("icon:{id}"), r); let warn = id == "cloud" && unsaved.is_some(); let colour = if warn { @@ -186,8 +206,12 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { ui.painter().text(c, Align2::CENTER_CENTER, "!", t.semibold(9.5), t.canvas); register(ui.ctx(), "indicator:unsaved", r); } - if resp.clicked() && !cmd.is_empty() { - let _ = app.run(cmd, json!({})); + // a refusal says why (Import while a Lightroom catalog is coming in) + if resp.clicked() + && !cmd.is_empty() + && let Err(e) = app.run(cmd, json!({})) + { + app.toast_error(ui.ctx(), e); } x -= 40.0; } @@ -195,7 +219,8 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) { let (icon, mode) = appearance_icon(app.ui.settings.appearance_mode); let r = Rect::from_center_size(pos2(x, full.center().y), vec2(28.0, 28.0)); let tip = format!("{}: {}", crate::i18n::tr("Appearance Mode"), crate::i18n::tr(mode)); - let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip); + let resp = ui.interact(r, egui::Id::new(("top", "appearance")), Sense::click()).on_hover_text(tip.clone()); + crate::widgets::announce(&resp, tip.clone()); register(ui.ctx(), "icon:appearance", r); paint(ui.painter(), r.shrink(5.0), icon, if resp.hovered() { t.text } else { t.icon }); if resp.clicked() { diff --git a/crates/ui-egui/src/panels/unnamed.rs b/crates/ui-egui/src/panels/unnamed.rs index 0b730db78..e1ef879e2 100644 --- a/crates/ui-egui/src/panels/unnamed.rs +++ b/crates/ui-egui/src/panels/unnamed.rs @@ -148,7 +148,7 @@ pub fn naming_bar(app: &mut LightcraftApp, ui: &mut egui::Ui) { if b.clicked() { submit = Some(chosen); } - let c = ui.button("Clear"); + let c = ui.button(crate::i18n::tr("Clear")); register(ui.ctx(), "unnamed:clear", c.rect); clear = c.clicked() || field.cancelled(); }); @@ -203,7 +203,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed p.text(pos2(at.x, at.y + 24.0), Align2::LEFT_CENTER, sub, t.font(12.0), t.text_dim); if !list.ready && list.faces.is_empty() { let r = Rect::from_min_size(pos2(at.x, at.y + 38.0), vec2(120.0, 22.0)); - let b = ui.put(r, egui::Button::new("Open Settings")); + let b = ui.put(r, egui::Button::new(crate::i18n::tr("Open Settings"))); register(ui.ctx(), "unnamed:openSettings", b.rect); if b.clicked() { let _ = app.run("app.settings", json!({"tab": "faces"})); @@ -211,7 +211,7 @@ pub fn section_header(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed } else if !list.faces.is_empty() { let r = Rect::from_min_size(pos2(right - 130.0, at.y + 12.0), vec2(130.0, 22.0)); let all = app.ui.unnamed_selected.len() == list.faces.len(); - let b = ui.put(r, egui::Button::new(if all { "Select none" } else { "Select all" })); + let b = ui.put(r, egui::Button::new(crate::i18n::tr(if all { "Select none" } else { "Select all" }))); register(ui.ctx(), "unnamed:selectAll", b.rect); if b.clicked() { app.ui.unnamed_selected.clear(); @@ -229,6 +229,7 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize let key = (f.photo, f.index); let selected = app.ui.unnamed_selected.contains(&key); let resp = ui.interact(r, egui::Id::new(("unnamed-face", f.photo, f.index)), Sense::click()); + crate::widgets::announce_selected(&resp, crate::i18n::tr("Unnamed face"), selected); register(ui.ctx(), format!("unnamed-face:{}:{}", f.photo, f.index), r); // a suggested name along the bottom: a click accepts it for this face (added after the tile, so it wins there) let strip = Rect::from_min_max(pos2(r.left(), r.bottom() - 20.0), r.right_bottom()); @@ -266,7 +267,13 @@ pub fn tile(app: &mut LightcraftApp, ui: &mut egui::Ui, list: &Unnamed, i: usize name(app, &[key], who); return; } - if resp.clicked() { + // a double click opens the photo, as in the grid; its second click must not undo the first one's selection + if resp.double_clicked() { + let _ = app.run("library.select", json!({"ids": [f.photo], "active": f.photo})); + let _ = app.run("view.detail", json!({})); + // Escape comes back to the faces + app.ui.person_from = Some((None, f.photo)); + } else if resp.clicked() { let shift = ui.input(|i| i.modifiers.shift); click(app, list, i, shift); } diff --git a/crates/ui-egui/src/pick.rs b/crates/ui-egui/src/pick.rs index 1032a5cd9..7ee3853ed 100644 --- a/crates/ui-egui/src/pick.rs +++ b/crates/ui-egui/src/pick.rs @@ -58,7 +58,7 @@ impl PickRequest { } /// What File ▸ Import Profiles & Presets… opens. -pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube"]; +pub const PRESET_EXTENSIONS: &[&str] = &["lcpreset", "xmp", "lrtemplate", "zip", "dng", "lmp", "mplumpack", "cube", "lceffect"]; /// What Import Point Curve Presets… opens. pub const CURVE_PRESET_EXTENSIONS: &[&str] = &["lccurve", "json"]; diff --git a/crates/ui-egui/src/print_view.rs b/crates/ui-egui/src/print_view.rs new file mode 100644 index 000000000..0c3745270 --- /dev/null +++ b/crates/ui-egui/src/print_view.rs @@ -0,0 +1,963 @@ +//! The Print dialog (File ▸ Print…): settings on the left, the pages drawn to scale on the right. +//! +//! Every change goes through a `print.*` command ([`lightcraft_engine::cmd`]); this file only draws +//! the session's print ([`lightcraft_engine::Session::print`]) and turns pointer input into +//! commands: dragging a cell is `print.cell.move`, its corner handle `print.cell.resize`, with +//! the guides' snapping done by the command. Widgets register under `print:*`, `button:print*` +//! and `drag:print*` ids for the control channel and the headless tests. +use egui::{Align2, Color32, Pos2, Rect, Sense, Stroke, StrokeKind, Ui, Vec2, pos2, vec2}; +use lightcraft_catalog::PhotoId; +use lightcraft_engine::export::ExportOptions; +use lightcraft_engine::print::compose::{self, place}; +use lightcraft_engine::print::layout::{self, Snap, Style, Unit}; +use lightcraft_engine::print::template::PrintSettings; +use lightcraft_engine::print_color::{ManagedSpace, PrintColorSettings, PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::theme::Tokens; +use crate::widgets::{register, text_button}; + +/// Width of the settings column. +const SETTINGS_W: f32 = 290.0; +/// Handle for resizing the selected cell, screen points. +const HANDLE: f32 = 12.0; +/// Snap distance while dragging, screen points. +const SNAP_PX: f32 = 8.0; +/// Cell sizes offered as buttons, inches (width, height). +const CELL_SIZES: &[(f64, f64)] = &[(2.0, 2.0), (2.5, 3.5), (3.5, 5.0), (4.0, 5.0), (4.0, 6.0), (5.0, 7.0), (8.0, 10.0), (3.0, 3.0)]; + +/// A drag of a cell in progress: the cell's rectangle when it began and how far the pointer went. +#[derive(Clone, Debug)] +pub struct Drag { + cell: u32, + origin: layout::Rect, + resize: bool, + /// Where the pointer was pressed; the distance dragged is measured from it, so the first few + /// points egui spends deciding between a click and a drag are not lost. + press: Pos2, + total: Vec2, +} + +/// What the Print dialog remembers between frames (not saved). +#[derive(Clone, Debug, Default)] +pub struct PrintUi { + /// The page being shown, zero-based. + pub page: usize, + /// The selected cell. + pub selected: Option<u32>, + drag: Option<Drag>, + /// The name typed for Save Template. + pub template_name: String, + /// Size of the next cell added, inches. + pub new_cell: Option<(f64, f64)>, +} + +fn run(app: &mut LightcraftApp, ctx: &egui::Context, id: &str, params: Value) -> Option<Value> { + match app.run(id, params) { + Ok(v) => Some(v), + Err(e) => { + app.toast(ctx, e); + None + } + } +} + +fn patch(app: &mut LightcraftApp, ctx: &egui::Context, params: Value) { + run(app, ctx, "print.set", params); +} + +fn heading(ui: &mut Ui, t: &Tokens, text: &str) { + ui.add_space(8.0); + ui.label(egui::RichText::new(crate::i18n::tr(text)).font(t.semibold(12.0)).color(t.text)); + ui.add_space(2.0); +} + +fn row<R>(ui: &mut Ui, t: &Tokens, label: &str, add: impl FnOnce(&mut Ui) -> R) -> R { + ui.horizontal(|ui| { + ui.allocate_ui_with_layout(vec2(86.0, 22.0), egui::Layout::left_to_right(egui::Align::Center), |ui| { + ui.label(egui::RichText::new(crate::i18n::tr(label)).color(t.text_label)); + }); + add(ui) + }) + .inner +} + +fn decimals(unit: Unit) -> usize { + match unit { + Unit::Inches | Unit::Centimeters => 2, + Unit::Millimeters => 1, + Unit::Points => 0, + } +} + +fn fmt_len(inches: f64, unit: Unit) -> String { + format!("{:.*} {}", decimals(unit), unit.from_inches(inches), unit.label()) +} + +/// A length field in the ruler's unit; the new value in inches when it changed. +fn length(ui: &mut Ui, id: &str, inches: f64, unit: Unit, min_in: f64, max_in: f64) -> Option<f64> { + let mut v = unit.from_inches(inches); + let r = ui.add( + egui::DragValue::new(&mut v) + .range(unit.from_inches(min_in)..=unit.from_inches(max_in)) + .speed(unit.per_inch() * 0.01) + .max_decimals(decimals(unit)) + .suffix(format!(" {}", unit.label())), + ); + register(ui.ctx(), format!("drag:{id}"), r.rect); + r.changed().then(|| unit.to_inches(v)) +} + +fn check(ui: &mut Ui, id: &str, label: &str, value: bool) -> Option<bool> { + let mut v = value; + let r = ui.checkbox(&mut v, crate::i18n::tr(label)); + register(ui.ctx(), format!("check:{id}"), r.rect); + r.changed().then_some(v) +} + +fn choice<V: PartialEq + Copy>(ui: &mut Ui, id: &str, options: &[(V, &str)], current: V) -> Option<V> { + let mut out = None; + ui.spacing_mut().item_spacing.x = 3.0; + for (i, (v, l)) in options.iter().enumerate() { + if text_button(ui, &format!("{id}-{i}"), l, current == *v).clicked() && current != *v { + out = Some(*v); + } + } + out +} + +fn colour(ui: &mut Ui, id: &str, rgb: [u8; 3]) -> Option<[u8; 3]> { + let mut c = rgb; + let r = ui.color_edit_button_srgb(&mut c); + register(ui.ctx(), format!("color:{id}"), r.rect); + (c != rgb).then_some(c) +} + +fn paper_label(name: &str) -> String { + match name { + "a3" | "a4" | "a5" => name.to_uppercase(), + "letter" => "Letter".into(), + "legal" => "Legal".into(), + "tabloid" => "Tabloid".into(), + "custom" => "Custom".into(), + other => other.replace('x', " × ") + " in", + } +} + +/// The dialog's body: the settings column and the page preview side by side. +pub fn body(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens) { + let ctx = ui.ctx().clone(); + let screen = ctx.content_rect(); + let width = (screen.width() - 80.0).clamp(640.0, 1180.0); + let height = (screen.height() - 190.0).clamp(280.0, 780.0); + let mut pu = std::mem::take(&mut app.ui.print); + let st = app.session.print.clone(); + // (no `id`/`ids` given: the selection, which never fails to parse) + let ids = app.session.targets(&json!({})).unwrap_or_default(); + // the page shown stays inside the print + let pages = st.layout.page_count(ids.len()); + pu.page = pu.page.min(pages.saturating_sub(1)); + if pu.selected.is_some_and(|c| st.layout.cell(c).is_none_or(|c| c.page as usize != pu.page)) && pu.drag.is_none() { + pu.selected = pu.selected.filter(|c| st.layout.cell(*c).is_some()); + } + ui.horizontal_top(|ui| { + ui.allocate_ui_with_layout(vec2(SETTINGS_W, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(SETTINGS_W, height)); + egui::ScrollArea::vertical().id_salt("print-settings").max_height(height).auto_shrink([false, false]).show(ui, |ui| { + ui.set_width(SETTINGS_W - 14.0); + settings(app, ui, t, &mut pu, &st, ids.len()); + }); + }); + // (a `separator` would take the dialog's whole free height) + let (line, _) = ui.allocate_exact_size(vec2(1.0, height), Sense::hover()); + ui.painter().line_segment([line.center_top(), line.center_bottom()], Stroke::new(1.0, t.divider)); + let w = (width - SETTINGS_W - 24.0).max(300.0); + ui.allocate_ui_with_layout(vec2(w, height), egui::Layout::top_down(egui::Align::Min), |ui| { + ui.set_min_size(vec2(w, height)); + preview(app, ui, t, &mut pu, &st, &ids, pages); + }); + }); + app.ui.print = pu; +} + +fn settings(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, photos: usize) { + let ctx = ui.ctx().clone(); + let l = &st.layout; + let unit = st.guides.units; + ui.spacing_mut().item_spacing.y = 5.0; + ui.label(egui::RichText::new(crate::i18n::tr_format!("{n} photo{}", if photos == 1 { "" } else { "s" }, n = photos)).color(t.text_dim)); + + heading(ui, t, "Templates"); + ui.horizontal(|ui| { + crate::text_field::TextField::singleline("field:printTemplateName", &mut pu.template_name) + .hint(crate::i18n::tr("Template name")) + .width(140.0) + .show(ui); + if text_button(ui, "printSaveTemplate", "Save", false).clicked() && !pu.template_name.trim().is_empty() { + run(app, &ctx, "print.template.save", json!({"name": pu.template_name})); + } + }); + let names: Vec<String> = app.session.print_templates.iter().map(|t| t.name.clone()).collect(); + for (i, name) in names.iter().enumerate() { + ui.horizontal(|ui| { + ui.label(egui::RichText::new(name).color(t.text_label)); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if text_button(ui, &format!("printTemplateDelete-{i}"), "Delete", false).clicked() { + run(app, &ctx, "print.template.delete", json!({"name": name})); + } + if text_button(ui, &format!("printTemplateApply-{i}"), "Apply", false).clicked() { + run(app, &ctx, "print.template.apply", json!({"name": name})); + pu.page = 0; + pu.selected = None; + } + }); + }); + } + + heading(ui, t, "Layout Style"); + let styles = + [(Style::SingleImage, "Single"), (Style::ContactSheet, "Contact"), (Style::PicturePackage, "Package"), (Style::CustomPackage, "Custom")]; + ui.horizontal_wrapped(|ui| { + if let Some(s) = choice(ui, "printStyle", &styles, l.style) { + patch(app, &ctx, json!({"layout": {"style": s}})); + pu.page = 0; + } + }); + + heading(ui, t, "Image Settings"); + let im = &st.image; + if let Some(v) = check(ui, "printZoomFill", "Zoom to Fill Frame", im.zoom_to_fill) { + patch(app, &ctx, json!({"image": {"zoomToFill": v}})); + } + if let Some(v) = check(ui, "printRotateFit", "Rotate to Fit", im.rotate_to_fit) { + patch(app, &ctx, json!({"image": {"rotateToFit": v}})); + } + if l.style.is_package() { + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printBorder", "Photo Border", im.border_enabled) { + patch(app, &ctx, json!({"image": {"borderEnabled": v}})); + } + if let Some(w) = length(ui, "printBorderWidth", im.border_width, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"image": {"borderWidth": w}})); + } + if let Some(c) = colour(ui, "printBorderColor", im.border_color) { + patch(app, &ctx, json!({"image": {"borderColor": c}})); + } + }); + } + ui.horizontal(|ui| { + if let Some(v) = check(ui, "printStroke", "Inner Stroke", im.stroke_enabled) { + patch(app, &ctx, json!({"image": {"strokeEnabled": v}})); + } + let mut pt = im.stroke_width_pt; + let r = ui.add(egui::DragValue::new(&mut pt).range(0.0..=36.0).speed(0.05).max_decimals(2).suffix(" pt")); + register(ui.ctx(), "drag:printStrokeWidth", r.rect); + if r.changed() { + patch(app, &ctx, json!({"image": {"strokeWidthPt": pt}})); + } + if let Some(c) = colour(ui, "printStrokeColor", im.stroke_color) { + patch(app, &ctx, json!({"image": {"strokeColor": c}})); + } + }); + + heading(ui, t, "Layout"); + match l.style { + Style::SingleImage => { + ui.label(egui::RichText::new(crate::i18n::tr("One photo fills each page inside the margins.")).color(t.text_dim)); + } + Style::ContactSheet => { + let g = &l.grid; + row(ui, t, "Grid", |ui| { + let (mut c, mut r) = (g.columns, g.rows); + let a = ui.add(egui::DragValue::new(&mut c).range(1..=layout::MAX_GRID).suffix(" cols")); + register(ui.ctx(), "drag:printColumns", a.rect); + let b = ui.add(egui::DragValue::new(&mut r).range(1..=layout::MAX_GRID).suffix(" rows")); + register(ui.ctx(), "drag:printRows", b.rect); + if a.changed() || b.changed() { + patch(app, &ctx, json!({"layout": {"grid": {"columns": c, "rows": r}}})); + } + }); + row(ui, t, "Spacing", |ui| { + if let Some(v) = length(ui, "printSpacingX", g.spacing_x, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingX": v}}})); + } + if let Some(v) = length(ui, "printSpacingY", g.spacing_y, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"grid": {"spacingY": v}}})); + } + }); + if let Some(v) = check(ui, "printKeepSquare", "Keep Square", g.keep_square) { + patch(app, &ctx, json!({"layout": {"grid": {"keepSquare": v}}})); + } + } + Style::PicturePackage | Style::CustomPackage => { + ui.label(egui::RichText::new(crate::i18n::tr("Add a cell")).color(t.text_label)); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(3.0, 3.0); + for (i, (w, h)) in CELL_SIZES.iter().enumerate() { + if text_button(ui, &format!("printAddCell-{i}"), &format!("{} × {}", trim(*w), trim(*h)), false).clicked() { + add_cell(app, &ctx, pu, *w, *h); + } + } + }); + let (mut w, mut h) = pu.new_cell.unwrap_or((3.0, 2.0)); + row(ui, t, "Custom size", |ui| { + if let Some(v) = length(ui, "printNewCellW", w, unit, layout::MIN_CELL_IN, 30.0) { + w = v; + } + if let Some(v) = length(ui, "printNewCellH", h, unit, layout::MIN_CELL_IN, 30.0) { + h = v; + } + }); + pu.new_cell = Some((w, h)); + ui.horizontal(|ui| { + if text_button(ui, "printAddCustom", "Add Cell", false).clicked() { + add_cell(app, &ctx, pu, w, h); + } + if l.style == Style::PicturePackage && text_button(ui, "printAutoLayout", "Auto Layout", false).clicked() { + run(app, &ctx, "print.layout.autoLayout", json!({})); + pu.page = 0; + } + }); + ui.horizontal(|ui| { + if text_button(ui, "printNewPage", "New Page", false).clicked() + && let Some(r) = run(app, &ctx, "print.layout.newPage", json!({})) + { + pu.page = r["page"].as_u64().unwrap_or(0) as usize; + } + if text_button(ui, "printClear", "Clear Layout", false).clicked() { + run(app, &ctx, "print.layout.clear", json!({})); + pu.page = 0; + pu.selected = None; + } + }); + row(ui, t, "Gap", |ui| { + if let Some(v) = length(ui, "printGap", l.spacing, unit, 0.0, 2.0) { + patch(app, &ctx, json!({"layout": {"spacing": v}})); + } + }); + } + } + + heading(ui, t, "Rulers, Grid & Guides"); + let g = &st.guides; + if let Some(v) = check(ui, "printRulers", "Show Rulers", g.show_rulers) { + patch(app, &ctx, json!({"guides": {"showRulers": v}})); + } + row(ui, t, "Units", |ui| { + let units = [(Unit::Inches, "in"), (Unit::Centimeters, "cm"), (Unit::Millimeters, "mm"), (Unit::Points, "pt")]; + if let Some(u) = choice(ui, "printUnits", &units, g.units) { + patch(app, &ctx, json!({"guides": {"units": u}})); + } + }); + row(ui, t, "Snap", |ui| { + if let Some(s) = choice(ui, "printSnap", &[(Snap::Off, "Off"), (Snap::Cells, "Cells"), (Snap::Grid, "Grid")], g.snap) { + patch(app, &ctx, json!({"guides": {"snap": s}})); + } + }); + row(ui, t, "Grid size", |ui| { + if let Some(v) = length(ui, "printGridSize", g.grid_size, unit, 0.05, 10.0) { + patch(app, &ctx, json!({"guides": {"gridSize": v}})); + } + }); + for (id, label, on, key) in [ + ("printShowMargins", "Page Margins", g.show_margins, "showMargins"), + ("printShowBleed", "Page Bleed", g.show_bleed, "showBleed"), + ("printShowGrid", "Page Grid", g.show_page_grid, "showPageGrid"), + ("printShowCells", "Image Cells", g.show_cells, "showCells"), + ("printShowDims", "Dimensions", g.show_dimensions, "showDimensions"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"guides": {(key): v}})); + } + } + + heading(ui, t, "Page Setup"); + row(ui, t, "Paper", |ui| { + let current = if l.page.paper.eq_ignore_ascii_case("custom") { "custom".to_string() } else { l.page.paper.to_ascii_lowercase() }; + let c = egui::ComboBox::from_id_salt("printPaper").selected_text(paper_label(&current)).show_ui(ui, |ui| { + for name in layout::paper_names().chain(std::iter::once("custom")) { + if ui.selectable_label(current == name, paper_label(name)).clicked() { + patch(app, &ctx, json!({"layout": {"page": {"paper": name}}})); + } + } + }); + register(ui.ctx(), "combo:printPaper", c.response.rect); + }); + if l.page.paper.eq_ignore_ascii_case("custom") { + row(ui, t, "Size", |ui| { + if let Some(v) = length(ui, "printCustomW", l.page.custom_width, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customWidth": v}}})); + } + if let Some(v) = length(ui, "printCustomH", l.page.custom_height, unit, layout::MIN_PAGE_IN, layout::MAX_PAGE_IN) { + patch(app, &ctx, json!({"layout": {"page": {"customHeight": v}}})); + } + }); + } + row(ui, t, "Orientation", |ui| { + let o = [(layout::Orientation::Portrait, "Portrait"), (layout::Orientation::Landscape, "Landscape")]; + if let Some(v) = choice(ui, "printOrientation", &o, l.page.orientation) { + patch(app, &ctx, json!({"layout": {"page": {"orientation": v}}})); + } + }); + let m = &l.page.margins; + for (label, id, key, v) in [ + ("Left", "printMarginLeft", "left", m.left), + ("Right", "printMarginRight", "right", m.right), + ("Top", "printMarginTop", "top", m.top), + ("Bottom", "printMarginBottom", "bottom", m.bottom), + ] { + row(ui, t, label, |ui| { + if let Some(v) = length(ui, id, v, unit, 0.0, layout::MAX_MARGIN_IN) { + patch(app, &ctx, json!({"layout": {"page": {"margins": {(key): v}}}})); + } + }); + } + + heading(ui, t, "Page"); + row(ui, t, "Background", |ui| { + if let Some(c) = colour(ui, "printBackground", st.page.background) { + patch(app, &ctx, json!({"page": {"background": c}})); + } + }); + if compose::has_page_options(l.style) { + ui.label(egui::RichText::new(crate::i18n::tr("Page Options")).color(t.text_label)); + let p = &st.page; + for (id, label, on, key) in [ + ("printPageNumbers", "Page Numbers", p.page_numbers, "pageNumbers"), + ("printPageInfo", "Page Info", p.page_info, "pageInfo"), + ("printCropMarks", "Crop Marks", p.crop_marks, "cropMarks"), + ] { + if let Some(v) = check(ui, id, label, on) { + patch(app, &ctx, json!({"page": {(key): v}})); + } + } + } + + heading(ui, t, "Print Job"); + row(ui, t, "Resolution", |ui| { + let mut ppi = st.job.ppi; + let r = ui.add(egui::DragValue::new(&mut ppi).range(72.0..=720.0).speed(1.0).max_decimals(0).suffix(" ppi")); + register(ui.ctx(), "drag:printPpi", r.rect); + if r.changed() { + patch(app, &ctx, json!({"job": {"ppi": ppi}})); + } + }); + print_job_color(app, ui, t, &st.job.color); +} + +/// Send one colour setting of the print job (the rest stay as they are). +fn patch_color(app: &mut LightcraftApp, ctx: &egui::Context, key: &str, value: Value) { + patch(app, ctx, json!({"job": {"color": {(key): value}}})); +} + +fn to_value<V: serde::Serialize>(v: V) -> Value { + serde_json::to_value(v).unwrap_or(Value::Null) +} + +/// The Print Job panel's colour management, print adjustment, print sharpening and depth. +fn print_job_color(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, color: &PrintColorSettings) { + let ctx = ui.ctx().clone(); + let managed = |space| PrintProfile::Managed { space }; + let shown = match &color.profile { + PrintProfile::Managed { space: ManagedSpace::Srgb } => crate::i18n::tr("Managed by Printer (sRGB)").to_string(), + PrintProfile::Managed { space: ManagedSpace::AdobeRgb } => crate::i18n::tr("Managed by Printer (Adobe RGB)").to_string(), + PrintProfile::Icc { path } => path.file_stem().map_or_else(|| path.display().to_string(), |n| n.to_string_lossy().into_owned()), + }; + let mut pick = false; + row(ui, t, "Profile", |ui| { + let c = egui::ComboBox::from_id_salt("print-profile").width(190.0).selected_text(shown.clone()).show_ui(ui, |ui| { + for (space, label) in [(ManagedSpace::Srgb, "Managed by Printer (sRGB)"), (ManagedSpace::AdobeRgb, "Managed by Printer (Adobe RGB)")] { + let r = ui.selectable_label(color.profile == managed(space), crate::i18n::tr(label)); + register(ui.ctx(), format!("item:printProfile-{}", to_value(space).as_str().unwrap_or_default()), r.rect); + if r.clicked() && color.profile != managed(space) { + patch_color(app, &ctx, "profile", to_value(managed(space))); + } + } + if let PrintProfile::Icc { .. } = &color.profile { + let _ = ui.selectable_label(true, shown.as_str()); + } + if app.services.pick_display_profile.is_some() { + let r = ui.selectable_label(false, crate::i18n::tr("Other…")); + register(ui.ctx(), "item:printProfile-other", r.rect); + pick |= r.clicked(); + } + }); + register(ui.ctx(), "combo:printProfile", c.response.rect); + }); + if pick && let Some(path) = app.services.pick_display_profile.as_mut().and_then(|pick| pick().into_iter().next()) { + patch_color(app, &ctx, "profile", json!({"kind": "icc", "path": path})); + } + row(ui, t, "Intent", |ui| { + if let Some(v) = choice(ui, "printIntent", &[(PrintIntent::Perceptual, "Perceptual"), (PrintIntent::Relative, "Relative")], color.intent) { + patch_color(app, &ctx, "intent", to_value(v)); + } + }); + ui.add_enabled_ui(color.intent == PrintIntent::Relative, |ui| { + if let Some(v) = check(ui, "printBpc", "Black Point Compensation", color.black_point_compensation) { + patch_color(app, &ctx, "blackPointCompensation", json!(v)); + } + }); + for (key, label, value) in [("brightness", "Brightness", color.brightness), ("contrast", "Contrast", color.contrast)] { + row(ui, t, label, |ui| { + let mut v = value; + let r = ui.add(egui::DragValue::new(&mut v).range(-100.0..=100.0).speed(0.5).max_decimals(0)); + register(ui.ctx(), format!("drag:print{label}"), r.rect); + if r.changed() { + patch_color(app, &ctx, key, json!(v)); + } + }); + } + row(ui, t, "Sharpening", |ui| { + let options = [(PrintSharpen::Off, "Off"), (PrintSharpen::Low, "Low"), (PrintSharpen::Standard, "Standard"), (PrintSharpen::High, "High")]; + if let Some(v) = choice(ui, "printSharpen", &options, color.sharpen) { + patch_color(app, &ctx, "sharpen", to_value(v)); + } + }); + ui.add_enabled_ui(color.sharpen != PrintSharpen::Off, |ui| { + row(ui, t, "Media", |ui| { + if let Some(v) = choice(ui, "printMedia", &[(PrintMedia::Matte, "Matte"), (PrintMedia::Glossy, "Glossy")], color.media) { + patch_color(app, &ctx, "media", to_value(v)); + } + }); + }); + if let Some(v) = check(ui, "print16bit", "16 bits per channel", color.sixteen_bit) { + patch_color(app, &ctx, "sixteenBit", json!(v)); + } + let bits = if color.sixteen_bit { "16-bit" } else { "8-bit" }; + let summary = format!("PDF · {shown} · {bits} · {}", crate::i18n::tr("current edits")); + ui.label(egui::RichText::new(summary).color(t.text_dim)); +} + +fn trim(v: f64) -> String { + let s = format!("{v:.2}"); + s.trim_end_matches('0').trim_end_matches('.').to_string() +} + +fn add_cell(app: &mut LightcraftApp, ctx: &egui::Context, pu: &mut PrintUi, w: f64, h: f64) { + if let Some(r) = run(app, ctx, "print.cell.add", json!({"width": w, "height": h})) { + pu.selected = r["id"].as_u64().and_then(|v| u32::try_from(v).ok()); + pu.page = r["cell"]["page"].as_u64().unwrap_or(0) as usize; + } +} + +// ---- preview ------------------------------------------------------------------------------- + +/// Screen geometry of the page on show. +struct Sheet { + page: Rect, + /// Screen points per inch. + scale: f32, +} + +impl Sheet { + fn rect(&self, r: &layout::Rect) -> Rect { + Rect::from_min_size(self.page.min + vec2(r.x as f32, r.y as f32) * self.scale, vec2(r.w as f32, r.h as f32) * self.scale) + } +} + +fn preview(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings, ids: &[PhotoId], pages: usize) { + let ctx = ui.ctx().clone(); + // room for the page buttons and, for a package, the selected cell's row (which may wrap) + let footer = if st.layout.style.is_package() { 104.0 } else { 44.0 }; + let size = vec2(ui.available_width(), (ui.available_height() - footer).max(120.0)); + let (area, _) = ui.allocate_exact_size(size, Sense::hover()); + let painter = ui.painter_at(area); + painter.rect_filled(area, 4.0, t.canvas); + let Ok((pw, ph)) = st.layout.page_size() else { + painter.text(area.center(), Align2::CENTER_CENTER, crate::i18n::tr("This page setup is not valid"), t.font(13.0), t.caution); + return; + }; + let g = &st.guides; + let ruler = if g.show_rulers { 22.0 } else { 0.0 }; + let inner = Rect::from_min_max(area.min + vec2(ruler, ruler), area.max).shrink(14.0); + let scale = ((inner.width() as f64 / pw).min(inner.height() as f64 / ph) as f32).max(1.0); + let sheet = Sheet { page: Rect::from_center_size(inner.center(), vec2(pw as f32, ph as f32) * scale), scale }; + register(&ctx, "print:page", sheet.page); + + painter.rect_filled(sheet.page.translate(vec2(2.0, 3.0)), 0.0, Color32::from_black_alpha(70)); + let bg = st.page.background; + painter.rect_filled(sheet.page, 0.0, Color32::from_rgb(bg[0], bg[1], bg[2])); + let guide = Stroke::new(1.0, Color32::from_rgb(90, 160, 240)); + if g.show_rulers { + rulers(&painter, t, &sheet, area, pw, ph, g.units); + } + let area_in = st.layout.printable().ok(); + if g.show_bleed { + let b = 0.125; + dashed(&painter, sheet.page.expand(b as f32 * scale), Stroke::new(1.0, t.caution)); + } + if g.show_page_grid + && let Some(a) = area_in + { + let faint = Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 120, 120, 70)); + let step = g.grid_size.max(0.05); + let mut x = 0.0; + while a.x + x <= a.right() + 1e-9 && x < 200.0 { + let sx = sheet.page.left() + (a.x + x) as f32 * scale; + painter.line_segment([pos2(sx, sheet.page.top()), pos2(sx, sheet.page.bottom())], faint); + x += step; + } + let mut y = 0.0; + while a.y + y <= a.bottom() + 1e-9 && y < 200.0 { + let sy = sheet.page.top() + (a.y + y) as f32 * scale; + painter.line_segment([pos2(sheet.page.left(), sy), pos2(sheet.page.right(), sy)], faint); + y += step; + } + } + if g.show_margins + && let Some(a) = area_in + { + dashed(&painter, sheet.rect(&layout::Rect::new(a.x, a.y, a.w, a.h)), guide); + } + + let package = st.layout.style.is_package(); + if package { + package_cells(app, ui, &painter, t, pu, st, ids, &sheet); + } else { + let plan = compose::plan_cells(&st.layout, ids).ok(); + let cells: Vec<(layout::Rect, Option<PhotoId>)> = match plan.as_ref().and_then(|p| p.get(pu.page)) { + Some(page) => page.iter().map(|(r, id)| (*r, Some(*id))).collect(), + None => st.layout.grid_rects().unwrap_or_default().into_iter().map(|r| (r, None)).collect(), + }; + let mut shown = Vec::new(); + for (i, (r, photo)) in cells.iter().enumerate() { + let rect = sheet.rect(r); + register(&ctx, format!("print:gridcell:{i}"), rect); + shown.extend(paint_cell(app, &painter, t, st, rect, *photo, &sheet, r)); + } + page_options(&painter, st, &sheet, &shown, pu.page + 1); + } + + // footer: page navigation and the selected cell + ui.horizontal(|ui| { + if text_button(ui, "printPagePrev", "◀", false).clicked() && pu.page > 0 { + pu.page -= 1; + pu.selected = None; + } + let label = ui.label(egui::RichText::new(format!("{} {} / {}", crate::i18n::tr("Page"), pu.page + 1, pages)).color(t.text)); + register(ui.ctx(), "print:pageLabel", label.rect); + if text_button(ui, "printPageNext", "▶", false).clicked() && pu.page + 1 < pages { + pu.page += 1; + pu.selected = None; + } + ui.label(egui::RichText::new(fmt_len(pw, g.units) + " × " + &fmt_len(ph, g.units)).color(t.text_dim)); + if package { + ui.label(egui::RichText::new(format!("{} {}", st.layout.cells.len(), crate::i18n::tr("cells"))).color(t.text_dim)); + if st.layout.has_overlaps() { + ui.label(egui::RichText::new(crate::i18n::tr("cells overlap")).color(t.caution)); + } + } + }); + if package { + inspector(app, ui, t, pu, st); + } +} + +fn press_origin(ui: &Ui, resp: &egui::Response) -> Pos2 { + ui.input(|i| i.pointer.press_origin()).or_else(|| resp.interact_pointer_pos()).unwrap_or_default() +} + +/// Draws and handles every cell of a package page, back to front. +#[allow(clippy::too_many_arguments)] +fn package_cells( + app: &mut LightcraftApp, + ui: &mut Ui, + painter: &egui::Painter, + t: &Tokens, + pu: &mut PrintUi, + st: &PrintSettings, + ids: &[PhotoId], + sheet: &Sheet, +) { + let ctx = ui.ctx().clone(); + let first = ids.first().copied(); + let cells: Vec<layout::Cell> = st.layout.cells_on_page(pu.page as u32).cloned().collect(); + for c in &cells { + let photo = if st.layout.style == Style::CustomPackage { c.photo.map(PhotoId).or(first) } else { first }; + let rect = sheet.rect(&c.rect); + paint_cell(app, painter, t, st, rect, photo, sheet, &c.rect); + let resp = ui.interact(rect, egui::Id::new(("print-cell", c.id)), Sense::click_and_drag()); + register(&ctx, format!("print:cell:{}", c.id), rect); + if resp.clicked() || resp.drag_started() { + pu.selected = Some(c.id); + } + if resp.drag_started() { + pu.drag = Some(Drag { cell: c.id, origin: c.rect, resize: false, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == c.id && !d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (x, y) = (d.origin.x + f64::from(d.total.x / sheet.scale), d.origin.y + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run(app, &ctx, "print.cell.move", json!({"id": c.id, "x": x, "y": y, "snap": true, "threshold": threshold})); + } + if resp.drag_stopped() { + pu.drag = None; + } + let id = c.id; + resp.context_menu(|ui| { + for (how, label) in + [("toFront", "Bring to Front"), ("forward", "Bring Forward"), ("backward", "Send Backward"), ("toBack", "Send to Back")] + { + if ui.button(crate::i18n::tr(label)).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + ui.close(); + } + } + ui.separator(); + if ui.button(crate::i18n::tr("Delete Cell")).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + ui.close(); + } + }); + } + // the selected cell's outline and resize handle go over everything + let Some(sel) = pu.selected.and_then(|s| cells.iter().find(|c| c.id == s)) else { return }; + let rect = sheet.rect(&sel.rect); + painter.rect_stroke(rect, 0.0, Stroke::new(2.0, t.accent), StrokeKind::Outside); + let handle = Rect::from_center_size(rect.right_bottom(), Vec2::splat(HANDLE)); + painter.rect_filled(handle, 2.0, t.accent); + let resp = ui.interact(handle, egui::Id::new(("print-resize", sel.id)), Sense::drag()); + register(&ctx, format!("print:resize:{}", sel.id), handle); + if resp.drag_started() { + pu.drag = Some(Drag { cell: sel.id, origin: sel.rect, resize: true, press: press_origin(ui, &resp), total: Vec2::ZERO }); + } + if resp.dragged() + && let Some(d) = pu.drag.as_mut().filter(|d| d.cell == sel.id && d.resize) + { + d.total = resp.interact_pointer_pos().map_or(d.total, |p| p - d.press); + let (w, h) = (d.origin.w + f64::from(d.total.x / sheet.scale), d.origin.h + f64::from(d.total.y / sheet.scale)); + let threshold = f64::from(SNAP_PX / sheet.scale); + run( + app, + &ctx, + "print.cell.resize", + json!({"id": sel.id, "width": w.max(layout::MIN_CELL_IN), "height": h.max(layout::MIN_CELL_IN), "snap": true, "threshold": threshold}), + ); + } + if resp.drag_stopped() { + pu.drag = None; + } +} + +/// The selected cell's numbers, its place in the stack and its photo. +fn inspector(app: &mut LightcraftApp, ui: &mut Ui, t: &Tokens, pu: &mut PrintUi, st: &PrintSettings) { + let ctx = ui.ctx().clone(); + let Some(cell) = pu.selected.and_then(|id| st.layout.cell(id)) else { + ui.label(egui::RichText::new(crate::i18n::tr("Click a cell to move, resize or arrange it")).color(t.text_dim)); + return; + }; + let (id, r, unit) = (cell.id, cell.rect, st.guides.units); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; + if let Some(v) = length(ui, "printCellX", r.x, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": v, "y": r.y})); + } + if let Some(v) = length(ui, "printCellY", r.y, unit, 0.0, 60.0) { + run(app, &ctx, "print.cell.move", json!({"id": id, "x": r.x, "y": v})); + } + if let Some(v) = length(ui, "printCellW", r.w, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": v, "height": r.h})); + } + if let Some(v) = length(ui, "printCellH", r.h, unit, layout::MIN_CELL_IN, 60.0) { + run(app, &ctx, "print.cell.resize", json!({"id": id, "width": r.w, "height": v})); + } + for (i, (how, label)) in [("toBack", "Back"), ("backward", "Backward"), ("forward", "Forward"), ("toFront", "Front")].into_iter().enumerate() + { + if text_button(ui, &format!("printStack-{i}"), label, false).clicked() { + run(app, &ctx, "print.cell.stack", json!({"id": id, "order": how})); + } + } + if st.layout.style == Style::CustomPackage { + if text_button(ui, "printCellPhoto", "Use Active Photo", false).clicked() { + let active = app.session.active().map(|p| p.0); + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": active})); + } + if cell.photo.is_some() && text_button(ui, "printCellPhotoClear", "Clear Photo", false).clicked() { + run(app, &ctx, "print.cell.photo", json!({"id": id, "photo": null})); + } + } + if text_button(ui, "printCellDelete", "Delete", false).clicked() { + run(app, &ctx, "print.cell.delete", json!({"id": id})); + pu.selected = None; + } + }); +} + +/// One cell on screen: the photo as it will print (fit or fill, turned, border, stroke). +#[allow(clippy::too_many_arguments)] +fn paint_cell( + app: &mut LightcraftApp, + painter: &egui::Painter, + t: &Tokens, + st: &PrintSettings, + cell: Rect, + photo: Option<PhotoId>, + sheet: &Sheet, + inches: &layout::Rect, +) -> Option<Rect> { + let im = &st.image; + if st.guides.show_cells { + painter.rect_stroke(cell, 0.0, Stroke::new(1.0, Color32::from_rgba_unmultiplied(120, 150, 190, 150)), StrokeKind::Inside); + } + let border = if st.layout.style.is_package() && im.border_enabled { + (im.border_width as f32 * sheet.scale).min(cell.width().min(cell.height()).max(2.0) / 2.0 - 1.0).max(0.0) + } else { + 0.0 + }; + let avail = cell.shrink(border); + if avail.width() < 1.0 || avail.height() < 1.0 { + return None; + } + // the photo's shape: its cropped size, or 3:2 for a placeholder + let (nw, nh) = photo.and_then(|id| app.session.catalog.photo(id)).map_or((3.0, 2.0), |p| { + let (w, h) = lightcraft_engine::export::output_size(p, &ExportOptions::default()); + (w as f64, h as f64) + }); + let scale = 8.0; // sub-point resolution for the rounding in `place` + let aw = (f64::from(avail.width()) * scale).round() as usize; + let ah = (f64::from(avail.height()) * scale).round() as usize; + let p = place(nw, nh, aw, ah, im.zoom_to_fill, im.rotate_to_fit)?; + let shown = Rect::from_center_size(avail.center(), vec2(p.w as f32, p.h as f32) / scale as f32); + let visible = shown.intersect(avail); + if border > 0.0 { + let frame = visible.expand(border); + let c = im.border_color; + painter.rect_filled(frame.intersect(cell), 0.0, Color32::from_rgb(c[0], c[1], c[2])); + } + let clip = painter.with_clip_rect(avail.intersect(painter.clip_rect())); + let tex = photo.and_then(|id| { + // Use the library's thumbnail when there is one. A thumbnail is kept at one size, so asking + // for another size here every frame made the preview and the grid behind the dialog + // re-render each other's thumbnail for as long as the dialog stayed open. + if app.renderer.thumb(id).is_none() { + let px = (shown.width().max(shown.height()) * 2.0).clamp(64.0, 1024.0) as usize; + crate::panels::grid::request_thumb(app, id, px, 10); + } + app.renderer.thumb(id).map(|tex| tex.tex.id()) + }); + match tex { + Some(id) => { + let mut mesh = egui::Mesh::with_texture(id); + // an unturned quad maps the texture straight; a turned one (a quarter clockwise) takes + // the left edge to the top + let uv: [Pos2; 4] = if p.rotated { + [pos2(0.0, 1.0), pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0)] + } else { + [pos2(0.0, 0.0), pos2(1.0, 0.0), pos2(1.0, 1.0), pos2(0.0, 1.0)] + }; + let corners = [shown.left_top(), shown.right_top(), shown.right_bottom(), shown.left_bottom()]; + for (pos, uv) in corners.into_iter().zip(uv) { + mesh.vertices.push(egui::epaint::Vertex { pos, uv, color: Color32::WHITE }); + } + mesh.indices.extend([0, 1, 2, 0, 2, 3]); + clip.add(egui::Shape::mesh(mesh)); + } + None => { + clip.rect_filled(shown, 0.0, if photo.is_some() { Color32::from_gray(52) } else { Color32::from_gray(200) }); + } + } + if im.stroke_enabled && im.stroke_width_pt > 0.0 { + let w = (im.stroke_width_pt as f32 / 72.0 * sheet.scale).max(1.0).min(visible.width().min(visible.height()) / 2.0); + let c = im.stroke_color; + painter.rect_stroke(visible, 0.0, Stroke::new(w, Color32::from_rgb(c[0], c[1], c[2])), StrokeKind::Inside); + } + if st.guides.show_dimensions { + let text = format!("{} × {}", fmt_len(inches.w, st.guides.units), fmt_len(inches.h, st.guides.units)); + let galley = painter.layout_no_wrap(text, t.font(10.0), Color32::WHITE); + let at = pos2(cell.center().x - galley.size().x / 2.0, cell.bottom() - galley.size().y - 3.0); + painter.rect_filled(Rect::from_min_size(at, galley.size()).expand(2.0), 2.0, Color32::from_black_alpha(160)); + painter.galley(at, galley, Color32::WHITE); + } + Some(visible) +} + +/// The Page Options as the print draws them ([`compose`]): crop marks at the corners of each +/// photo, the page number at the bottom right and the print settings at the bottom left. +/// Packages draw none of them, as the print doesn't ([`compose::has_page_options`]). +fn page_options(painter: &egui::Painter, st: &PrintSettings, sheet: &Sheet, photos: &[Rect], number: usize) { + if !compose::has_page_options(st.layout.style) { + return; + } + let p = &st.page; + let ink = compose::mark_ink(p.background); + let ink = Color32::from_rgb(ink[0], ink[1], ink[2]); + if p.crop_marks { + let (len, gap) = (compose::CROP_MARK_IN as f32 * sheet.scale, compose::CROP_MARK_GAP_IN as f32 * sheet.scale); + let stroke = Stroke::new(1.0, ink); + for r in photos { + for (corner, dx, dy) in + [(r.left_top(), -1.0, -1.0), (r.right_top(), 1.0, -1.0), (r.left_bottom(), -1.0, 1.0), (r.right_bottom(), 1.0, 1.0)] + { + painter.line_segment([corner + vec2(dx * gap, 0.0), corner + vec2(dx * (gap + len), 0.0)], stroke); + painter.line_segment([corner + vec2(0.0, dy * gap), corner + vec2(0.0, dy * (gap + len))], stroke); + } + } + } + let Ok(area) = st.layout.printable() else { return }; + let area = sheet.rect(&area); + let font = egui::FontId::proportional((compose::PAGE_TEXT_PT as f32 / 72.0 * sheet.scale).max(4.0)); + let y = (area.bottom() + sheet.page.bottom()) / 2.0; + if p.page_numbers { + painter.text(pos2(area.right(), y), Align2::RIGHT_CENTER, number.to_string(), font.clone(), ink); + } + if p.page_info { + painter.text(pos2(area.left(), y), Align2::LEFT_CENTER, compose::page_info_text(st), font, ink); + } +} + +fn dashed(painter: &egui::Painter, r: Rect, stroke: Stroke) { + let pts = [r.left_top(), r.right_top(), r.right_bottom(), r.left_bottom(), r.left_top()]; + painter.extend(egui::Shape::dashed_line(&pts, stroke, 5.0, 4.0)); +} + +/// Rulers along the top and left of the preview, in `unit`, zero at the page's corner. +fn rulers(painter: &egui::Painter, t: &Tokens, sheet: &Sheet, area: Rect, pw: f64, ph: f64, unit: Unit) { + let (major, minor) = match unit { + Unit::Inches => (1.0, 0.25), + Unit::Centimeters => (1.0, 0.5), + Unit::Millimeters => (10.0, 5.0), + Unit::Points => (72.0, 18.0), + }; + let tick = Stroke::new(1.0, t.text_dim); + let ink = t.text_dim; + let top = Rect::from_min_max(area.min, pos2(area.max.x, area.min.y + 20.0)); + let left = Rect::from_min_max(area.min, pos2(area.min.x + 20.0, area.max.y)); + painter.rect_filled(top, 0.0, t.chrome); + painter.rect_filled(left, 0.0, t.chrome); + let ticks = |len_in: f64| (0..).map(move |k| f64::from(k) * minor).take_while(move |v| *v <= unit.from_inches(len_in) + 1e-9).take(2000); + for v in ticks(pw) { + let x = sheet.page.left() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(x, top.bottom()), pos2(x, top.bottom() - if is_major { 10.0 } else { 5.0 })], tick); + if is_major { + painter.text(pos2(x + 2.0, top.top() + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } + for v in ticks(ph) { + let y = sheet.page.top() + unit.to_inches(v) as f32 * sheet.scale; + let is_major = (v / major - (v / major).round()).abs() < 1e-9; + painter.line_segment([pos2(left.right(), y), pos2(left.right() - if is_major { 10.0 } else { 5.0 }, y)], tick); + if is_major { + painter.text(pos2(left.left() + 1.0, y + 1.0), Align2::LEFT_TOP, format!("{}", v.round() as i64), t.font(9.0), ink); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn lengths_read_in_the_ruler_unit() { + assert_eq!(fmt_len(1.0, Unit::Inches), "1.00 in"); + assert_eq!(fmt_len(1.0, Unit::Millimeters), "25.4 mm"); + assert_eq!(fmt_len(1.0, Unit::Points), "72 pt"); + assert_eq!(trim(4.0), "4"); + assert_eq!(trim(2.5), "2.5"); + assert_eq!(paper_label("a4"), "A4"); + assert_eq!(paper_label("4x6"), "4 × 6 in"); + } +} diff --git a/crates/ui-egui/src/recheck.rs b/crates/ui-egui/src/recheck.rs new file mode 100644 index 000000000..9a53c5a06 --- /dev/null +++ b/crates/ui-egui/src/recheck.rs @@ -0,0 +1,46 @@ +//! Preview-only raws rechecked when a library opens (issue #662): a raw imported while its format +//! couldn't be decoded is probed again when the library opens in another LightCraft version (see +//! `lightcraft_engine::cmd::recheck`). The probe runs on a worker thread, so opening never waits +//! for it; the result is applied back here, unless another library was opened meanwhile. + +use lightcraft_engine::cmd::recheck; + +use crate::LightcraftApp; + +/// The activity stack's label while the files are read. +pub const LABEL: &str = "Checking raw files"; + +/// Once per opened library (called every frame): start the recheck when it has stale photos. +pub fn tick(app: &mut LightcraftApp) { + let library = app.session.library_generation(); + if app.ui.rechecked_library == Some(library) { + return; + } + app.ui.rechecked_library = Some(library); + // a demo or browser session has no files to read + let Some(probe) = recheck::file_probe(&app.session) else { return }; + let stale = recheck::stale(&app.session.catalog, false); + if stale.is_empty() { + return; + } + let work = move || recheck::probe(&probe, stale); + let done = move |app: &mut LightcraftApp, ctx: &egui::Context, results| { + if app.session.library_generation() != library { + return; + } + match recheck::apply(&mut app.session, results) { + Ok(r) => { + let n = r.cleared.len(); + if n == 1 { + app.toast(ctx, crate::i18n::tr("1 raw can now be developed from its raw data")); + } else if n > 1 { + app.toast(ctx, crate::i18n::tr_format!("{n} raws can now be developed from their raw data", n = n)); + } + } + Err(e) => log::warn!("recheck of preview-only raws: {e}"), + } + }; + if let Err(e) = crate::tasks::spawn(app, LABEL, Some("recheck"), work, done) { + log::warn!("{e}"); + } +} diff --git a/crates/ui-egui/src/region.rs b/crates/ui-egui/src/region.rs index 3e2cc6638..953008e0d 100644 --- a/crates/ui-egui/src/region.rs +++ b/crates/ui-egui/src/region.rs @@ -72,8 +72,10 @@ pub struct ViewSizes { pub native_long: usize, /// The largest texture the GPU takes. pub texture_side: usize, - /// 1.0, or less while a slider is dragged (a draft of the whole-frame render). - pub draft_scale: f32, + /// While a slider is dragged, the scale of the whole-frame draft against the render at rest + /// (1.0 or [`CPU_DRAFT_SCALE`], from [`DragScale`]); a draft is never above the canvas size. + /// `None`: no drag. + pub draft: Option<f32>, /// Whether a window can be drawn over the whole-frame render (not with a diagnostic overlay /// that needs whole-frame context, or a read too far for one window). Mask /// overlays and soft proofing are window-safe. @@ -92,17 +94,141 @@ pub struct LoupePlan { pub window_edge: Option<usize>, } +/// Size of a slider drag's whole-frame draft when full size would be too slow (the CPU renders +/// it, or full-size drafts of this drag went over [`DRAFT_BUDGET_MS`]), against the render at rest. +pub const CPU_DRAFT_SCALE: f32 = 0.6; + +/// The slider budget (ms): a drag whose full-size drafts take longer gets smaller drafts next time. +/// A draft's time is its render plus the frame that shows it (the texture upload is part of it). +pub const DRAFT_BUDGET_MS: f64 = 16.0; + +/// Full-size drafts this many times over budget give up on full size in the middle of the drag: +/// a drag at 200 ms a step is worse than one jump in resolution. +const GIVE_UP_FACTOR: f64 = 2.0; + +/// The full-size drafts (after the first) a drag times before it may give up on full size. +const GIVE_UP_AFTER: usize = 3; + +/// The most (photo, control) pairs [`DragScale`] remembers as too slow for full-size drafts. +const SLOW_DRAGS_KEPT: usize = 256; + +/// The size of a slider drag's whole-frame draft, chosen when the drag starts and kept until it +/// ends (a change in the middle would make the resolution jump), unless its full-size drafts are +/// far over budget. A drag renders at full size when the GPU draws the view and no earlier drag of +/// the same control on this photo went over budget at that size; otherwise at [`CPU_DRAFT_SCALE`] +/// (issue #330). Whether the GPU drew a render is not enough on its own: some views (a LUT +/// profile, refined masks, a photo past a GPU limit) fall back to the CPU per render, and a large +/// draft costs a texture upload too, which only the time a draft took to show tells. +#[derive(Clone, Debug, Default)] +pub struct DragScale { + /// The drag running now: (photo, control) and its scale. + current: Option<((lightcraft_catalog::PhotoId, String), f32)>, + /// How long the current drag's full-size drafts took to show (ms). + samples: Vec<f64>, + /// Key of the whole-frame render last timed (each render is timed once). + seen: Option<u64>, + /// The render time (ms) of a draft that reached the screen this frame: the time of the frame + /// that uploads it is only known on the next one. + pending: Option<f64>, + /// Drags whose full-size drafts went over budget. + slow: std::collections::HashSet<(lightcraft_catalog::PhotoId, String)>, +} + +impl DragScale { + /// The draft scale for this frame, or `None` without a drag. `drag` is the photo and control + /// being dragged; `gpu` whether the GPU draws this view; `shown` the key of the whole-frame + /// render on screen (a render from before the drag is not one of its drafts). + pub fn scale(&mut self, drag: Option<(lightcraft_catalog::PhotoId, &str)>, gpu: bool, shown: Option<u64>) -> Option<f32> { + let Some((photo, control)) = drag else { + self.finish(); + return None; + }; + match &self.current { + Some(((p, c), s)) if *p == photo && c == control => return Some(*s), + Some(_) => self.finish(), + None => {} + } + let key = (photo, control.to_string()); + let s = if gpu && !self.slow.contains(&key) { 1.0 } else { CPU_DRAFT_SCALE }; + self.current = Some((key, s)); + self.seen = shown; + Some(s) + } + + /// Once per frame: `key` identifies the whole-frame render on screen and `render_ms` is how + /// long it took; `last_frame_ms` is how long the previous frame took, painting included (the + /// host's report; `None` without one, e.g. headless). Times the current drag's full-size + /// drafts, each as its render plus the frame that uploaded and showed it, and gives up on full + /// size when they are far over budget. + pub fn observe(&mut self, key: u64, render_ms: f64, last_frame_ms: Option<f64>) { + let full = matches!(self.current, Some((_, s)) if s >= 1.0); + if !full { + self.pending = None; + return; + } + if let Some(render) = self.pending.take() { + let frame = last_frame_ms.filter(|ms| ms.is_finite() && *ms >= 0.0).unwrap_or(0.0); + if self.samples.len() < 1024 { + self.samples.push(render + frame); + } + if self.samples.len() > GIVE_UP_AFTER + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS * GIVE_UP_FACTOR) + && let Some((_, s)) = self.current.as_mut() + { + *s = CPU_DRAFT_SCALE; + return; + } + } + if self.seen == Some(key) { + return; + } + self.seen = Some(key); + if render_ms.is_finite() && render_ms >= 0.0 { + self.pending = Some(render_ms); + } + } + + /// Remember drags of `control` on `photo` as too slow for full-size drafts (tests). + #[cfg(test)] + pub fn remember_slow(&mut self, photo: lightcraft_catalog::PhotoId, control: &str) { + self.slow.insert((photo, control.to_string())); + } + + /// The drag ended: remember it if its full-size drafts were over budget. + fn finish(&mut self) { + if let Some((key, _)) = self.current.take() + && median_after_first(&self.samples).is_some_and(|m| m > DRAFT_BUDGET_MS) + { + if self.slow.len() >= SLOW_DRAGS_KEPT { + self.slow.clear(); + } + self.slow.insert(key); + } + self.samples.clear(); + self.seen = None; + self.pending = None; + } +} + +/// The median of a drag's draft times (ms), leaving out the first draft (it rebuilds the stage +/// caches the others reuse); `None` before a second draft. +fn median_after_first(samples: &[f64]) -> Option<f64> { + let mut rest: Vec<f64> = samples.iter().skip(1).copied().collect(); + rest.sort_by(f64::total_cmp); + rest.get(rest.len() / 2).copied() +} + /// The loupe's render plan. The cost of a render follows the canvas, not the zoom: the whole-frame /// render is canvas-sized, and the window holds only the pixels on screen at no more than 100 %. pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { let canvas_long = if v.canvas_long.is_finite() { v.canvas_long.max(8.0) } else { 8.0 }; let drawn_long = if v.drawn_long.is_finite() { v.drawn_long.max(8.0) } else { canvas_long }; - let scale = if v.draft_scale.is_finite() { v.draft_scale.clamp(0.1, 1.0) } else { 1.0 }; + let scale = v.draft.map_or(1.0, |s| if s.is_finite() { s.clamp(0.1, 1.0) } else { 1.0 }); if !v.windows { // the whole frame at the size it is drawn (a whole-frame diagnostic has no // window); while a slider drags, a canvas-sized draft as with windows: a drag at 1:1 must // not render the photo's every pixel each frame - let long = if scale < 1.0 { drawn_long.min(canvas_long) } else { drawn_long }; + let long = if v.draft.is_some() { drawn_long.min(canvas_long) } else { drawn_long }; let main_edge = settings.loupe_edge(long * scale, v.native_long, v.texture_side); return LoupePlan { main_edge, window_edge: None }; } @@ -110,13 +236,15 @@ pub fn plan(settings: &crate::state::AppSettings, v: ViewSizes) -> LoupePlan { // original is not decoded for the whole-frame render let source_cap = if settings.preview_limit == 0 { lightcraft_engine::SourceLevel::Preview.max_edge() } else { settings.preview_limit as usize }; let edge_at = |scale: f32| settings.loupe_edge(drawn_long.min(canvas_long) * scale, v.native_long, v.texture_side).min(source_cap); - let main_edge = edge_at(scale); let frame_edge = settings.window_frame_edge(drawn_long, v.native_long); // a user who capped the preview size has chosen speed over sharpness at fit let fit = drawn_long <= canvas_long * 1.05; // (against the undrafted whole-frame render: a drag must not switch the window off and on) let window_edge = (frame_edge as f32 > edge_at(1.0) as f32 * WINDOW_RATIO && !(fit && settings.preview_limit != 0)).then_some(frame_edge); - LoupePlan { main_edge, window_edge } + // with a window over it, the whole-frame draft is hidden (it feeds the histogram and stands in + // where the window has no pixels yet): the small draft does, and the window keeps the frame time + let scale = if window_edge.is_some() && v.draft.is_some() { scale.min(CPU_DRAFT_SCALE) } else { scale }; + LoupePlan { main_edge: edge_at(scale), window_edge } } /// How long the render sizes stay as they were after a pinch or two-finger scroll stops (s). @@ -238,7 +366,7 @@ mod tests { const BIG: usize = 16384; fn sizes(drawn: f32, canvas: f32, native: usize) -> ViewSizes { - ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft_scale: 1.0, windows: true } + ViewSizes { drawn_long: drawn, canvas_long: canvas, native_long: native, texture_side: BIG, draft: None, windows: true } } fn auto() -> AppSettings { @@ -289,7 +417,7 @@ mod tests { #[test] fn a_drag_shrinks_the_whole_frame_draft_not_the_window() { let still = plan(&auto(), sizes(24000.0, 2800.0, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(24000.0, 2800.0, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(24000.0, 2800.0, 6000) }); assert!(drag.main_edge < still.main_edge, "{drag:?} vs {still:?}"); assert_eq!(drag.window_edge, still.window_edge); } @@ -299,14 +427,167 @@ mod tests { // windowed path #[test] fn without_a_window_a_drag_drafts_at_the_canvas_size() { - let no_window = |draft_scale: f32| ViewSizes { windows: false, draft_scale, ..sizes(24000.0, 2800.0, 6000) }; - let still = plan(&auto(), no_window(1.0)); - let drag = plan(&auto(), no_window(0.6)); + let no_window = |draft: Option<f32>| ViewSizes { windows: false, draft, ..sizes(24000.0, 2800.0, 6000) }; + let still = plan(&auto(), no_window(None)); + let drag = plan(&auto(), no_window(Some(0.6))); assert_eq!(still.window_edge, None); assert!(still.main_edge > 2800, "at rest the zoomed frame: {still:?}"); assert!(drag.main_edge <= 2800, "a drag drafts at most the canvas: {drag:?}"); } + // Given a slider drag, the GPU renders it at full size and the CPU a smaller draft (#330) + #[test] + fn a_drag_shrinks_the_draft_only_on_the_cpu() { + use lightcraft_catalog::PhotoId; + let mut gpu = DragScale::default(); + assert_eq!(gpu.scale(None, true, None), None, "no drag, no draft"); + assert_eq!(gpu.scale(Some((PhotoId(1), "Exposure")), true, None), Some(1.0)); + let mut cpu = DragScale::default(); + assert_eq!(cpu.scale(Some((PhotoId(1), "Exposure")), false, None), Some(CPU_DRAFT_SCALE)); + } + + // Given a drag that started before the GPU was ready, it keeps its size until it ends: the + // resolution never jumps in the middle of a drag + #[test] + fn a_drag_keeps_its_draft_size_until_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, false, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "the GPU came up mid-drag"); + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(drag, true, None), Some(1.0), "the next drag gets full size"); + // and the other way: the GPU lost mid-drag keeps the drag at full size until it ends + assert_eq!(d.scale(drag, false, None), Some(1.0)); + } + + // Given full-size drafts over the slider budget (a LUT profile or refined mask the CPU renders, + // a photo past a GPU limit, a slow GPU), the next drag of that control on that photo drafts + // small; other controls and photos keep full size + #[test] + fn a_drag_over_budget_drafts_small_the_next_time() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let exposure = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(exposure, true, Some(7)), Some(1.0)); + d.observe(7, 400.0, None); // the render from before the drag: not a draft of it + for (key, ms) in [(8, 300.0), (9, 20.0), (10, 25.0), (11, 9.0)] { + d.observe(key, ms, None); + d.observe(key, ms, None); // a render is timed once, however many frames show it + } + assert_eq!(d.scale(exposure, true, None), Some(1.0), "over budget, but not far enough to give up mid-drag"); + assert_eq!(d.scale(None, true, None), None); + assert_eq!(d.scale(exposure, true, None), Some(CPU_DRAFT_SCALE)); + assert_eq!(d.scale(Some((PhotoId(1), "Contrast")), true, None), Some(1.0)); + assert_eq!(d.scale(Some((PhotoId(2), "Exposure")), true, None), Some(1.0)); + } + + // Given full-size drafts within budget (the first one, rebuilding the stage caches, may be + // slow), the next drag stays at full size + #[test] + fn a_drag_within_budget_stays_full_size() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for (key, ms) in [(1, 120.0), (2, 4.0), (3, 6.0), (4, 30.0), (5, 5.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + // hostile times are ignored, never a panic + for (key, ms) in [(6, f64::NAN), (7, f64::INFINITY), (8, -1.0)] { + d.observe(key, ms, None); + } + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + } + + // Given renders within budget whose frames (the texture upload) take it over, the drag counts + // as over budget: a render time alone said 231 of 234 drafts fit while none showed in 16 ms + #[test] + fn a_drafts_time_includes_the_frame_that_shows_it() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + d.scale(drag, true, None); + for key in 1..=5 { + d.observe(key, 8.0, Some(12.0)); // the previous frame, which uploaded the last draft + } + d.observe(5, 8.0, Some(12.0)); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE), "8 ms renders + 12 ms frames: over 16 ms"); + // the same renders without the host's frame times (headless) stay within budget + let mut headless = DragScale::default(); + headless.scale(drag, true, None); + for key in 1..=6 { + headless.observe(key, 8.0, None); + } + headless.scale(None, true, None); + assert_eq!(headless.scale(drag, true, None), Some(1.0)); + // a hostile frame time counts as nothing, never a panic + let mut hostile = DragScale::default(); + hostile.scale(drag, true, None); + for (key, frame) in [(1, f64::NAN), (2, f64::INFINITY), (3, -1.0), (4, f64::NAN)] { + hostile.observe(key, 8.0, Some(frame)); + } + hostile.scale(None, true, None); + assert_eq!(hostile.scale(drag, true, None), Some(1.0)); + } + + // Given full-size drafts far over budget (a refined mask the CPU renders at 190 ms a step), + // the drag drops to the small draft after a few of them instead of at its end, and the next + // drag starts small + #[test] + fn a_drag_far_over_budget_drafts_small_before_it_ends() { + use lightcraft_catalog::PhotoId; + let mut d = DragScale::default(); + let drag = Some((PhotoId(1), "Exposure")); + assert_eq!(d.scale(drag, true, None), Some(1.0)); + let mut scales = Vec::new(); + for key in 1..=8 { + d.observe(key, 190.0, None); + scales.push(d.scale(drag, true, None)); + } + // drafts 1–4 are timed one frame late; the median of 2–4 decides on the fifth frame + assert_eq!(scales[..4], [Some(1.0); 4], "{scales:?}"); + assert_eq!(scales[4..], [Some(CPU_DRAFT_SCALE); 4], "{scales:?}"); + d.scale(None, true, None); + assert_eq!(d.scale(drag, true, None), Some(CPU_DRAFT_SCALE)); + } + + // Given a full-size drag zoomed in (a window covers the view), the hidden whole-frame draft is + // the small one: at 1:1 on a Retina display it cost 7–9 ms a step for pixels nobody sees + #[test] + fn a_full_size_drag_under_a_window_drafts_the_whole_frame_small() { + let zoomed = sizes(24000.0, 2800.0, 6000); + let small = plan(&auto(), ViewSizes { draft: Some(CPU_DRAFT_SCALE), ..zoomed }); + let full = plan(&auto(), ViewSizes { draft: Some(1.0), ..zoomed }); + assert!(full.window_edge.is_some(), "{full:?}"); + assert_eq!(full, small); + // at fit without a window, full size stays full size + let fit = sizes(2800.0, 2800.0, 6000); + assert_eq!(plan(&auto(), ViewSizes { draft: Some(1.0), ..fit }), plan(&auto(), fit)); + } + + // Given a drag at fit at full size, the draft is as sharp as the render at rest + #[test] + fn a_full_size_drag_at_fit_keeps_the_resolution() { + for drawn in [1400.0, 2800.0] { + let still = plan(&auto(), sizes(drawn, drawn, 6000)); + let drag = plan(&auto(), ViewSizes { draft: Some(1.0), ..sizes(drawn, drawn, 6000) }); + assert_eq!(drag, still, "{drawn}"); + } + } + + // Given no window and a full-size drag, the draft is still at most the canvas: a drag at 1:1 + // must not render the photo's every pixel each frame + #[test] + fn a_full_size_drag_without_a_window_drafts_at_the_canvas_size() { + let drag = plan(&auto(), ViewSizes { windows: false, draft: Some(1.0), ..sizes(24000.0, 2800.0, 6000) }); + assert!(drag.main_edge <= 2816, "{drag:?}"); + } + // Given the user capped the preview size, the whole-frame render obeys it, and a fit view // stays on it (no original decoded); a zoomed view still gets its sharp window #[test] @@ -339,7 +620,7 @@ mod tests { fn a_drag_at_fit_on_a_large_display_keeps_the_window() { for drawn in [3300.0, 3840.0, 4096.0, 5120.0] { let still = plan(&auto(), sizes(drawn, drawn, 6000)); - let drag = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(drawn, drawn, 6000) }); + let drag = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(drawn, drawn, 6000) }); assert!(still.window_edge.is_some(), "{drawn}: {still:?}"); assert_eq!(drag.window_edge, still.window_edge, "{drawn}"); } @@ -383,7 +664,7 @@ mod tests { // Given a drag at fit on a Retina Mac, the window does not switch on (it would flicker with the drag) #[test] fn a_drag_at_fit_does_not_switch_the_window_on() { - let p = plan(&auto(), ViewSizes { draft_scale: 0.6, ..sizes(2800.0, 2800.0, 6000) }); + let p = plan(&auto(), ViewSizes { draft: Some(0.6), ..sizes(2800.0, 2800.0, 6000) }); assert_eq!(p.window_edge, None); } @@ -457,7 +738,7 @@ mod tests { #[test] fn hostile_sizes_give_a_usable_plan() { for v in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY, -3.0, 0.0] { - let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft_scale: v, ..sizes(1.0, 1.0, 6000) }); + let p = plan(&auto(), ViewSizes { drawn_long: v, canvas_long: v, draft: Some(v), ..sizes(1.0, 1.0, 6000) }); assert!((8..=2560).contains(&p.main_edge), "{v}: {p:?}"); } let p = plan(&auto(), sizes(1000.0, 1000.0, 0)); diff --git a/crates/ui-egui/src/render.rs b/crates/ui-egui/src/render.rs index fd0f0e9bf..64ab067b7 100644 --- a/crates/ui-egui/src/render.rs +++ b/crates/ui-egui/src/render.rs @@ -55,6 +55,12 @@ pub enum Slot { Import(u32), /// The second window's view of the active photo. Second, + /// A slideshow slide (the one up, the one fading out, the next ones prepared in advance; see + /// [`crate::panels::slideshow`]): a photo's, or a title screen. + Slide(lightcraft_engine::slideshow::Item), + /// The small composed slide in Settings ▸ Slideshow (the active photo with the layout being + /// edited). + SlidePreview, /// A slider drag's live HDR histogram: a small full-quality render of an HDR edit, beside the /// drafts (which have no HDR histogram), with no texture ([`Renderer::live_hdr_histogram`]). HdrHistogram, @@ -83,6 +89,14 @@ pub trait RenderOffload { fn finished(&mut self) -> Vec<(Slot, RenderResult, f64)>; } +/// A job's result as the pool hands it back: the render and its texture pixels, converted on the +/// worker (`None` for prefetches and failures). A 10 MP zoom window's conversion took a few ms of +/// the UI thread on every slider step. +struct Finished { + result: RenderResult, + color: Option<Arc<egui::ColorImage>>, +} + struct Queued { slot: Slot, priority: u32, @@ -182,7 +196,7 @@ pub struct Renderer { thumb_inputs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64, bool)>, /// Photos whose grid thumbnail is the camera's embedded preview itself (no render behind it). embedded_thumbs: HashMap<PhotoId, (std::sync::Weak<lightcraft_catalog::Photo>, usize, u64)>, - pool: JobPool<Slot, RenderResult>, + pool: JobPool<Slot, Finished>, /// Jobs run elsewhere (see [`RenderOffload`]); `queue` holds the ones not started yet. offload: Option<Box<dyn RenderOffload>>, queue: Vec<Queued>, @@ -217,6 +231,8 @@ pub struct Renderer { variant_want: usize, /// Frames polled so far. frame: u64, + /// Slideshow slides may be held ([`Self::release_slides`] has something to do). + slides_live: bool, /// Stage caches cleared to stay in budget (see [`stage_trim_order`]). stages_trimmed: u64, /// A budget other than the memory budget's share (tests). @@ -243,7 +259,7 @@ impl Slot { impl Default for Renderer { fn default() -> Self { - let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, RenderResult>::default_threads().min(6) }; + let threads = if cfg!(target_arch = "wasm32") { 0 } else { JobPool::<Slot, Finished>::default_threads().min(6) }; Self::with_worker_threads(threads) } } @@ -280,6 +296,7 @@ impl Renderer { variant_used: HashMap::new(), variant_want: 0, frame: 0, + slides_live: false, stages_trimmed: 0, #[cfg(test)] stage_budget_override: None, @@ -349,7 +366,15 @@ impl Renderer { fn renders_for_display(slot: Slot) -> bool { matches!( slot, - Slot::Main | Slot::Region | Slot::Before | Slot::RegionBefore | Slot::Hover | Slot::Compare(_) | Slot::CompareRegion(_) | Slot::Second + Slot::Main + | Slot::Region + | Slot::Before + | Slot::RegionBefore + | Slot::Hover + | Slot::Compare(_) + | Slot::CompareRegion(_) + | Slot::Second + | Slot::Slide(_) ) } @@ -379,12 +404,13 @@ impl Renderer { } /// Run `job` and make its image ready for `display` (on the worker thread). - fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> RenderResult { + fn run_for(job: impl FnOnce() -> RenderResult, display: &Option<Arc<lightcraft_engine::display::Display>>) -> Finished { let mut r = job(); if let Some(d) = display { lightcraft_engine::display::present(&mut r, d); } - r + let color = r.rendered.as_ref().ok().map(|x| Arc::new(color_image(&x.image))); + Finished { result: r, color } } fn request_needed(&self, slot: Slot, key: u64, priority: u32) -> bool { @@ -424,6 +450,9 @@ impl Renderer { /// Request a render for `slot` (no-op if already current or pending at the same priority). pub fn request(&mut self, slot: Slot, job: RenderJob, priority: u32) { + if matches!(slot, Slot::Slide(_)) { + self.slides_live = true; + } let mut job = self.job_for(slot, job); if !self.request_needed(slot, job.key, priority) { return; @@ -468,6 +497,24 @@ impl Renderer { self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| job.run(), &display))); } + /// Render a slideshow title screen into `slot` (once per screen key, like [`Self::request`]): + /// a slide with no photo, composed on a worker (on wasm: the job pool's inline runs, not the + /// offload, which only knows photo renders). + pub fn request_title(&mut self, slot: Slot, compose: Arc<lightcraft_engine::slide_title::TitleCompose>, priority: u32) { + if matches!(slot, Slot::Slide(_)) { + self.slides_live = true; + } + let key = compose.key(); + if !self.request_needed(slot, key, priority) { + return; + } + self.pending.insert(slot, (key, priority)); + let request_id = lightcraft_preview::next_tick(); + self.request_ids.insert(slot, request_id); + let display = self.display.clone(); + self.pool.submit(slot, key, priority, Box::new(move || Self::run_for(|| compose.result(request_id), &display))); + } + /// Prepare a photo in the background (a [`Slot::Prefetch`] job runs once per key; a newer one /// replaces it while queued). Memory stays bounded by the engine's source caches. pub fn prefetch(&mut self, slot: Slot, mut job: RenderJob, priority: u32) { @@ -479,7 +526,29 @@ impl Renderer { job.request_id = lightcraft_preview::next_tick(); self.request_ids.insert(slot, job.request_id); let key = job.key; - self.pool.submit(slot, key, priority, Box::new(move || lightcraft_engine::memory::in_background(|| job.run()))); + self.pool.submit( + slot, + key, + priority, + Box::new(move || Finished { result: lightcraft_engine::memory::in_background(|| job.run()), color: None }), + ); + } + + /// Drop the slideshow slides other than `keep`: their textures and their requests that have + /// not started (a slide that is no longer near is not worth rendering). + pub fn release_slides(&mut self, keep: &[lightcraft_engine::slideshow::Item]) { + if !self.slides_live { + return; + } + let gone = |s: &Slot| matches!(s, Slot::Slide(id) if !keep.contains(id)); + self.textures.retain(|s, _| !gone(s)); + let _ = self.pool.reprioritize(|s, p| if gone(s) { None } else { Some(p) }); + self.queue.retain(|q| !gone(&q.slot)); + // (a render already running finishes unclaimed: its result is dropped) + self.pending.retain(|s, _| !gone(s)); + self.request_ids.retain(|s, _| !gone(s)); + self.failed.retain(|s, _| !gone(s)); + self.slides_live = !keep.is_empty(); } /// Is a request for `slot` queued or running? @@ -701,14 +770,14 @@ impl Renderer { fn collect(&mut self, ctx: &egui::Context, session: &mut Session, inline_ms: f64) -> bool { let mut finished = Vec::new(); while let Some(done) = self.pool.try_recv() { - finished.push((done.slot, done.result, if done.ms > 0.0 { done.ms } else { inline_ms })); + finished.push((done.slot, done.result.result, done.result.color, if done.ms > 0.0 { done.ms } else { inline_ms })); } if let Some(off) = self.offload.as_mut() { - finished.extend(off.finished()); + finished.extend(off.finished().into_iter().map(|(slot, r, ms)| (slot, r, None, ms))); } self.dispatch(); let mut changed = false; - for (mut slot, mut r, ms) in finished { + for (mut slot, mut r, mut color, ms) in finished { self.completed += 1; // made for another display profile than the one in use: obsolete if r.display.is_some_and(|id| Some(id) != self.display_id()) { @@ -737,7 +806,11 @@ impl Renderer { } // (results from an offload, which doesn't know the display profile: converted here) if let Some(d) = &self.display { + let before = r.display; lightcraft_engine::display::present(&mut r, d); + if r.display != before { + color = None; // converted after the worker made its texture pixels + } } if self.pending.get(&slot).is_some_and(|p| p.0 == r.key) { self.pending.remove(&slot); @@ -778,7 +851,7 @@ impl Renderer { self.textures.remove(&Slot::ThumbQuick(id)); } let img = &rendered.image; - let color = std::sync::Arc::new(color_image(img)); + let color = color.unwrap_or_else(|| Arc::new(color_image(img))); let pixels = self.keep_pixels.then(|| color.clone()); let name = format!("{slot:?}"); match self.textures.get_mut(&slot) { @@ -962,6 +1035,7 @@ mod thumbnail_tests { file_size: 99, content_hash: Some("changed".into()), preview_only: None, + preview_checked: String::new(), }) .unwrap(); assert!(!r.thumb_current(catalog.photo(PhotoId(1)).unwrap(), 384, 10)); @@ -1571,6 +1645,30 @@ mod color_image_tests { assert_eq!(got.pixels, want.pixels); } + // Given a finished render, the worker hands back its texture pixels with it (the UI thread no + // longer converts a 10 MP window on every slider step); a failed render has none + #[test] + fn the_worker_converts_the_texture_pixels() { + let image = lightcraft_raster::Rgba8::from_fn(5, 3, |x, y| [(x * 40) as u8, (y * 80) as u8, 7, 255]); + let result = |rendered| RenderResult { + request_id: 1, + source_key: None, + photo: PhotoId(1), + level: lightcraft_engine::SourceLevel::Preview, + key: 1, + rendered, + gpu_fallback: None, + loaded: None, + quick: None, + display: None, + }; + let histogram = Histogram::of_srgb8(&image); + let ok = Renderer::run_for(|| result(Ok(lightcraft_pipeline::Rendered { image: image.clone(), histogram, deep: None })), &None); + assert_eq!(ok.color.map(|c| c.pixels.clone()), Some(color_image(&image).pixels)); + let failed = Renderer::run_for(|| result(Err("unreadable".into())), &None); + assert!(failed.color.is_none()); + } + #[test] fn an_empty_image_is_an_empty_texture() { let img = lightcraft_raster::Rgba8::new(0, 0); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index b10387c76..b9d1874a1 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -168,6 +168,7 @@ pub fn format(m: Modifiers, k: Key) -> Option<String> { Key::CloseBracket => "]", Key::Quote => "'", Key::Comma => ",", + Key::Backtick => "`", k => k.name(), }; let mut s = String::new(); @@ -203,6 +204,9 @@ pub const ALIASES: &[(&str, &str, &str)] = &[ ("F6", "view.filmstrip", "{}"), ("F7", "view.leftPanel", "{}"), ("Shift+M", "version.create", "{}"), + // grid thumbnail size: Classic's key is `=`; `+` (keypad, or Shift+= reported as Plus) agrees + ("Plus", "view.thumbSizeUp", "{}"), + ("Shift+Plus", "view.thumbSizeUp", "{}"), ("Shift+X", "photo.flag", r#"{"flag": "reject", "advance": true}"#), ("Shift+Z", "photo.flag", r#"{"flag": "pick", "advance": true}"#), ("Shift+U", "photo.flag", r#"{"flag": "none", "advance": true}"#), @@ -316,6 +320,12 @@ fn keyboard_owned(app: &LightcraftApp, ctx: &egui::Context) -> bool { if crate::widgets::slider_has_focus(ctx) { return ctx.input(|input| [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown].iter().any(|key| input.key_pressed(*key))); } + // a handle on the photo keeps the arrows (it moves) and Return / Space (it acts) + if crate::canvas_keys::has_focus(ctx) { + return ctx.input(|input| { + [Key::ArrowLeft, Key::ArrowRight, Key::ArrowUp, Key::ArrowDown, Key::Enter, Key::Space].iter().any(|key| input.key_pressed(*key)) + }); + } let chrome = app .ui .docking @@ -339,6 +349,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { if app.ui.dialog.is_some() { return; } + if app.recording_shortcut.is_none() && app.ui.name_edit.is_none() && crate::panels::person::handle_bulk_key(app, ctx) { + return; + } // don't steal keys from text fields or from the keymap editor recording a shortcut if keyboard_owned(app, ctx) || app.recording_shortcut.is_some() || app.ui.name_edit.is_some() { return; @@ -349,6 +362,9 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { let mut aliased: Vec<(&str, serde_json::Value)> = Vec::new(); let keymap = &app.ui.settings.keymap; let grid = library_grid(app); + // The grid navigation keys act on the photos in view, so they wait while the full-screen preview + // covers the grid: a key must not move the selection onto photos nobody can see. + let grid_navigation = grid && !app.ui.fullscreen; // keys the user gave to a command: the fixed bindings below (aliases, ratings) yield to them let taken: Vec<(Modifiers, Key)> = keymap.values().filter_map(|s| parse(s)).collect(); // an open popup (a menu, a date picker's calendar) closes on Esc itself: Esc's command (Back, @@ -357,6 +373,16 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // Esc that just took a text field's focus away (it gives back the field's text) does nothing else let popup_open = popup_open || app.text_focus; ctx.input(|i| { + // Enter opens the active grid photo; widgets, popups and custom bindings keep the key. + if grid + && app.session.active().is_some() + && !popup_open + && !native("Enter") + && !taken.contains(&(Modifiers::NONE, Key::Enter)) + && matches(i, Modifiers::NONE, Key::Enter) + { + fire.push("view.detail".to_string()); + } for b in bindable() { if let Some(sc) = binding(keymap, b.id, b.default) && let Some((m, k)) = parse(sc) @@ -391,6 +417,20 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { fire.push(format!("label:{label}")); } } + if grid_navigation { + // Home / End: the first / last photo of the view + for (key, id) in [(Key::Home, "grid:first"), (Key::End, "grid:last")] { + if matches(i, Modifiers::NONE, key) && !taken.contains(&(Modifiers::NONE, key)) { + fire.push(id.to_string()); + } + } + // Shift+← / Shift+→: the previous / next photo of the view joins the selection + for (key, step) in [(Key::ArrowLeft, -1i64), (Key::ArrowRight, 1)] { + if matches(i, Modifiers::SHIFT, key) && !taken.contains(&(Modifiers::SHIFT, key)) { + fire.push(format!("grid:extend:{step}")); + } + } + } }); use crate::panels::compare; // rating/flag/label keys: in Compare/Survey they act on the active photo only; Shift+key or @@ -403,13 +443,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } }; for (id, params) in aliased { - // Space pauses / resumes a slideshow - if id == "view.zoomToggle" - && let Some((interval, _, paused)) = app.ui.slideshow - { - let now = ctx.input(|i| i.time); - app.ui.slideshow = Some((interval, now + interval, !paused)); - app.toast(ctx, if paused { "Slideshow resumed" } else { "Slideshow paused" }); + // a slideshow: Space pauses / resumes + if id == "view.zoomToggle" && app.ui.slideshow.is_some() { + if let Ok(v) = app.run("slideshow.togglePause", json!({})) { + app.toast(ctx, crate::i18n::tr(if v["paused"] == true { "Slideshow paused" } else { "Slideshow resumed" })); + } continue; } // flag/rate aliases (Shift+X…) go through the culling path: active photo in Compare/Survey, @@ -429,6 +467,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } } for f in fire { + // a slideshow: ←/→ step (a cut) + if app.ui.slideshow.is_some() && matches!(f.as_str(), "library.next" | "library.previous") { + let _ = app.run(if f == "library.next" { "slideshow.next" } else { "slideshow.previous" }, json!({})); + continue; + } if let Some(rest) = f.strip_prefix("rate:") { let (n, adv) = rest.split_once(':').unwrap_or(("0", "0")); cull(app, "photo.rate", json!({"rating": n.parse::<u8>().unwrap_or(0)}), adv == "1"); @@ -448,9 +491,43 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { } else if f == "view.softProof" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { // S in a grid: expand / collapse the stack (Lightroom's Library binding) let _ = app.run("stack.toggle", json!({})); + } else if f == "grid:first" || f == "grid:last" { + // Home / End in a grid: make the first / last photo of the view the active, selected one + let vis = app.session.visible_cloned(); + let edge = if f == "grid:first" { vis.first() } else { vis.last() }; + if let Some(id) = edge { + let _ = app.run("library.select", json!({"ids": [id.0], "active": id.0})); + } + } else if let Some(step) = f.strip_prefix("grid:extend:") { + // Shift+← / Shift+→ in a grid: the neighbour of the active photo joins the selection and + // becomes the active one. At either end of the view the key does nothing. + let step = step.parse::<i64>().unwrap_or(0); + let vis = app.session.visible_cloned(); + let neighbour = app + .session + .active() + .and_then(|active| vis.iter().position(|p| *p == active)) + .and_then(|pos| i64::try_from(pos).ok()) + .and_then(|pos| pos.checked_add(step)) + .and_then(|pos| usize::try_from(pos).ok()) + .and_then(|pos| vis.get(pos)); + if let Some(id) = neighbour { + let _ = app.run("library.select", json!({"ids": [id.0], "mode": "add"})); + } } else if compare::culling(app) && (f == "library.next" || f == "library.previous") { let d = if f == "library.next" { 1 } else { -1 }; let _ = if app.ui.view == crate::state::ViewMode::Compare { compare::compare_step(app, d) } else { compare::survey_step(app, d) }; + } else if matches!(f.as_str(), "photo.cycleFlag" | "photo.increaseFlag" | "photo.decreaseFlag") { + // `, ⌘↑, ⌘↓: step the flag; the toast names the active photo's new state (read before Auto Advance moves on) + let active = app.session.active(); + cull(app, &f, json!({}), false); + let flag = active.and_then(|id| app.session.catalog.photo(id)).map(|p| p.flag); + match flag { + Some(lightcraft_catalog::Flag::Pick) => app.toast(ctx, crate::i18n::tr("Flagged as Pick")), + Some(lightcraft_catalog::Flag::Reject) => app.toast(ctx, crate::i18n::tr("Flagged as Reject")), + Some(lightcraft_catalog::Flag::None) => app.toast(ctx, crate::i18n::tr("Unflagged")), + None => {} + } } else if matches!(f.as_str(), "photo.pick" | "photo.reject" | "photo.unflag") && app.ui.right != crate::state::RightPanel::Crop { cull(app, &f, json!({}), false); match f.as_str() { @@ -484,6 +561,11 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { R::RedEye => continue, _ => {} } + // in an album, Delete takes the photos out of the album only, as in Lightroom: they + // stay in the library and their folder (issue #1805) + if crate::menus::delete_in_album(app, ctx) { + continue; + } if crate::menus::confirm_delete(app) { continue; } @@ -491,7 +573,7 @@ pub fn handle(app: &mut LightcraftApp, ctx: &egui::Context) { // B: the brush while editing; in the grids, add to the target album (Quick Collection) if f == "tool.brush" && matches!(app.ui.view, crate::state::ViewMode::PhotoGrid | crate::state::ViewMode::SquareGrid) { if let Ok(r) = app.run("album.toggleTarget", json!({})) { - let n = app.session.targets(&json!({})).len(); + let n = app.session.selected().len(); let what = crate::i18n::tr_format!("{n} photo{}", if n == 1 { "" } else { "s" }, n = n); let name = r["name"].as_str().unwrap_or("Quick Collection").to_string(); app.toast( @@ -1009,8 +1091,181 @@ mod tests { } } } + mod issue1771 { + use super::*; + use crate::state::{Dialog, ViewMode}; + use lightcraft_catalog::PhotoId; + + fn grid(view: ViewMode) -> crate::headless::Headless { + let mut h = library(); + h.app.ui.view = view; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3], "active": 2})).unwrap(); + for _ in 0..3 { + h.step(); + } + h + } + + #[test] + fn issue1771_photo_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::PhotoGrid); + } + + #[test] + fn issue1771_square_grid_enter_opens_active_preserving_multiselection() { + opens(ViewMode::SquareGrid); + } + + fn opens(view: ViewMode) { + let mut h = grid(view); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "Enter from {view:?}"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail, "second Enter remains in Detail"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + } + + #[test] + fn issue1771_no_selection_and_modifier_chords_do_not_open() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + for modifiers in [json!({"shift":true}), json!({"cmd":true}), json!({"alt":true}), json!({"ctrl":true})] { + let mut args = modifiers; + args["key"] = json!("Enter"); + assert_eq!(h.request("ui.key", args, std::time::Duration::from_secs(20))["ok"], true); + assert_eq!(h.app.ui.view, view); + } + h.app.session.selection = Default::default(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert!(h.app.session.selection.ids.is_empty()); + assert_eq!(h.app.session.active(), None); + } + } + + #[test] + fn issue1771_search_dialog_and_shortcut_recording_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"field:search"}), std::time::Duration::from_secs(20))["ok"], true); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "focused search"); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "shortcut dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + h.app.recording_shortcut = Some("view.survey".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "recording owns Enter"); + assert_eq!(super::super::shortcut_of(&h.app.ui.settings.keymap, "view.survey"), Some("Enter")); + } + } + + #[test] + fn issue1771_custom_binding_and_native_menu_keep_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + h.app.run("app.setShortcut", json!({"id":"view.survey", "shortcut":"Enter"})).unwrap(); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Survey, "custom binding wins"); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + let mut h = grid(view); + h.app.native_shortcuts.insert("Enter".into()); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view, "native menu owns Enter"); + } + } + + #[test] + fn issue1771_popup_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + let ctx = egui::Context::default(); + let mut output = ctx.run_ui( + egui::RawInput { + events: vec![egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: false, + modifiers: egui::Modifiers::NONE, + }], + ..Default::default() + }, + |ui| { + let ctx = ui.ctx(); + egui::Popup::open_id(ctx, egui::Id::new("issue1771-menu")); + assert!(egui::Popup::is_any_open(ctx)); + super::super::handle(&mut h.app, ctx); + }, + ); + output.textures_delta.clear(); + assert_eq!(h.app.ui.view, view); + } + } + + #[test] + fn issue1771_detail_enter_keeps_active_and_multiselection() { + let mut h = grid(ViewMode::Detail); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids, vec![PhotoId(1), PhotoId(2), PhotoId(3)]); + } + + #[test] + fn issue1771_open_file_menu_keeps_enter() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] { + let mut h = grid(view); + assert_eq!(h.request("ui.clickWidget", json!({"id":"menu:File"}), std::time::Duration::from_secs(20))["ok"], true); + h.step(); + assert!(egui::Popup::is_any_open(&h.view.ctx), "File menu open before Enter"); + key(&mut h, "Enter", false); + assert_eq!(h.app.ui.view, view); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } + + #[test] + fn issue1771_repeat_event_opens_once_and_detail_keeps_selection() { + let mut h = grid(ViewMode::PhotoGrid); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.active(), Some(PhotoId(2))); + assert_eq!(h.app.session.selection.ids.len(), 3); + h.events.push(egui::Event::Key { + key: egui::Key::Enter, + physical_key: None, + pressed: true, + repeat: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); + assert_eq!(h.app.ui.view, ViewMode::Detail); + assert_eq!(h.app.session.selection.ids.len(), 3); + } + } } #[cfg(test)] #[path = "shortcuts/tests_relative_ratings.rs"] mod relative_rating_tests; + +#[cfg(test)] +#[path = "shortcuts/tests_flag_cycle.rs"] +mod flag_cycle_tests; diff --git a/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs new file mode 100644 index 000000000..d4e6deec5 --- /dev/null +++ b/crates/ui-egui/src/shortcuts/tests_flag_cycle.rs @@ -0,0 +1,140 @@ +//! The backtick (`) key cycles the flag of the selection / active photo; Cmd+Up / Cmd+Down raise / lower it. +use egui::{Key, Modifiers}; +use lightcraft_catalog::{Flag, Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::LightcraftApp; +use crate::state::ViewMode; + +fn app() -> lightcraft_engine::Result<LightcraftApp> { + let mut s = lightcraft_engine::Session::new(); + for id in 1..=3 { + let photo = Photo::new( + PhotoId(id), + Source::File { path: format!("/lightcraft-flag-cycle-keys/{id}.jpg") }, + &format!("{id}.jpg"), + "JPEG", + 40, + 30, + "2026-10-13", + ); + s.catalog.apply(Op::AddPhoto { photo: Box::new(photo) })?; + } + s.execute("library.sort", &json!({"key": "fileName", "ascending": true}))?; + s.execute("library.select", &json!({"ids": [2]}))?; + Ok(LightcraftApp::new(s, crate::Services { png: None, ..Default::default() })) +} + +fn flags(app: &LightcraftApp) -> Vec<Flag> { + (1..=3).map(|id| app.session.catalog.photo(PhotoId(id)).unwrap().flag).collect() +} + +fn frame(app: &mut LightcraftApp, ctx: &egui::Context, key: Option<(Key, Modifiers)>, text: Option<&mut String>) { + let events = + key.map(|(key, modifiers)| egui::Event::Key { key, physical_key: Some(key), pressed: true, repeat: false, modifiers }).into_iter().collect(); + let mut text = text; + let mut out = ctx.run_ui( + egui::RawInput { events, screen_rect: Some(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(800.0, 600.0))), ..Default::default() }, + |ui| { + if let Some(text) = text.as_deref_mut() { + ui.add(egui::TextEdit::singleline(text).id(egui::Id::new("flag-cycle-search"))).request_focus(); + } + super::handle(app, ui.ctx()); + }, + ); + out.textures_delta.clear(); +} + +fn tick(app: &mut LightcraftApp, ctx: &egui::Context) { + frame(app, ctx, Some((Key::Backtick, Modifiers::NONE)), None); +} + +#[test] +fn backtick_parses_and_formats_as_the_grave_key() { + assert_eq!(super::parse("`"), Some((Modifiers::NONE, Key::Backtick))); + assert_eq!(super::format(Modifiers::NONE, Key::Backtick).as_deref(), Some("`")); + assert_eq!(super::shortcut_of(&Default::default(), "photo.cycleFlag"), Some("`")); +} + +#[test] +fn backtick_cycles_the_selected_photo_in_grid_and_loupe() { + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::Detail] { + let mut app = app().unwrap(); + app.ui.view = view; + let ctx = egui::Context::default(); + let mut seen = Vec::new(); + for _ in 0..3 { + tick(&mut app, &ctx); + seen.push(flags(&app)[1]); + } + assert_eq!(seen, [Flag::Pick, Flag::Reject, Flag::None], "{view:?}"); + assert_eq!(flags(&app)[0], Flag::None); + assert_eq!(flags(&app)[2], Flag::None); + assert_eq!(app.session.undo.len(), 3); + app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(flags(&app)[1], Flag::Reject); + } +} + +#[test] +fn cmd_up_and_down_raise_and_lower_the_flag() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + let cmd = Modifiers::COMMAND; + frame(&mut app, &ctx, Some((Key::ArrowUp, cmd)), None); + assert_eq!(flags(&app)[1], Flag::Pick); + frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None); + frame(&mut app, &ctx, Some((Key::ArrowDown, cmd)), None); + assert_eq!(flags(&app)[1], Flag::Reject); + assert_eq!(app.session.undo.len(), 3); +} + +#[test] +fn backtick_does_nothing_while_a_text_field_has_focus() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + let mut text = String::new(); + frame(&mut app, &ctx, None, Some(&mut text)); + frame(&mut app, &ctx, Some((Key::Backtick, Modifiers::NONE)), Some(&mut text)); + assert!(ctx.egui_wants_keyboard_input()); + assert_eq!(flags(&app), [Flag::None; 3]); + assert!(app.session.undo.is_empty()); +} + +#[test] +fn compare_flags_only_the_active_photo_and_auto_advance_moves_on() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::Compare; + let ctx = egui::Context::default(); + app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]); + app.ui.view = ViewMode::PhotoGrid; + app.ui.auto_advance = true; + app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app), [Flag::Pick, Flag::Pick, Flag::None]); + assert_eq!(app.session.active(), Some(PhotoId(2))); +} + +#[test] +fn a_saved_assignment_of_the_key_wins_over_the_default() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::PhotoGrid; + let ctx = egui::Context::default(); + super::assign(&mut app.ui.settings.keymap, "photo.reject", Some("`")).unwrap(); + tick(&mut app, &ctx); + assert_eq!(flags(&app)[1], Flag::Reject); +} + +#[test] +fn the_menu_item_follows_the_same_culling_rules() { + let mut app = app().unwrap(); + app.ui.view = ViewMode::Compare; + app.session.execute("library.select", &json!({"ids": [1, 2], "active": 2})).unwrap(); + crate::menubar::run_item(&mut app, "photo.cycleFlag", serde_json::Value::Null).unwrap(); + assert_eq!(flags(&app), [Flag::None, Flag::Pick, Flag::None]); + assert_eq!(crate::menubar::shortcut_text("Cmd+Up", true), "⌘↑"); +} diff --git a/crates/ui-egui/src/slideshow_export.rs b/crates/ui-egui/src/slideshow_export.rs new file mode 100644 index 000000000..62996e421 --- /dev/null +++ b/crates/ui-egui/src/slideshow_export.rs @@ -0,0 +1,166 @@ +//! View ▸ Export Slideshow…: the dialog's choices go to `app.slideshowExport`, which asks where +//! to write (a PDF or video file, or the folder of a JPEG sequence) and starts the engine's +//! `slideshow.export` in the background. The export's row in the activity stack shows its +//! progress and ✕ (the engine owns the row); [`poll`] tells the user how it ended. + +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::pick::{PickKind, PickRequest, Picked}; + +/// `app.slideshowExport {format, size?, quality?, fps?, container?, fullScreen?, speed?, path?}`: +/// without a path, a save dialog asks for one first. +pub fn start(app: &mut LightcraftApp, id: &str, p: &Value) -> Result<Value, String> { + if app.session.slideshow_export.as_ref().is_some_and(|r| r.running()) { + return Err("a slideshow export is already running".into()); + } + let mut params = p.clone(); + if p.get("path").and_then(Value::as_str).is_none_or(|x| x.trim().is_empty()) { + let req = match p.get("format").and_then(Value::as_str) { + Some("jpeg") => PickRequest { + kind: PickKind::Save, + title: Some(crate::i18n::tr("Export Slideshow: folder for the JPEG files").to_string()), + filter: None, + file_name: Some("Slideshow".into()), + }, + Some("video") if p.get("container").and_then(Value::as_str) == Some("webm") => { + PickRequest::save(crate::i18n::tr("Export Slideshow"), "WebM", &["webm"], "Slideshow.webm") + } + Some("video") => PickRequest::save(crate::i18n::tr("Export Slideshow"), "MP4", &["mp4"], "Slideshow.mp4"), + _ => PickRequest::save(crate::i18n::tr("Export Slideshow"), "PDF", &["pdf"], "Slideshow.pdf"), + }; + let paths = match crate::pick::ask(app, id, p, "path", req, |_| None) { + Picked::Now(paths) => paths, + Picked::Later => return Ok(Value::Null), + Picked::Unavailable => return Err("no save dialog on this platform; supply path".into()), + }; + let Some(path) = paths.into_iter().next() else { return Ok(Value::Null) }; + params["path"] = json!(path); + } + let r = app.run("slideshow.export", params)?; + app.slideshow_export_reported = false; + Ok(r) +} + +/// Each frame: when the export has ended, say how (once). +pub fn poll(app: &mut LightcraftApp, ctx: &egui::Context) { + if app.slideshow_export_reported { + return; + } + let Some(run) = app.session.slideshow_export.clone() else { return }; + if run.running() { + return; + } + app.slideshow_export_reported = true; + let msg = match run.result() { + Some(Ok(r)) => { + let what = match r["format"].as_str() { + Some("pdf") => crate::i18n::tr_format!("{n} pages", n = r["pages"].as_u64().unwrap_or(0)), + Some("jpeg") => crate::i18n::tr_format!("{n} JPEG files", n = r["slides"].as_u64().unwrap_or(0)), + _ => crate::i18n::tr_format!("{s} s of AV1 video", s = r["seconds"].as_f64().unwrap_or(0.0).round()), + }; + let mut m = crate::i18n::tr_format!("Slideshow exported: {what}", what = what); + if let Some(n) = r["skipped"].as_u64().filter(|n| *n > 0) { + m += &crate::i18n::tr_format!(" · {n} skipped (could not be rendered)", n = n); + } + m + } + Some(Err(e)) => e, + None => crate::i18n::tr("Export stopped unexpectedly").to_string(), + }; + app.toast(ctx, msg); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::state::Dialog; + use std::sync::{Arc, Mutex}; + use std::time::{Duration, Instant}; + + /// The app with a picker that answers `answer` and remembers what it was asked. + fn app_answering(answer: String) -> (LightcraftApp, Arc<Mutex<Vec<PickRequest>>>) { + let asked = Arc::new(Mutex::new(Vec::<PickRequest>::new())); + let requests = asked.clone(); + let services = crate::Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec![answer.clone()]).unwrap(); + Ok(rx) + })), + ..Default::default() + }; + let mut s = lightcraft_engine::Session::with_demo(); + s.execute("slideshow.playback", &json!({"slideDuration": 1, "fadeDuration": 0.5})).unwrap(); + (LightcraftApp::new(s, services), asked) + } + + fn run_dialog(h: &mut crate::headless::Headless) -> Value { + let dialog = h.app.ui.dialog.take().unwrap(); + crate::panels::dialogs::confirm_dialog(&mut h.app, &dialog).unwrap(); + let deadline = Instant::now() + Duration::from_secs(300); + let mut seen_row = false; + loop { + h.step(); + seen_row |= h.app.session.activity.list().iter().any(|t| t.kind == "export" && t.label == "Exporting slideshow"); + if h.app.slideshow_export_reported && h.app.session.slideshow_export.is_some() { + break; + } + assert!(Instant::now() < deadline, "export did not finish"); + std::thread::sleep(Duration::from_millis(5)); + } + let run = h.app.session.slideshow_export.clone().unwrap(); + let mut r = run.result().unwrap().unwrap(); + r["sawRow"] = json!(seen_row); + r["toast"] = json!(h.app.ui.toast.as_ref().map(|t| t.0.clone())); + r + } + + #[test] + fn menu_dialog_picker_and_background_pdf_export() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.pdf").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + assert!(crate::menus::ui_enabled(&app, "dialog.slideshowExport")); + app.run("dialog.slideshowExport", json!({"format": "pdf"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no slideshow export dialog") }; + assert_eq!((settings.size, settings.quality, settings.full_screen), (1920, 90, true)); + settings.size = 96; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + let req = asked.lock().unwrap(); + assert_eq!(req[0].kind, PickKind::Save); + assert_eq!(req[0].filter.as_ref().unwrap().1, &["pdf"]); + assert_eq!(r["width"], 96); + assert!(r["pages"].as_u64().unwrap() > 1); + assert!(std::fs::read(&path).unwrap().starts_with(b"%PDF-1.4")); + assert!(r["toast"].as_str().unwrap().contains("Slideshow exported"), "{r}"); + let _ = std::fs::remove_dir_all(&dir); + } + + #[test] + fn video_dialog_asks_for_an_mp4_and_jpeg_for_a_folder_name() { + let dir = std::env::temp_dir().join(format!("lc-ui-ssexp-v-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let path = dir.join("Show.mp4").to_string_lossy().to_string(); + let (mut app, asked) = app_answering(path.clone()); + app.run("dialog.slideshowExport", json!({"format": "video"})).unwrap(); + let Some(Dialog::SlideshowExport { settings }) = app.ui.dialog.as_mut() else { panic!("no dialog") }; + assert_eq!((settings.size, settings.fps, settings.container.as_str()), (720, 30, "mp4")); + settings.size = 64; + settings.fps = 24; + let mut h = crate::headless::Headless::new(app, [1000.0, 800.0], 1.0); + let r = run_dialog(&mut h); + assert_eq!(asked.lock().unwrap()[0].filter.as_ref().unwrap().1, &["mp4"]); + assert_eq!((r["codec"].as_str(), r["height"].as_u64()), (Some("av1"), Some(64))); + assert!(r["sawRow"].as_bool().unwrap(), "the export showed in the activity stack"); + assert!(std::path::Path::new(&path).is_file()); + // JPEG: a save dialog names the folder (no file type) + let (mut app, asked) = app_answering(dir.join("Frames").to_string_lossy().to_string()); + app.run("app.slideshowExport", json!({"format": "jpeg", "size": 64})).unwrap(); + assert!(asked.lock().unwrap()[0].filter.is_none()); + let _ = std::fs::remove_dir_all(&dir); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index cc583e7b3..f8c8cc0d4 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -91,6 +91,70 @@ pub enum CropOverlay { None, } +/// Tool Overlay: when the retouching tools' pins (Remove / Heal / Clone spots, mask pins) show on +/// the photo. Shift+H steps through the modes in this order (`view.toolOverlay`). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum ToolOverlay { + /// While the pointer is over the photo (hidden while a new spot or brush stroke is painted). + #[default] + Auto, + Always, + /// Only the selected spot's or mask's. + Selected, + /// None; the tool still works (a click still adds a spot). + Never, +} + +impl ToolOverlay { + pub const ALL: [ToolOverlay; 4] = [ToolOverlay::Auto, ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never]; + + /// The id used by `view.toolOverlay {mode}` and the saved settings. + pub fn name(self) -> &'static str { + match self { + ToolOverlay::Auto => "auto", + ToolOverlay::Always => "always", + ToolOverlay::Selected => "selected", + ToolOverlay::Never => "never", + } + } + + pub fn parse(s: &str) -> Option<ToolOverlay> { + ToolOverlay::ALL.into_iter().find(|m| m.name().eq_ignore_ascii_case(s.trim())) + } + + /// Menu label. + pub fn label(self) -> &'static str { + match self { + ToolOverlay::Auto => "Auto", + ToolOverlay::Always => "Always", + ToolOverlay::Selected => "Selected", + ToolOverlay::Never => "Never", + } + } + + /// The mode Shift+H steps to. + pub fn next(self) -> ToolOverlay { + match self { + ToolOverlay::Auto => ToolOverlay::Always, + ToolOverlay::Always => ToolOverlay::Selected, + ToolOverlay::Selected => ToolOverlay::Never, + ToolOverlay::Never => ToolOverlay::Auto, + } + } + + /// Whether a pin shows: `selected` = its spot or mask is the selected one, `live` = the pointer + /// is over the photo (or a pin is being dragged), which is what Auto waits for. + pub fn shows(self, selected: bool, live: bool) -> bool { + match self { + ToolOverlay::Auto => live, + ToolOverlay::Always => true, + ToolOverlay::Selected => selected, + ToolOverlay::Never => false, + } + } +} + /// Which view the app opens in. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] @@ -135,6 +199,39 @@ impl InfoOverlay { } } +/// View ▸ Loupe Overlay ▸ Layout Image (Lightroom Classic's loupe overlay): a graphic with +/// transparency, such as a magazine cover's masthead and cover lines, drawn over the photo in the +/// loupe (`panels::layout_overlay`). Saved with the UI state, so the layout stays chosen across +/// launches. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default, rename_all = "camelCase")] +pub struct LayoutImage { + /// The graphic's file; empty until one is chosen. + pub path: String, + /// Drawn over the loupe (⌥⌘O). + pub show: bool, + /// The graphic's opacity, 0..=1. + pub opacity: f32, + /// How dark the photo is outside the graphic's box, 0..=1 (0: not darkened). + pub matte: f32, + /// Bumped by each Choose Layout Image…, so choosing the same file again reads it again (after + /// it was edited elsewhere). + #[serde(skip)] + pub revision: u32, +} + +impl Default for LayoutImage { + fn default() -> Self { + LayoutImage { path: String::new(), show: false, opacity: 1.0, matte: 0.0, revision: 0 } + } +} + +impl LayoutImage { + /// The opacity and matte steps View ▸ Loupe Overlay offers. + pub const OPACITY_STEPS: [f32; 4] = [0.25, 0.5, 0.75, 1.0]; + pub const MATTE_STEPS: [f32; 4] = [0.0, 0.25, 0.5, 0.75]; +} + /// Lights Out (View ▸ Lights Out, Shift+L cycles On → Dim → Off → On; Esc turns the lights back /// on): everything but the photo is veiled in black, the panels stay where they are. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -246,6 +343,9 @@ pub struct AppSettings { /// `confirmDelete: false` by default (#1239). #[serde(rename = "askBeforeDelete")] pub confirm_delete: bool, + /// Ask before photos dropped on a Folders row move there on disk (the dialog's "Don't ask + /// again" turns it off). + pub ask_before_move: bool, /// GPU rendering allowed (`app.gpu`). pub gpu: bool, /// Largest long edge (pixels) the user lets the loupe render at; 0 = Automatic (the size it is @@ -283,6 +383,14 @@ pub struct AppSettings { /// The monitor's ICC profile previews are shown through (`app.displayProfile`; "" = none: /// the display is treated as sRGB). pub display_profile: String, + /// The Keywording box: whole keyword paths, or grouped by the keyword they are inside. + #[serde(deserialize_with = "lenient")] + pub keyword_chips: KeywordChips, + /// View ▸ Show Focus Point: draw the box the camera focused on (`view.focusPoint`). + pub focus_point: bool, + /// View ▸ Zoom to Focus Point: Toggle Zoom (Z) centres the zoomed view on the camera's focus point + /// when the photo has one (`view.zoomToFocus`). + pub zoom_to_focus: bool, } impl Default for AppSettings { @@ -292,6 +400,7 @@ impl Default for AppSettings { recent_libraries: Vec::new(), startup_view: StartupView::Last, confirm_delete: true, + ask_before_move: true, gpu: true, preview_limit: 0, external_editor: String::new(), @@ -308,6 +417,9 @@ impl Default for AppSettings { light_theme: LightTheme::Silver, favorite_themes: Vec::new(), display_profile: String::new(), + keyword_chips: KeywordChips::default(), + focus_point: false, + zoom_to_focus: true, } } } @@ -457,6 +569,12 @@ impl AppSettings { /// Click-zoom ratios offered (percent): 1:1, 2:1, 3:1, 4:1, 8:1. pub const CLICK_ZOOMS: [u32; 5] = [100, 200, 300, 400, 800]; +/// Grid thumbnail size (points): the bottom-bar slider's range, and the `=` / `-` keys' step +/// (`view.thumbSizeUp` / `view.thumbSizeDown`), about a thirteenth of the range. +pub const THUMB_SIZE_MIN: f32 = 90.0; +pub const THUMB_SIZE_MAX: f32 = 480.0; +pub const THUMB_SIZE_STEP: f32 = 30.0; + /// Width limits of a side panel the user resizes (points). #[derive(Clone, Copy, Debug, PartialEq)] pub struct PanelWidth { @@ -490,9 +608,26 @@ pub enum KeywordingView { WillExport, } +/// How the Keywording box lays out the selection's keywords. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub enum KeywordChips { + /// One chip per keyword, each with its whole path ("Places › Portugal › Lisbon"). + #[default] + Paths, + /// Keywords inside the same keyword share a line naming it, and their chips say only what is + /// below it; long lists and the suggestions are shortened. + Grouped, +} + #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct UiState { + /// How large the app's window was and whether it was zoomed, to open it like that again (the + /// desktop app; absent until a window has rested at a size). Read leniently: a window that + /// is not one reads as none, and never costs the other settings. + #[serde(skip_serializing_if = "Option::is_none", deserialize_with = "crate::window_place::WindowPlace::lenient")] + pub window: Option<crate::window_place::WindowPlace>, /// Shared panel arrangement; absent in legacy settings until their first layout. pub docking: Option<crate::docking::Workspace>, #[serde(default = "crate::i18n::default_language")] @@ -531,7 +666,7 @@ pub struct UiState { pub open_sections: Vec<String>, /// Open flyouts (curve, mixer, grading…). pub open_flyouts: Vec<String>, - /// Single-panel mode: opening one section closes the others. + /// Solo Mode (`view.soloSections`): opening one Edit section closes the others. pub single_panel: bool, pub show_clipping: bool, pub histogram: bool, @@ -552,6 +687,9 @@ pub struct UiState { pub mask_overlay_color: [u8; 3], pub mask_overlay_opacity: f32, pub mask_pins: bool, + /// When the Remove tool's spot pins and the mask pins show (Shift+H cycles). Mask pins also + /// need [`mask_pins`](Self::mask_pins). + pub tool_overlay: ToolOverlay, /// Masking: show every visible mask's region as one overlay at once (Ctrl+Shift+O / Cmd+Shift+O), /// instead of just the selected mask's ([`mask_overlay`]). pub mask_overlay_all: bool, @@ -583,9 +721,10 @@ pub struct UiState { /// The person whose page the People view shows (their faces and the faces that look like them); `None`: everyone. #[serde(skip)] pub person_page: Option<String>, - /// The person page a photo in the loupe was opened from, and that photo: Escape goes back to the page (not the grid). + /// The person page a photo in the loupe was opened from (`None`: the People view's unnamed faces), and that photo: + /// Escape goes back there (not to the grid). #[serde(skip)] - pub person_from: Option<(String, u64)>, + pub person_from: Option<(Option<String>, u64)>, /// The last person page left: the People view offers a way back to it next to its title. #[serde(skip)] pub last_person: Option<String>, @@ -613,6 +752,15 @@ pub struct UiState { /// When the watched folder was last scanned (egui time). #[serde(skip)] pub auto_import_at: f64, + /// When the automatic XMP writes that failed were last tried again (egui time), and how many were waiting then. + #[serde(skip)] + pub sidecar_retry_at: f64, + #[serde(skip)] + pub sidecars_waiting: usize, + /// The cards and cameras already seen (volume roots) while Settings → Import → "Show the + /// import review when a camera or card is connected" is on; `None` = not looked yet. + #[serde(skip)] + pub known_cards: Option<Vec<String>>, /// The develop control whose slider is being dragged (geometry sliders show a grid). #[serde(skip)] pub dragging_control: Option<String>, @@ -672,6 +820,8 @@ pub struct UiState { pub brush_erase: bool, /// Brush Auto Mask: dabs stick to areas like the one under the brush centre. pub brush_auto_mask: bool, + /// Brush Pen Pressure: a pen's pressure scales each dab's size and flow (a mouse paints at full pressure). + pub brush_pressure: bool, /// Remove tool brush: size (fraction of the long edge), feather and opacity (0..100). pub remove_size: f32, pub remove_feather: f32, @@ -711,6 +861,14 @@ pub struct UiState { pub keyword_painter: Option<String>, /// What the Keywording box shows (Lightroom Classic's Keyword Tags views). pub keywording_view: KeywordingView, + /// The Keywording box, grouped: every keyword of this selection (its hash) is shown, however + /// many lines that takes ("+N more" was clicked). Another selection's list is short again. + #[serde(skip)] + pub keyword_chips_all: Option<u64>, + /// Sections of the right panel folded shut by a click on their title (`keywording`, + /// `keywordList`); the others are open. + #[serde(deserialize_with = "lenient_ids")] + pub folded_sections: Vec<String>, /// The Keyword List's open levels (lower-case paths). pub keyword_list_open: Vec<String>, /// The keyword picked in the Keyword List (− deletes it, Edit edits it). @@ -719,12 +877,13 @@ pub struct UiState { /// A keyword being dragged in the Keyword List (onto another to nest it). #[serde(skip)] pub dragging_keyword: Option<String>, - /// A running slideshow (full screen): seconds per photo, when the next one is due (egui - /// time), paused. + /// A running slideshow (full screen; see [`crate::panels::slideshow`]). #[serde(skip)] - pub slideshow: Option<(f64, f64, bool)>, + pub slideshow: Option<Box<crate::panels::slideshow::Run>>, /// Info overlay on the loupe. pub info_overlay: InfoOverlay, + /// The loupe's layout image overlay (View ▸ Loupe Overlay). + pub layout_image: LayoutImage, /// Navigator mini map in the loupe while zoomed in. pub navigator: bool, /// App preferences (Settings dialog). @@ -751,10 +910,17 @@ pub struct UiState { #[cfg(test)] #[serde(skip)] pub find_missing_test_delay: Option<std::time::Duration>, + /// The library (its `Session::library_generation`) whose preview-only raws were rechecked + /// (`crate::recheck`). + #[serde(skip)] + pub rechecked_library: Option<u64>, #[serde(skip)] pub status: String, #[serde(skip)] pub dialog: Option<Dialog>, + /// The Print dialog's page, selection and drag. + #[serde(skip)] + pub print: crate::print_view::PrintUi, } /// Settings group ids (`SettingsGroup` serde names) a new preset includes by default: everything @@ -783,9 +949,23 @@ pub struct NameEdit { #[derive(Clone, Debug, Serialize, Deserialize, PartialEq)] #[serde(tag = "kind", rename_all = "camelCase")] pub enum Dialog { + /// A Lightroom import result stays readable after its activity row disappears. + LightroomResult { + summary: String, + library: Option<String>, + details: Vec<String>, + first: Option<u64>, + }, + /// File ▸ Print…: the Print module. Its state is the session's (`print.*`); the page on show + /// and the selected cell are [`UiState::print`]. + Print, ContactSheet { options: lightcraft_engine::contact_sheet::Options, }, + /// View ▸ Export Slideshow…: format, size, quality, frame rate, container. + SlideshowExport { + settings: lightcraft_engine::slideshow_export::ExportSettings, + }, /// `parent`: the folder to create it in (none: the top level). `include_selected`: the new album /// holds the selected photos (Lightroom's "Include selected photos"; folders hold none). NewAlbum { @@ -840,6 +1020,13 @@ pub enum Dialog { from: String, to: String, }, + /// Rename every face of `from` to `to`. `merge` is the user's yes to combining with the person + /// `to` already names. + RenamePerson { + from: String, + to: String, + merge: bool, + }, /// Create a keyword, or edit one (`editing`): Lightroom Classic's Create / Edit Keyword Tag. KeywordTag { /// The keyword being edited; `None`: a new one. @@ -986,10 +1173,43 @@ pub enum Dialog { folder: String, catalog: Option<String>, }, + /// Save the selected mask's sliders as a mask effect preset: which sliders (`FIELDS` names, + /// the colour as `color_sat`), whether it carries Amount and the brush settings. + SaveEffect { + name: String, + group: String, + fields: Vec<String>, + amount: bool, + brush: bool, + }, + /// Manage Effect Presets: search, the selected effects, an inline rename (old, new), + /// a delete waiting for confirmation, a new group's name being typed. + ManageEffects { + search: String, + selected: Vec<String>, + rename: Option<(String, String)>, + confirm_delete: bool, + new_group: String, + }, + /// Confirm deleting mask effect presets. + DeleteEffects { + names: Vec<String>, + }, /// Confirm moving photos to Recently Deleted. ConfirmDelete { count: usize, }, + /// Confirm moving photos dropped on a Folders row into that folder on disk + /// (`photo.moveToFolder`). `name`: the folder's name, as the question says it. + MovePhotos { + ids: Vec<u64>, + folder: String, + name: String, + dont_ask: bool, + /// The library the photos were dropped in (`Session::library_generation`): ids are + /// numbered per library, so in another one they name other photos. + library: u64, + }, /// Confirm taking a folder's photos out of the library (`library.removeFolder`). RemoveFolder { path: String, @@ -1043,6 +1263,7 @@ pub struct SyncCounts { impl Default for UiState { fn default() -> Self { UiState { + window: None, docking: None, language: crate::i18n::default_language(), preview_build_seen: None, @@ -1077,6 +1298,7 @@ impl Default for UiState { mask_overlay_color: [230, 30, 40], mask_overlay_opacity: 50.0, mask_pins: true, + tool_overlay: ToolOverlay::Auto, mask_overlay_all: false, crop_overlay: CropOverlay::Thirds, crop_default_aspect: None, @@ -1101,6 +1323,9 @@ impl Default for UiState { external_edits: Vec::new(), was_focused: true, auto_import_at: 0.0, + sidecar_retry_at: 0.0, + sidecars_waiting: 0, + known_cards: None, search: String::new(), focus_search: false, renaming_mask: None, @@ -1124,6 +1349,7 @@ impl Default for UiState { brush_flow: 60.0, brush_erase: false, brush_auto_mask: false, + brush_pressure: true, remove_size: 0.02, remove_feather: 50.0, remove_opacity: 100.0, @@ -1141,10 +1367,13 @@ impl Default for UiState { second_window: false, keyword_painter: None, keywording_view: KeywordingView::default(), + keyword_chips_all: None, + folded_sections: Vec::new(), keyword_list_open: Vec::new(), keyword_list_selected: None, dragging_keyword: None, info_overlay: InfoOverlay::Off, + layout_image: LayoutImage::default(), navigator: true, settings: AppSettings::default(), window_fullscreen: None, @@ -1158,8 +1387,10 @@ impl Default for UiState { last_find_missing_result: None, #[cfg(test)] find_missing_test_delay: None, + rechecked_library: None, status: String::new(), dialog: None, + print: Default::default(), } } } @@ -1182,19 +1413,43 @@ impl UiState { self.grading_hidden = h.iter().any(|x| *x).then_some((photo, h)); } + /// A right-panel section that folds by its title is folded shut. + pub fn section_folded(&self, id: &str) -> bool { + self.folded_sections.iter().any(|s| s == id) + } + pub fn toggle_folded(&mut self, id: &str) { + if self.section_folded(id) { + self.folded_sections.retain(|s| s != id); + } else { + self.folded_sections.push(id.to_string()); + } + } + pub fn section_open(&self, id: &str) -> bool { self.open_sections.iter().any(|s| s == id) } pub fn toggle_section(&mut self, id: &str) { if self.section_open(id) { self.open_sections.retain(|s| s != id); + } else if self.single_panel { + self.solo_section(id); } else { - if self.single_panel { - self.open_sections.clear(); - } self.open_sections.push(id.to_string()); } } + /// Opens `id` and closes every other Edit section (⌥-click on a section header). + pub fn solo_section(&mut self, id: &str) { + self.open_sections.clear(); + self.open_sections.push(id.to_string()); + } + /// Turns Solo Mode on or off. Turning it on keeps only the section opened last. + pub fn set_single_panel(&mut self, on: bool) { + self.single_panel = on; + if on { + let keep = self.open_sections.len().saturating_sub(1); + self.open_sections.drain(..keep); + } + } /// Whether the sidebar section with this id is folded shut (an unknown id: no). pub fn sidebar_section_collapsed(&self, id: &str) -> bool { crate::sidebar::SidebarSection::from_id(id).is_some_and(|s| self.sidebar.is_collapsed(s)) @@ -1211,7 +1466,7 @@ impl UiState { } /// Clamp values restored from disk. pub fn sanitized(mut self) -> Self { - self.thumb_size = self.thumb_size.clamp(90.0, 480.0); + self.thumb_size = self.thumb_size.clamp(THUMB_SIZE_MIN, THUMB_SIZE_MAX); self.left_width = LEFT_WIDTH.clamp(self.left_width); self.right_width = RIGHT_WIDTH.clamp(self.right_width); self.brush_size = self.brush_size.clamp(0.002, 0.5); @@ -1230,6 +1485,19 @@ impl UiState { } } +/// A setting read leniently: a value that isn't one of its own (a hand-edited file, a newer +/// build's choice) is the default, and the rest of the settings file is still read. +fn lenient<'de, D: serde::Deserializer<'de>, T: Default + serde::de::DeserializeOwned>(d: D) -> Result<T, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value(value).unwrap_or_default()) +} + +/// A list of ids read leniently: what isn't a list is none, and what isn't text in it is left out. +fn lenient_ids<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(value.as_array().into_iter().flatten().filter_map(|v| v.as_str().map(str::to_string)).collect()) +} + fn deserialize_favorite_themes<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Vec<String>, D::Error> { let values = Vec::<String>::deserialize(d)?; let mut seen = std::collections::BTreeSet::new(); diff --git a/crates/ui-egui/src/tests_activity.rs b/crates/ui-egui/src/tests_activity.rs index 34c7f7491..bce9d2eb1 100644 --- a/crates/ui-egui/src/tests_activity.rs +++ b/crates/ui-egui/src/tests_activity.rs @@ -9,7 +9,8 @@ use crate::headless::Headless; use crate::panels::activity::{WIDTH, count_text}; use crate::{LightcraftApp, Services}; -const T: Duration = Duration::from_secs(20); +// generous: merges and renders run on worker threads that a busy machine (a full workspace test run) slows a lot +const T: Duration = Duration::from_secs(60); fn demo() -> Headless { let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); @@ -360,6 +361,84 @@ fn final_merge_shows_a_row_and_cancels() { let _ = std::fs::remove_dir_all(dir); } +/// The merge dialog opens with the options of the last merge of its kind (per kind), and "with Last +/// Settings" uses them; a kind never merged opens with the defaults. +#[test] +fn merge_dialog_remembers_the_last_settings_per_kind() { + let (mut h, dir) = bracket("merge-last"); + let opts = crate::merge::MergeDialog { + align: false, + deghost: "high".into(), + auto_settings: false, + ..crate::merge::MergeDialog::for_command("merge.hdr") + }; + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + h.app.ui.dialog = None; + crate::merge::start_final(&mut h.app, &opts).unwrap(); + let row = h.app.session.activity.list()[0].id; + h.app.session.activity.cancel(row).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + crate::merge::open(&mut h.app, "merge.hdr").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &opts); + crate::merge::open(&mut h.app, "merge.panorama").unwrap(); + let Some(crate::state::Dialog::Merge { opts: shown }) = &h.app.ui.dialog else { panic!("no merge dialog") }; + assert_eq!(shown, &crate::merge::MergeDialog::for_command("merge.panorama")); + h.app.ui.dialog = None; + // older or hand-edited preferences: missing fields take their defaults, junk is ignored + h.app.session.last_merge.insert("merge.panorama".into(), json!({"projection": "cylindrical", "command": "merge.hdr"})); + h.app.session.last_merge.insert("merge.hdrPanorama".into(), json!("junk")); + let pano = crate::merge::last_options(&h.app, "merge.panorama"); + assert_eq!((pano.command.as_str(), pano.projection.as_str(), pano.auto_crop), ("merge.panorama", "cylindrical", true)); + assert_eq!(crate::merge::last_options(&h.app, "merge.hdrPanorama"), crate::merge::MergeDialog::for_command("merge.hdrPanorama")); + let _ = std::fs::remove_dir_all(dir); +} + +/// HDR for Each Bracket Set runs the merges one after the other, each with its activity row; cancelling one stops the +/// rest. +#[test] +fn batch_merge_runs_each_set_in_turn() { + let dir = std::env::temp_dir().join(format!("lc-activity-batch-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut paths = Vec::new(); + // different exposures, or the import would take set B for duplicates of set A + for (set, evs) in [("A", [-2.0, 0.0, 2.0]), ("B", [-1.5, 0.5, 2.5]), ("C", [-1.0, 1.0, 3.0])] { + for (i, bytes) in lightcraft_merge::synth::bracket_dngs(1600, 1200, &evs).unwrap().into_iter().enumerate() { + let p = dir.join(format!("{set}_{i}.dng")); + std::fs::write(&p, bytes).unwrap(); + paths.push(p.to_string_lossy().to_string()); + } + } + let mut session = lightcraft_engine::Session::new().with_fs(); + let r = session.execute("library.import", &json!({"paths": paths})).unwrap(); + let ids: Vec<u64> = r["imported"].as_array().unwrap().iter().map(|v| v.as_u64().unwrap()).collect(); + assert_eq!(ids.len(), 9); + for set in ids.chunks(3) { + session.execute("stack.group", &json!({"ids": set})).unwrap(); + } + session.execute("library.select", &json!({"ids": ids})).unwrap(); + let app = LightcraftApp::new(session, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.settle(T); + let r = crate::menus::run_ui_command(&mut h.app, "merge.hdrBatch", &json!({})).unwrap().unwrap(); + assert_eq!(r["sets"], 3); + assert_eq!(h.app.merge.batch.len(), 2, "one running, two queued"); + let rows = h.app.session.activity.list(); + assert_eq!((rows.len(), rows.first().map(|r| r.kind)), (1, Some("merge"))); + // the first merge finishes and the second starts by itself + assert!(h.step_until(T, |h| h.app.merge.batch.len() == 1 && h.app.merge.final_task.is_some())); + assert_eq!(h.app.session.catalog.len(), 10); + // cancelling the second drops the third + let rows = h.app.session.activity.list(); + h.app.session.activity.cancel(rows[0].id).unwrap(); + assert!(h.step_until(T, |h| h.app.merge.final_task.is_none())); + assert!(h.app.merge.batch.is_empty()); + assert_eq!(h.app.session.catalog.len(), 10, "nothing more merged"); + assert!(h.app.session.activity.list().is_empty()); + let _ = std::fs::remove_dir_all(dir); +} + #[test] fn find_missing_shows_an_indeterminate_row() { let mut h = demo(); diff --git a/crates/ui-egui/src/tests_album_remove.rs b/crates/ui-egui/src/tests_album_remove.rs new file mode 100644 index 000000000..9c6f9d81f --- /dev/null +++ b/crates/ui-egui/src/tests_album_remove.rs @@ -0,0 +1,134 @@ +//! In an album, the Delete key takes the selected photos out of the album only (issue #1805): they +//! stay in the library and in their folder, as in Lightroom. Photo ▸ Delete Photo still moves them +//! to Recently Deleted, from everywhere. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::json; + +use crate::headless::Headless; +use crate::menubar::MenuNode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + h.step(); + h.step(); + r["result"].clone() +} + +/// The Photo menu's items: (id, shortcut). +fn photo_menu(h: &Headless) -> Vec<(String, Option<String>)> { + let bar = crate::menubar::menu_bar(&h.app); + let photo = &bar.iter().find(|(t, _)| t == "Photo").expect("Photo menu").1; + photo + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, shortcut, .. } => Some((id.clone(), shortcut.clone())), + _ => None, + }) + .collect() +} + +fn garden(h: &Headless) -> u64 { + h.app.session.catalog.albums().find(|a| a.name == "Garden").map(|a| a.id.0).expect("the demo's Garden album") +} + +fn in_album(h: &Headless, album: u64, photo: PhotoId) -> bool { + h.app.session.catalog.album(lightcraft_catalog::AlbumId(album)).is_some_and(|a| a.photos.contains(&photo)) +} + +/// Given an album, when a photo is selected in it and Delete is pressed, then the photo leaves the +/// album and nothing else: it is not deleted, nothing asks, and All Photos still shows it. +#[test] +fn the_delete_key_in_an_album_removes_from_the_album_only() { + let mut h = demo(); + assert!(h.app.ui.settings.confirm_delete, "the delete confirmation is on by default"); + let album = garden(&h); + exec(&mut h, "library.source", json!({"kind": "album", "id": album})); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + + let r = h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert_eq!(h.app.ui.dialog, None, "removing from an album asks nothing (it is undoable and keeps the photo)"); + assert!(!in_album(&h, album, photo), "the photo left the album"); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted, "the photo is not deleted from the library"); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert!(h.app.session.visible_cloned().contains(&photo), "All Photos still shows it"); + + // one undo brings it back into the album + exec(&mut h, "edit.undo", json!({})); + assert!(in_album(&h, album, photo)); +} + +/// In an album the Photo menu offers Remove from Album with the Delete key, and Delete Photo (no +/// key) still deletes from the library, after asking; outside albums there is no Remove from Album. +#[test] +fn the_photo_menu_in_an_album_has_remove_from_album_on_the_delete_key() { + let mut h = demo(); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let items = photo_menu(&h); + assert!(!items.iter().any(|(id, _)| id == "album.removePhotos"), "not outside albums: {items:?}"); + assert!(items.iter().any(|(id, sc)| id == "photo.delete" && sc.as_deref() == Some("Delete")), "{items:?}"); + + let album = garden(&h); + exec(&mut h, "library.source", json!({"kind": "album", "id": album})); + let photo = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let items = photo_menu(&h); + assert!(items.iter().any(|(id, sc)| id == "album.removePhotos" && sc.as_deref() == Some("Delete")), "{items:?}"); + assert!(items.iter().any(|(id, sc)| id == "photo.delete" && sc.is_none()), "{items:?}"); + + // Photo ▸ Remove from Album (no params: the album on screen) + crate::menubar::run_item(&mut h.app, "album.removePhotos", json!({})).unwrap(); + assert!(!in_album(&h, album, photo)); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted); + + // Photo ▸ Delete Photo still deletes from the library: it asks, then OK deletes + let other = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [other.0]})); + crate::menubar::run_item(&mut h.app, "photo.delete", json!({})).unwrap(); + assert_eq!(h.app.ui.dialog, Some(crate::state::Dialog::ConfirmDelete { count: 1 })); + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.session.catalog.photo(other).unwrap().deleted, "Delete Photo removes it from the library"); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert!(!h.app.session.visible_cloned().contains(&other)); +} + +/// A smart album's photos follow its rules: Delete there neither removes nor deletes anything. +#[test] +fn the_delete_key_in_a_smart_album_deletes_nothing() { + let mut h = demo(); + let r = exec(&mut h, "album.createSmart", json!({"name": "Rated", "rules": {"rating": 0, "ratingOp": "atLeast"}})); + let smart = r["id"].as_u64().expect("smart album id"); + exec(&mut h, "library.source", json!({"kind": "album", "id": smart})); + let photo = *h.app.session.visible_cloned().first().expect("the smart album shows photos"); + exec(&mut h, "library.select", json!({"ids": [photo.0]})); + let r = h.request("ui.key", json!({"key": "delete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.dialog, None); + assert!(!h.app.session.catalog.photo(photo).unwrap().deleted); + assert!(h.app.session.visible_cloned().contains(&photo)); + assert!(!photo_menu(&h).iter().any(|(id, _)| id == "album.removePhotos")); +} diff --git a/crates/ui-egui/src/tests_before_after.rs b/crates/ui-egui/src/tests_before_after.rs index 50ec66cf4..02ee879a1 100644 --- a/crates/ui-egui/src/tests_before_after.rs +++ b/crates/ui-egui/src/tests_before_after.rs @@ -1,10 +1,11 @@ -//! Headless tests of clicks in the two-view Before / After layouts. +//! Headless tests of input and zoom animation in the two-view Before / After layouts. use std::time::Duration; use serde_json::json; -use crate::headless::Headless; +use crate::headless::{Headless, RenderGate}; +use crate::render::Slot; use crate::state::{BeforeAfter, ViewMode, Zoom}; use crate::{LightcraftApp, Services}; @@ -70,6 +71,222 @@ fn wb_selector_picks_the_same_point_in_the_before_view() { } } +/// A click at Fit zooms in on the point clicked whichever view of a side-by-side or top/bottom +/// Before / After layout it lands in: both views then show the same part of the photo. A click +/// on the Before view used to be measured against the After view's rectangle, so it zoomed in on +/// the photo's edge (#1742). +#[test] +fn a_click_in_the_before_view_zooms_in_on_the_point_clicked() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + let fit = |h: &mut Headless, mode: BeforeAfter| { + h.app.ui.before_after = mode; + h.app.ui.zoom = Zoom::Fit; + h.app.ui.pan = Default::default(); + h.settle(SETTLE); + (h.app.image_rect.expect("After view on screen"), h.app.canvas_rect.expect("canvas")) + }; + for mode in [BeforeAfter::SideBySide, BeforeAfter::TopBottom] { + let (img, canvas) = fit(&mut h, mode); + let shift = match mode { + BeforeAfter::SideBySide => egui::vec2(img.left() + img.right() - 2.0 * canvas.center().x, 0.0), + _ => egui::vec2(0.0, img.top() + img.bottom() - 2.0 * canvas.center().y), + }; + assert!(shift.length() > 100.0, "{mode:?}: two views, {shift:?} apart"); + // off the middle of the photo, so that a zoom on another point shows + let after = img.center() + egui::vec2(img.width() * 0.3, img.height() * -0.25); + let mut pans = Vec::new(); + for before_view in [false, true] { + fit(&mut h, mode); + click_at(&mut h, if before_view { after - shift } else { after }); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{mode:?}: the click zoomed in ({:?})", h.app.ui.zoom); + pans.push(h.app.ui.pan); + } + let (a, b) = (pans[0], pans[1]); + assert!((a.0 - 0.8).abs() < 0.02 && (a.1 - 0.25).abs() < 0.02, "{mode:?}: the After view zooms in on the point clicked ({a:?})"); + assert!((b.0 - a.0).abs() < 1e-3 && (b.1 - a.1).abs() < 1e-3, "{mode:?}: the Before view zooms in on the same point ({b:?}, not {a:?})"); + } +} + +/// A texture mesh in its pane, excluding the Navigator's copy of the Main texture. +fn pane_mesh(h: &Headless, slot: Slot, pane: egui::Rect) -> egui::Rect { + let tex = &h.app.renderer.textures[&slot]; + let meshes: Vec<_> = h + .view + .shapes + .iter() + .filter_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == tex.tex.id() && c.clip_rect == pane => Some(m.calc_bounds()), + _ => None, + }) + .collect(); + assert_eq!(meshes.len(), 1, "{slot:?}: one image clipped to {pane:?}, got {meshes:?}"); + meshes[0] +} + +fn assert_rect(actual: egui::Rect, expected: egui::Rect) { + assert!( + (actual.min - expected.min).abs().max_elem() < 1.0 && (actual.max - expected.max).abs().max_elem() < 1.0, + "image bounds {actual:?}, expected {expected:?}" + ); +} + +/// Start with both textures ready, then hold replacement renders so worker timing cannot hide +/// a frame of the transition or replace a cached tile under the assertions. +fn animation_view(mode: BeforeAfter, zoom: Zoom) -> (Headless, [egui::Rect; 2], std::rc::Rc<std::cell::Cell<bool>>) { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "none", "filmstrip": false}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.before_after = mode; + h.app.ui.zoom = zoom; + h.app.ui.pan = (0.5, 0.5); + h.app.ui.click_zoom = 300; + assert!(h.settle(SETTLE), "baseline renders settled"); + let before_id = h.app.renderer.textures[&Slot::Before].tex.id(); + let before = h + .view + .shapes + .iter() + .find_map(|c| match &c.shape { + egui::epaint::Shape::Mesh(m) if m.texture_id == before_id => Some(c.clip_rect), + _ => None, + }) + .expect("Before image on screen"); + let canvas = h.app.canvas_rect.expect("canvas"); + let shift = 2.0 * (canvas.center() - before.center()); + let after = before.translate(shift); + assert!(canvas.contains_rect(before) && canvas.contains_rect(after), "both clips stay in the canvas"); + assert!(!before.intersects(after), "pane clips do not overlap"); + let open = std::rc::Rc::new(std::cell::Cell::new(false)); + h.app.renderer.set_offload(Box::new(RenderGate::new(open.clone()))); + (h, [before, after], open) +} + +fn queue_click(h: &mut Headless, pos: egui::Pos2) { + h.app.synthetic.extend([ + egui::Event::PointerMoved(pos), + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]); + while !h.app.synthetic.is_empty() { + h.step(); + } + assert!(h.app.ui.zoom_anim, "the click started an animation"); +} + +fn check_click_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Fit); + let shift = before.center() - after.center(); + let fit = h.app.image_rect.expect("Fit image"); + let id = h.app.session.active().expect("photo"); + let photo = h.app.session.catalog.photo(id).expect("photo"); + let native = [photo.width as usize, photo.height as usize]; + let aspect = native[0] as f32 / native[1] as f32; + // Exercise clicks in both panes, including their return to Fit, without settling the motion. + for before_click in [false, true] { + for zoom_in in [true, false] { + let pos = if zoom_in { fit.center() + egui::vec2(fit.width() * 0.3, fit.height() * -0.25) } else { after.center() }; + queue_click(&mut h, if before_click { pos + shift } else { pos }); + assert_eq!(h.app.ui.zoom, if zoom_in { Zoom::Percent(300.0) } else { Zoom::Fit }); + if zoom_in { + let pan = h.app.ui.pan; + assert!((pan.0 - 0.8).abs() < 0.02 && (pan.1 - 0.25).abs() < 0.02, "{mode:?}: clicked point {pan:?}"); + } + let target = crate::panels::detail::fit_rect(after, aspect, h.app.ui.zoom, native, 1.0, h.app.ui.pan); + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::Before), h.app.renderer.wanted(Slot::Main))); + let img = h.app.image_rect.expect("animated image"); + let between = img.width() > fit.width() + 1.0 && img.width() < native[0] as f32 * 3.0 - 1.0; + if between { + intermediate += 1; + } + if between || !h.app.ui.zoom_anim { + let a = pane_mesh(&h, Slot::Main, after); + let b = pane_mesh(&h, Slot::Before, before); + assert_rect(b, a.translate(shift)); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate animation frames"); + assert!(!h.app.ui.zoom_anim, "animation finished"); + assert_rect(h.app.image_rect.expect("settled image"), target); + assert_rect(pane_mesh(&h, Slot::Main, after), crate::panels::detail::fit_texture_rect(target, h.app.renderer.textures[&Slot::Main].size)); + assert_eq!(keys.len(), 1, "both panes request stable final-size renders throughout the animation: {keys:?}"); + } + } + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_side_by_side() { + check_click_animation(BeforeAfter::SideBySide); +} + +#[test] +fn both_before_after_panes_use_the_animated_rect_top_bottom() { + check_click_animation(BeforeAfter::TopBottom); +} + +fn check_region_animation(mode: BeforeAfter) { + let (mut h, [before, after], open) = animation_view(mode, Zoom::Percent(200.0)); + let shift = before.center() - after.center(); + let start = h.app.image_rect.expect("200 percent image"); + let tiles = [(Slot::RegionBefore, before, true), (Slot::Region, after, false)].map(|(slot, pane, is_before)| { + let tex = &h.app.renderer.textures[&slot]; + let tile = h.app.region_tiles[&(is_before, tex.key)]; + pane_mesh(&h, slot, pane); + (slot, pane, tile, tex.key) + }); + h.app.ui.zoom = Zoom::Percent(400.0); + h.app.ui.zoom_anim = true; + let mut intermediate = 0; + let mut keys = std::collections::HashSet::new(); + for _ in 0..40 { + h.step(); + keys.insert((h.app.renderer.wanted(Slot::RegionBefore), h.app.renderer.wanted(Slot::Region))); + let img = h.app.image_rect.expect("animated image"); + if img.width() > start.width() + 1.0 && img.width() < start.width() * 2.0 - 1.0 { + intermediate += 1; + } + if img.width() <= start.width() + 1.0 { + continue; + } + for (slot, pane, tile, key) in tiles { + assert_eq!(h.app.renderer.textures[&slot].key, key, "the cached tile stays on screen while renders wait"); + let full = if tile.before { img.translate(shift) } else { img }; + let (fw, fh) = (tile.full.0 as f32, tile.full.1 as f32); + let w = tile.window; + let expected = egui::Rect::from_min_size( + full.min + egui::vec2(w.x as f32 / fw * full.width(), w.y as f32 / fh * full.height()), + egui::vec2(w.w as f32 / fw * full.width(), w.h as f32 / fh * full.height()), + ); + assert_rect(pane_mesh(&h, slot, pane), expected); + } + } + assert!(intermediate > 0, "{mode:?}: inspected intermediate region frames"); + assert!(!h.app.ui.zoom_anim); + assert!((h.app.image_rect.expect("settled image").width() - start.width() * 2.0).abs() < 1.0); + assert_eq!(keys.len(), 1, "region requests use the final target throughout the animation: {keys:?}"); + open.set(true); + assert!(h.settle(SETTLE)); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_side_by_side() { + check_region_animation(BeforeAfter::SideBySide); +} + +#[test] +fn before_after_region_tiles_use_the_animated_rect_top_bottom() { + check_region_animation(BeforeAfter::TopBottom); +} + /// Mean (r, g, b) of the 41 × 41 screen pixels around `at`. fn mean_around(shot: &egui::ColorImage, at: egui::Pos2) -> [f32; 3] { let mut sum = [0.0f32; 3]; diff --git a/crates/ui-egui/src/tests_browse_active.rs b/crates/ui-egui/src/tests_browse_active.rs new file mode 100644 index 000000000..f593883ff --- /dev/null +++ b/crates/ui-egui/src/tests_browse_active.rs @@ -0,0 +1,95 @@ +//! Showing a Local folder picks the active photo the way every other source does (issue #1447): +//! the current one when the folder shows it, else the folder's first photo, so Photo Grid marks +//! it, the info and histogram describe it, and Detail opens on it instead of a photo from +//! another source. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use serde_json::{Value, json}; + +use crate::LightcraftApp; +use crate::headless::Headless; +use crate::state::ViewMode; + +const T: Duration = Duration::from_secs(10); +const SETTLE: Duration = Duration::from_secs(120); + +fn demo() -> Headless { + let services = crate::Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.view = ViewMode::PhotoGrid; + Headless::new(app, [1300.0, 900.0], 1.0) +} + +fn exec(h: &mut Headless, command: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command} {params}: {r}"); + h.settle(SETTLE); + r +} + +/// A folder of `n` small PNGs (different bytes each) in the temporary folder. +fn folder(name: &str, n: u8) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-ui-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + for i in 0..n { + let img = lightcraft_raster::Rgba8::from_fn(24, 16, |x, y| [(x * 9) as u8, (y * 12) as u8, 40 * i, 255]); + std::fs::write(dir.join(format!("p{i}.png")), lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + } + dir +} + +fn active(h: &Headless) -> Option<PhotoId> { + h.app.session.selection.active +} + +#[test] +fn browsing_a_local_folder_makes_its_first_photo_active() { + let mut h = demo(); + h.settle(SETTLE); + let dir = folder("browse-active", 3); + // a library photo is active: the folder doesn't show it + let before = h.app.session.visible_cloned()[0]; + exec(&mut h, "library.select", json!({"ids": [before.0]})); + exec(&mut h, "library.browse", json!({"path": dir.to_string_lossy()})); + let vis = h.app.session.visible_cloned(); + assert_eq!(vis.len(), 3); + assert_eq!(active(&h), Some(vis[0]), "the folder's first photo is active, not a photo the grid doesn't show"); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "and selected"); + // the grid marks it, and Detail opens on it + assert!(h.app.widgets.iter().any(|(w, _)| *w == format!("thumb:{}", vis[0].0))); + h.request("ui.set", json!({"view": "detail"}), T); + h.settle(SETTLE); + assert_eq!(active(&h), Some(vis[0])); + + // the folder shows the current photo again: it stays active + h.request("ui.set", json!({"view": "photoGrid"}), T); + exec(&mut h, "library.select", json!({"ids": [vis[2].0]})); + exec(&mut h, "library.source", json!({"kind": "all"})); + assert_ne!(active(&h), Some(vis[2]), "All Photos doesn't list Local photos"); + exec(&mut h, "library.select", json!({"ids": [vis[2].0]})); + exec(&mut h, "library.browse", json!({"path": dir.to_string_lossy()})); + assert_eq!(active(&h), Some(vis[2]), "a photo the folder shows stays active"); + let _ = std::fs::remove_dir_all(&dir); +} + +/// An album behaves the same (it already did: kept here as the rule's reference). +#[test] +fn choosing_an_album_keeps_or_picks_the_active_photo() { + let mut h = demo(); + h.settle(SETTLE); + let album = h.app.session.catalog.albums().find(|a| !a.folder && !a.is_smart() && a.photos.len() > 1).map(|a| (a.id, a.photos.clone())); + let (album, photos) = album.expect("the demo library has an album"); + let outside = h.app.session.visible_cloned().into_iter().find(|p| !photos.contains(p)).expect("a photo outside the album"); + exec(&mut h, "library.select", json!({"ids": [outside.0]})); + exec(&mut h, "library.source", json!({"kind": "album", "id": album.0})); + let vis = h.app.session.visible_cloned(); + assert_eq!(active(&h), vis.first().copied()); + exec(&mut h, "library.select", json!({"ids": [vis[1].0]})); + exec(&mut h, "library.source", json!({"kind": "all"})); + exec(&mut h, "library.source", json!({"kind": "album", "id": album.0})); + assert_eq!(active(&h), Some(vis[1])); +} diff --git a/crates/ui-egui/src/tests_button.rs b/crates/ui-egui/src/tests_button.rs new file mode 100644 index 000000000..e06948fff --- /dev/null +++ b/crates/ui-egui/src/tests_button.rs @@ -0,0 +1,159 @@ +//! The button component: its sizes, its looks in every theme and state, what screen readers hear, +//! and that a disabled one can't be clicked. + +use egui::{Color32, Pos2, Rect, pos2, vec2}; + +use crate::button::{Button, Kind, Size, State, contrast, look}; +use crate::icons::Icon; +use crate::theme::{ThemeKind, Tokens}; + +/// Run frames of `draw` (with pointer `events` in the second), returning the AccessKit nodes' +/// (role, name) and what `draw` reported. +fn frames<T: Default>(events: Vec<egui::Event>, mut draw: impl FnMut(&mut egui::Ui) -> T) -> (Vec<(String, String)>, T) { + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut found = Vec::new(); + let mut last = T::default(); + for frame in 0..3 { + let raw = egui::RawInput { + screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), + events: if frame == 1 { events.clone() } else { Vec::new() }, + ..Default::default() + }; + let mut out = ctx.run_ui(raw, |ui| last = draw(ui)); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + found = update.nodes.iter().map(|(_, n)| (format!("{:?}", n.role()), n.label().unwrap_or_default().to_string())).collect(); + } + } + (found, last) +} + +#[test] +fn the_three_sizes_and_an_icon_alone() { + let (_, sizes) = frames(Vec::new(), |ui| { + [Size::Small, Size::Medium, Size::Large] + .map(|s| (Button::new("Import Photos…").size(s).desired_size(ui), Button::icon_only(Icon::Trash, "Delete").size(s).desired_size(ui))) + .to_vec() + }); + let heights: Vec<f32> = sizes.iter().map(|(text, _)| text.y).collect(); + assert_eq!(heights, vec![24.0, 30.0, 36.0]); + for (text, icon) in &sizes { + assert_eq!(icon.x, icon.y, "an icon alone is square"); + assert!(text.x > icon.x, "text makes it wider"); + } + // an icon beside the text makes it wider still, and a minimum width holds + let (_, (plain, with_icon, wide)) = frames(Vec::new(), |ui| { + ( + Button::new("Remove").desired_size(ui).x, + Button::new("Remove").icon(Icon::Trash).desired_size(ui).x, + Button::new("OK").min_width(120.0).desired_size(ui).x, + ) + }); + assert!(with_icon > plain); + assert_eq!(wide, 120.0); +} + +#[test] +fn a_screen_reader_hears_every_button_by_its_name() { + let (found, _) = frames(Vec::new(), |ui| { + Button::new("Import Photos…").primary().show(ui); + Button::icon_only(Icon::Trash, "Delete mask").ghost().show(ui); + Button::new("Remove").icon(Icon::Trash).danger().enabled(false).show(ui); + }); + let has = |label: &str| found.iter().any(|(r, l)| r == "Button" && l == label); + assert!(has("Import Photos…"), "{found:?}"); + assert!(has("Delete mask"), "an icon alone is named: {found:?}"); + assert!(has("Remove"), "a disabled one is still announced: {found:?}"); +} + +#[test] +fn text_on_a_fill_reads_at_aa_in_every_theme_and_state() { + for kind in ThemeKind::ALL { + let t = Tokens::for_kind(kind); + for k in [Kind::Primary, Kind::Danger, Kind::Secondary] { + for state in [State::Idle, State::Hovered, State::Pressed] { + let lk = look(&t, k, state); + let fill = lk.fill.unwrap_or(t.canvas); + let c = contrast(lk.content, fill); + assert!(c >= 4.5, "{kind:?} {k:?} {state:?}: {c:.2}:1"); + } + } + // a ghost's text on the surfaces it sits on + let ghost = look(&t, Kind::Ghost, State::Idle); + assert!(ghost.fill.is_none()); + assert!(contrast(ghost.content, t.chrome) >= 4.5, "{kind:?} ghost on chrome"); + } +} + +#[test] +fn hover_and_press_are_darker_and_disabled_is_faded() { + let t = Tokens::for_kind(ThemeKind::Charcoal); + let luma = |c: Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); + let fill = |s| look(&t, Kind::Primary, s).fill.map(luma).unwrap_or(0); + assert!(fill(State::Hovered) < fill(State::Idle)); + assert!(fill(State::Pressed) < fill(State::Hovered)); + let disabled = look(&t, Kind::Primary, State::Disabled); + assert!(disabled.content.a() < 255, "faded"); +} + +#[test] +fn a_disabled_button_cannot_be_clicked() { + let at = pos2(40.0, 15.0); + let click = vec![ + egui::Event::PointerMoved(at), + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }, + egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }, + ]; + let place = Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0)); + let mut disabled_clicked = false; + let _ = frames(click.clone(), |ui| { + if Button::new("Delete").danger().enabled(false).show_at(ui, place).clicked() { + disabled_clicked = true; + } + }); + assert!(!disabled_clicked, "on any frame"); + let mut seen = false; + let _ = frames(click, |ui| { + if Button::new("Delete").danger().show_at(ui, place).clicked() { + seen = true; + } + }); + assert!(seen, "an enabled one is"); +} + +#[test] +fn buttons_in_one_place_with_the_same_text_are_told_apart() { + // two "Delete mask" buttons side by side, placed by the caller: two widgets, not one + let (found, _) = frames(Vec::new(), |ui| { + let a = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(0.0, 0.0), vec2(30.0, 30.0))); + let b = Button::icon_only(Icon::Trash, "Delete mask").show_at(ui, Rect::from_min_size(pos2(40.0, 0.0), vec2(30.0, 30.0))); + assert_ne!(a.id, b.id); + }); + assert_eq!(found.iter().filter(|(r, l)| r == "Button" && l == "Delete mask").count(), 2, "{found:?}"); +} + +#[test] +fn a_button_in_a_disabled_part_of_the_window_says_it_is_disabled() { + // (egui refuses the click there anyway: what the button adds is looking and reading disabled) + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + ctx.enable_accesskit(); + let mut disabled = None; + for _ in 0..3 { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(800.0, 600.0))), ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| { + ui.add_enabled_ui(false, |ui| { + let resp = Button::new("Delete").show_at(ui, Rect::from_min_size(Pos2::ZERO, vec2(120.0, 30.0))); + // drawn as disabled: it asks for hover only, as a button turned off does + assert!(!resp.sense.senses_click(), "drawn as a button that can be clicked"); + }); + }); + out.textures_delta.clear(); + if let Some(update) = out.platform_output.accesskit_update.take() { + disabled = update.nodes.iter().find(|(_, n)| n.label() == Some("Delete")).map(|(_, n)| n.is_disabled()); + } + } + assert_eq!(disabled, Some(true), "a screen reader hears it disabled"); +} diff --git a/crates/ui-egui/src/tests_canvas_keys.rs b/crates/ui-egui/src/tests_canvas_keys.rs new file mode 100644 index 000000000..c78d80820 --- /dev/null +++ b/crates/ui-egui/src/tests_canvas_keys.rs @@ -0,0 +1,147 @@ +//! The canvas tools by keyboard: crop handles, spot removals, gradient handles and mask pins are +//! reached with Tab or focus, moved with the arrow keys (Shift: further), one undo step a press. + +use std::time::Duration; + +use lightcraft_develop::MaskShape; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services, canvas_keys}; + +const T: Duration = Duration::from_secs(20); + +fn detail(panel: &str) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + // pins on whatever the pointer does: these tests move handles with the keyboard, and a headless + // pointer is never over the photo, where the Tool Overlay's default Auto shows them + h.app.ui.tool_overlay = crate::state::ToolOverlay::Always; + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn develop(h: &Headless) -> lightcraft_develop::DevelopSettings { + let id = h.app.session.active().expect("active photo"); + (*h.app.session.develop_of(id).unwrap_or_default()).clone() +} + +fn focus(h: &mut Headless, key: &str) { + h.step(); + h.view.ctx.memory_mut(|m| m.request_focus(canvas_keys::id(key))); + h.step(); + h.step(); + assert_eq!(h.view.ctx.memory(|m| m.focused()), Some(canvas_keys::id(key)), "{key} takes the focus"); +} + +fn key(h: &mut Headless, name: &str, shift: bool) { + let r = h.request("ui.key", json!({"key": name, "shift": shift}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +#[test] +fn crop_corner_moves_with_the_arrows_and_shift_moves_further() { + let mut h = detail("panel.crop"); + let photo = h.app.session.active(); + let r0 = develop(&h).crop.geometry.rect; + focus(&mut h, "crop:2"); + key(&mut h, "left", false); + let r1 = develop(&h).crop.geometry.rect; + assert!(r1.x1 < r0.x1 - 0.001 && (r1.x0 - r0.x0).abs() < 1e-9, "{r0:?} -> {r1:?}"); + key(&mut h, "up", true); + let r2 = develop(&h).crop.geometry.rect; + assert!(r2.y1 < r1.y1 - 0.03, "Shift is a big step: {r1:?} -> {r2:?}"); + // the arrows moved the handle, not to another photo + assert_eq!(h.app.session.active(), photo); + // one undo step a press + exec(&mut h, "edit.undo", json!({})); + assert!((develop(&h).crop.geometry.rect.y1 - r1.y1).abs() < 1e-9); + // the whole box moves with the box's handle + focus(&mut h, "crop:8"); + let before = develop(&h).crop.geometry.rect; + key(&mut h, "right", false); + let after = develop(&h).crop.geometry.rect; + assert!((after.x1 - after.x0 - (before.x1 - before.x0)).abs() < 1e-6, "{before:?} -> {after:?}"); +} + +#[test] +fn tab_reaches_the_crop_handles() { + let mut h = detail("panel.crop"); + let handles: Vec<egui::Id> = (0..9).map(|i| canvas_keys::id(&format!("crop:{i}"))).collect(); + let mut reached = false; + for _ in 0..200 { + key(&mut h, "tab", false); + if h.view.ctx.memory(|m| m.focused()).is_some_and(|f| handles.contains(&f)) { + reached = true; + break; + } + } + assert!(reached, "Tab reaches a crop handle"); +} + +#[test] +fn spots_are_added_and_moved_by_keyboard() { + let mut h = detail("panel.remove"); + assert!(develop(&h).spots.is_empty()); + focus(&mut h, "spot:new"); + key(&mut h, "right", true); + key(&mut h, "enter", false); + let spots = develop(&h).spots; + assert_eq!(spots.len(), 1, "Return adds a spot"); + let p = spots[0].points[0]; + assert!((p.x - 0.55).abs() < 1e-6 && (p.y - 0.5).abs() < 1e-6, "{p:?}"); + focus(&mut h, "spot:0:target"); + key(&mut h, "down", false); + let q = develop(&h).spots[0].points[0]; + assert!((q.y - p.y - canvas_keys::STEP).abs() < 1e-6, "{p:?} -> {q:?}"); + if develop(&h).spots[0].source_offset.is_some() { + let o = develop(&h).spots[0].source_offset; + focus(&mut h, "spot:0:source"); + key(&mut h, "left", false); + assert_ne!(develop(&h).spots[0].source_offset, o, "the source moves"); + } +} + +#[test] +fn gradient_handles_and_pins_move_by_keyboard() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.5, 0.2], "end": [0.5, 0.7]})); + h.step(); + let mid = h.app.session.active_mask.expect("a selected mask"); + focus(&mut h, &format!("mask:{mid}:0:2")); + key(&mut h, "down", false); + let MaskShape::Linear { start, end } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((end.y - 0.7 - canvas_keys::STEP).abs() < 1e-3 && (start.y - 0.2).abs() < 1e-6, "{start:?} {end:?}"); + // the pin moves the whole gradient + let r = h.request("ui.set", json!({"maskPins": true}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + focus(&mut h, &format!("mask:{mid}:0:0")); + key(&mut h, "right", true); + let MaskShape::Linear { start: s2, end: e2 } = develop(&h).masks[0].components[0].shape.clone() else { panic!("linear") }; + assert!((s2.x - start.x - canvas_keys::BIG_STEP).abs() < 1e-3 && (e2.x - end.x - canvas_keys::BIG_STEP).abs() < 1e-3); + // a radial gradient: its right edge widens it + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.5, 0.5], "rx": 0.2, "ry": 0.1})); + h.step(); + let rid = h.app.session.active_mask.expect("the radial mask"); + let rx = |h: &Headless| match develop(h).masks.iter().find(|m| m.id == rid).map(|m| m.components[0].shape.clone()) { + Some(MaskShape::Radial { rx, .. }) => rx, + other => panic!("{other:?}"), + }; + let rx0 = rx(&h); + focus(&mut h, &format!("mask:{rid}:0:1")); + key(&mut h, "right", true); + assert!(rx(&h) > rx0, "{rx0} -> {}", rx(&h)); +} diff --git a/crates/ui-egui/src/tests_crop_rotate.rs b/crates/ui-egui/src/tests_crop_rotate.rs index 8e27411eb..848b1b035 100644 --- a/crates/ui-egui/src/tests_crop_rotate.rs +++ b/crates/ui-egui/src/tests_crop_rotate.rs @@ -121,7 +121,7 @@ fn the_straighten_value_says_it_can_be_typed() { // tooltips wait for the pointer to rest let _ = h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t.contains("type"))); assert!( - h.painted_text().iter().any(|t| t == "Click to type a value"), + h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"), "{:?}", h.painted_text().iter().filter(|t| t.contains("lick")).collect::<Vec<_>>() ); @@ -415,3 +415,42 @@ fn rotating_the_crop_dims_nothing_inside_the_box() { let lum = |c: egui::Color32| u32::from(c.r()) + u32::from(c.g()) + u32::from(c.b()); assert!(lum(pixel(&rotated, corner_px)) < lum(pixel(&plain, corner_px)), "the photo's corner, outside the box, is dimmed"); } + +fn crop_rect(h: &Headless) -> lightcraft_geom::Rect { + let id = h.app.session.active().expect("a photo"); + h.app.session.catalog.photo(id).expect("the photo").develop.crop.geometry.rect +} + +/// Press where the pointer shows the resize cursor for the left edge handle, `dx` px from the +/// edge, and drag on by `step` px per event: the drag must resize, as the cursor said (issue #1930). +fn drag_left_handle(dx: f32, step: f32) -> Headless { + let mut h = crop_tool(); + let img = h.app.image_rect.expect("the photo on screen"); + hover(&mut h, egui::pos2(img.left() + dx, img.center().y)); + assert_eq!(h.last_cursor, egui::CursorIcon::ResizeHorizontal, "the pointer shows a resize"); + let x = |px: f32| px / img.width(); + let events: Vec<_> = std::iter::once(json!({"kind": "down", "x": x(dx), "y": 0.5})) + .chain((1..=4).map(|i| json!({"kind": "drag", "x": x(dx + step * i as f32), "y": 0.5}))) + .collect(); + let r = h.request("ui.pointer", json!({ "events": events }), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// Just outside the box, inside the handle's reach: the drag resizes, it does not rotate. +#[test] +fn a_drag_from_just_outside_a_handle_resizes_not_rotates() { + let h = drag_left_handle(-8.0, -3.0); + assert_eq!(angle(&h), 0.0, "the resize cursor showed, so nothing rotates"); + assert!(!has(&h, "cropAngleReadout"), "no rotation under way"); +} + +/// Just inside the box, inside the handle's reach: the drag moves the left edge, not the box. +#[test] +fn a_drag_from_just_inside_a_handle_resizes_not_moves() { + let h = drag_left_handle(8.0, 3.0); + let r = crop_rect(&h); + assert!(r.x0 > 0.0, "the left edge moved in: {r:?}"); + assert!((r.x1 - 1.0).abs() < 1e-6, "the right edge stays: {r:?}"); +} diff --git a/crates/ui-egui/src/tests_dialog_fit.rs b/crates/ui-egui/src/tests_dialog_fit.rs index 0014c6bdb..52d412829 100644 --- a/crates/ui-egui/src/tests_dialog_fit.rs +++ b/crates/ui-egui/src/tests_dialog_fit.rs @@ -93,10 +93,11 @@ fn export_dialog_is_unchanged_in_a_tall_window() { let h = export_dialog([1600.0, 1000.0]); assert_fits(&h, "Export at 1600×1000"); let dialog = rect(&h, "dialog:window"); - // (846 tall as before #781: the whole dialog, nothing scrolled away; 615 wide since the - // format row gained JPEG XL, #780. On macOS the dialog has one more row, "Add to Apple Photos" - // (#236), so it is 876 tall there) - let height = if cfg!(target_os = "macos") { 876.0 } else { 846.0 }; + // (846 tall as before #781: the whole dialog, nothing scrolled away, plus the Export to row + // since #956; 615 wide since the format row gained JPEG XL, #780. On macOS the dialog has one + // more row, "Add to Apple Photos" (#236), so it is 30 taller there. 8 more since its footer's + // buttons are the button component's medium size, 30 points, as the welcome's and every dialog's) + let height = if cfg!(target_os = "macos") { 916.0 } else { 886.0 }; assert!((dialog.height() - height).abs() <= 1.0, "{dialog:?}"); assert!((dialog.width() - 615.2).abs() <= 1.0, "{dialog:?}"); } @@ -134,3 +135,26 @@ fn a_dialog_list_shrinks_to_fit_a_short_window() { let dialog = rect(&h, "dialog:window"); assert!(dialog.height() <= 320.0, "{dialog:?}"); } + +/// Settings ▸ Performance: the GPU status line above the controls stays one line whatever it says +/// (without a driver it is a long wgpu message, e.g. on the FreeBSD CI), so the controls below it +/// don't move when the process-wide GPU state changes between two frames (a click aimed at +/// "Memory for caches" then landed on the gap above it). +#[test] +fn gpu_status_never_moves_the_performance_controls() { + let mut h = demo([1300.0, 820.0]); + let r = h.request("ui.key", json!({"key": ",", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:settingsTab-performance"}), T); + assert_eq!(r["ok"], true, "{r}"); + let mut seen = vec![]; + for gpu in [true, false, true] { + // the setting is applied process-wide at the next frame (`app.gpu`), as other tests do + h.app.ui.settings.gpu = gpu; + h.step(); + h.step(); + seen.push((gpu, rect(&h, "button:settingsMemory-2"), lightcraft_engine::gpu::unavailable_reason())); + } + let first = seen[0].1; + assert!(seen.iter().all(|(_, r, _)| *r == first), "the memory buttons moved: {seen:?}"); +} diff --git a/crates/ui-egui/src/tests_dialog_primary.rs b/crates/ui-egui/src/tests_dialog_primary.rs new file mode 100644 index 000000000..29078d009 --- /dev/null +++ b/crates/ui-egui/src/tests_dialog_primary.rs @@ -0,0 +1,74 @@ +//! A dialog's confirm button reads as its primary action: on the right, Cancel to its left, and +//! filled with the accent when it makes or brings in something (Import, Merge, Save…). A +//! destructive confirm (Delete, Remove) stays plain: never the eye-catching default. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::dialogs::{ConfirmStyle, confirm_style}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} not on screen")) +} + +fn png(dir: &std::path::Path, name: &str) -> String { + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let bytes = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let p = dir.join(name); + std::fs::write(&p, bytes).unwrap(); + p.to_string_lossy().to_string() +} + +#[test] +fn the_import_review_puts_its_confirm_on_the_right_as_the_primary_action() { + // Given the import review of two photos + let dir = std::env::temp_dir().join(format!("lc-ui-dialog-primary-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let paths = [png(&dir, "a.png"), png(&dir, "b.png")]; + let app = LightcraftApp::new(lightcraft_engine::Session::new().with_fs(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("file.addPhotos", json!({"paths": paths})).unwrap(); + h.step_until(T, |h| h.app.widgets.iter().any(|(w, _)| w == "button:dialogOk")); + h.step(); + // Then "Import 2 Photos" is right of Cancel, at the dialog's right edge + let (ok, cancel, window) = (rect(&h, "button:dialogOk"), rect(&h, "button:dialogCancel"), rect(&h, "dialog:window")); + assert!(ok.left() > cancel.right(), "confirm {ok:?} right of cancel {cancel:?}"); + assert!(window.right() - ok.right() < 40.0, "confirm {ok:?} at the right edge of {window:?}"); + let _ = std::fs::remove_dir_all(&dir); +} + +#[test] +fn constructive_confirms_are_primary_and_destructive_ones_plain() { + use crate::state::Dialog; + assert_eq!(confirm_style(&Dialog::AutoStack { gap: 60.0 }), ConfirmStyle::Primary); + assert_eq!(confirm_style(&Dialog::ConfirmDelete { count: 3 }), ConfirmStyle::Plain); + // a dialog that only shows something has nothing to confirm: its Close is plain + assert_eq!(confirm_style(&Dialog::About), ConfirmStyle::Plain); +} + +#[test] +fn a_dialog_wider_than_its_content_still_ends_its_footer_at_its_edge() { + // Auto-Stack's title makes it wider than its slider and note: the buttons go to the edge, and + // the window doesn't grow for them + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, [1400.0, 1000.0], 1.0); + h.step(); + h.app.run("dialog.autoStack", json!({})).unwrap(); + for _ in 0..4 { + h.step(); + } + let (ok, window) = (rect(&h, "button:dialogOk"), rect(&h, "dialog:window")); + assert!(window.right() - ok.right() < 24.0, "confirm {ok:?} at the right edge of {window:?}"); + let width = window.width(); + for _ in 0..4 { + h.step(); + } + assert_eq!(rect(&h, "dialog:window").width(), width, "the footer doesn't widen the window"); +} diff --git a/crates/ui-egui/src/tests_docking_acceptance.rs b/crates/ui-egui/src/tests_docking_acceptance.rs index 0e3c499a7..1dc97892e 100644 --- a/crates/ui-egui/src/tests_docking_acceptance.rs +++ b/crates/ui-egui/src/tests_docking_acceptance.rs @@ -21,6 +21,22 @@ fn dock(h: &mut Headless, p: serde_json::Value) { fn rect(h: &Headless, id: &str) -> egui::Rect { h.app.widgets.iter().find(|(key, _)| key == id).unwrap().1 } + +#[test] +fn the_gaps_between_dock_panes_show_chrome_not_the_dark_clear_colour() { + let mut h = app(); + let image = h.snapshot(std::time::Duration::from_secs(5)); + let pixel = |p: egui::Pos2| image.pixels[p.y as usize * image.size[0] + p.x as usize]; + // each panel's own chrome just inside its edge stroke, and the dock's gap just outside it + let right = rect(&h, "panel:right_panel"); + let left = rect(&h, "panel:left_panel"); + for (panel, inside, gap) in [ + ("Tools", egui::pos2(right.left() + 3.0, right.center().y), egui::pos2(right.left() - 2.0, right.center().y)), + ("Library", egui::pos2(left.right() - 3.0, left.center().y), egui::pos2(left.right() + 2.0, left.center().y)), + ] { + assert_eq!(pixel(gap), pixel(inside), "the gap beside {panel} at {gap:?} is not chrome"); + } +} fn release(h: &mut Headless, pos: egui::Pos2) { h.app.synthetic.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed: true, modifiers: Modifiers::NONE }); h.step(); diff --git a/crates/ui-egui/src/tests_edit_layout.rs b/crates/ui-egui/src/tests_edit_layout.rs new file mode 100644 index 000000000..7c6d79ff0 --- /dev/null +++ b/crates/ui-egui/src/tests_edit_layout.rs @@ -0,0 +1,68 @@ +//! The Edit panel at its narrowest with a long profile applied: the profile row fits (#1759). + +use egui::{Rect, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::edit::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn a_long_profile_name_leaves_the_edit_panel_its_width() { + let width = crate::state::RIGHT_WIDTH.min; + let (texts, widgets) = panel(width); + let rect = |id: &str| widgets.iter().find(|(w, _)| w == id).unwrap_or_else(|| panic!("{id} not drawn")).1; + let dropdown = rect("dropdown:profile"); + let browse = rect("icon:profileBrowser"); + assert!(dropdown.right() <= browse.left(), "the profile menu {dropdown:?} runs into Browse Profiles {browse:?}"); + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME).unwrap(); + assert!(name.galley.elided, "a name wider than the row is cut short"); + assert!(name.visual_bounding_rect().right() <= dropdown.right(), "the name leaves its menu"); + // every slider drawn, from the profile's Amount down + rect("sliderValue:profile.amount"); + rect("sliderValue:light.exposure"); + for (id, r) in widgets.iter().filter(|(id, _)| id.starts_with("slider")) { + assert!(r.right() <= width, "{id} {r:?} is cut off by the {width} pt panel"); + } +} diff --git a/crates/ui-egui/src/tests_empty_library.rs b/crates/ui-egui/src/tests_empty_library.rs new file mode 100644 index 000000000..2207875b9 --- /dev/null +++ b/crates/ui-egui/src/tests_empty_library.rs @@ -0,0 +1,188 @@ +//! LR-BEHAV-EMPTY-STATES: a library with no photos leads to Import. It opens on the Photo Grid +//! with the sidebar, the grid and the Detail view offer Import Photos… and Import Lightroom +//! Catalog… as buttons, and the top bar has an Import button for every library. + +use std::cell::Cell; +use std::rc::Rc; +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::panels::empty_library; +use crate::state::ViewMode; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn app(session: lightcraft_engine::Session) -> LightcraftApp { + LightcraftApp::new(session, Services::default()) +} + +/// A picker that records that it was asked and gives no files (the person cancelled). +fn counting_picker(h: &mut Headless) -> Rc<Cell<usize>> { + let asked = Rc::new(Cell::new(0)); + let seen = asked.clone(); + h.app.services.pick_files = Some(Box::new(move || { + seen.set(seen.get() + 1); + Vec::new() + })); + asked +} + +#[test] +fn an_empty_library_opens_on_the_grid_with_the_sidebar() { + // Given a library with no photos, left in the Detail view with the sidebar hidden + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + // When it opens at launch + empty_library::open_on_empty_library(&mut a); + // Then it shows the Photo Grid and the sidebar, where Import is offered + assert_eq!(a.ui.view, ViewMode::PhotoGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn an_empty_library_left_on_the_square_grid_stays_there() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::SquareGrid; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::SquareGrid); + assert!(a.ui.left_panel); +} + +#[test] +fn a_library_with_photos_opens_as_it_was_left() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + a.ui.left_panel = false; + empty_library::open_on_empty_library(&mut a); + assert_eq!(a.ui.view, ViewMode::Detail); + assert!(!a.ui.left_panel); +} + +#[test] +fn deleted_photos_leave_the_library_empty() { + let mut s = lightcraft_engine::Session::with_demo(); + assert!(!empty_library::is_empty(&s)); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); + assert!(empty_library::is_empty(&s), "photos in Recently Deleted are not in the library"); +} + +#[test] +fn the_empty_grid_offers_import_as_buttons() { + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::PhotoGrid; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); + #[cfg(not(target_arch = "wasm32"))] + assert!(has(&h, "button:emptyImportLightroom")); + // a click asks for the photos, as File ▸ Import Photos… does + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "button:emptyImportPhotos"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_empty_detail_view_offers_import_instead_of_the_grid() { + // the dead end the critique found: Detail pointed at a grid with nothing in it + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn detail_with_photos_but_none_selected_keeps_its_message() { + let mut a = app(lightcraft_engine::Session::with_demo()); + a.ui.view = ViewMode::Detail; + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.app.session.selection.ids.clear(); + h.app.session.selection.active = None; + h.step(); + h.step(); + assert!(h.app.session.active().is_none()); + assert!(!has(&h, "button:emptyImportPhotos"), "a library with photos needs no import prompt"); +} + +#[test] +fn the_top_bar_imports_in_every_library() { + let mut h = Headless::new(app(lightcraft_engine::Session::with_demo()), [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(has(&h, "icon:import")); + let asked = counting_picker(&mut h); + assert_eq!(h.request("ui.clickWidget", json!({"id": "icon:import"}), T)["ok"], true); + h.step(); + assert_eq!(asked.get(), 1); +} + +#[test] +fn the_hint_shows_the_shortcut_as_bound() { + let mut keymap = crate::state::AppSettings::default().keymap; + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⇧I"); + assert_eq!(empty_library::hint(&keymap, false), "or drop photos and folders into this window · Ctrl+Shift+I"); + // a remapped shortcut is the one shown + crate::shortcuts::assign(&mut keymap, "file.addPhotos", Some("Cmd+Alt+I")).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window · ⌘⌥I"); + // and none at all leaves the key out + crate::shortcuts::assign(&mut keymap, "file.addPhotos", None).unwrap(); + assert_eq!(empty_library::hint(&keymap, true), "or drop photos and folders into this window"); +} + +#[test] +fn a_folder_browsed_under_local_is_not_the_empty_library() { + // its photos aren't in the library, but they are what is on screen: no import prompt + let mut a = app(lightcraft_engine::Session::new()); + a.ui.view = ViewMode::Detail; + a.session.source = lightcraft_engine::LibrarySource::Folder; + assert!(empty_library::is_empty(&a.session)); + assert!(!empty_library::shown(&a.session)); + let mut h = Headless::new(a, [1300.0, 900.0], 1.0); + h.step(); + h.step(); + assert!(!has(&h, "button:emptyImportPhotos")); +} + +#[test] +fn recently_deleted_is_not_the_empty_library() { + // the deleted photos are on screen there: no import prompt beside them + let mut s = lightcraft_engine::Session::with_demo(); + let ids: Vec<_> = s.catalog.photos().map(|p| p.id).collect(); + s.selection.ids = ids.clone(); + s.execute("photo.delete", &json!({"ids": ids.iter().map(|i| i.0).collect::<Vec<_>>()})).unwrap(); + s.source = lightcraft_engine::LibrarySource::RecentlyDeleted; + assert!(empty_library::is_empty(&s)); + assert!(!empty_library::shown(&s)); +} + +#[test] +fn the_import_button_leaves_the_filter_and_appearance_icons_apart() { + // the search field is centred: a seventh icon on the right must not push it into them + // with the menus in the window (Windows, Linux, web) and in the native menu bar (macOS) + for (width, native_menu) in [900.0, 906.0, 1024.0, 1200.0, 1600.0].into_iter().flat_map(|w| [(w, false), (w, true)]) { + let mut a = app(lightcraft_engine::Session::with_demo()); + // a native menu bar comes with the title bar drawn in the window (macOS): a wider left margin + a.native_menu = native_menu; + a.integrated_titlebar = native_menu; + let mut h = Headless::new(a, [width, 800.0], 1.0); + h.step(); + h.step(); + let rect = |id: &str| h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("{id} at {width}")); + let (filter, appearance, import) = (rect("icon:filter"), rect("icon:appearance"), rect("icon:import")); + assert!(!filter.intersects(appearance), "{width} (native menu {native_menu}): filter {filter:?} meets appearance {appearance:?}"); + assert!(!filter.intersects(import), "{width} (native menu {native_menu}): filter {filter:?} meets import {import:?}"); + } +} diff --git a/crates/ui-egui/src/tests_focus_point.rs b/crates/ui-egui/src/tests_focus_point.rs new file mode 100644 index 000000000..f93dd4f7b --- /dev/null +++ b/crates/ui-egui/src/tests_focus_point.rs @@ -0,0 +1,189 @@ +//! Headless tests of the camera's focus point (#1480): View ▸ Show Focus Point draws the box where the +//! camera focused, through the user's rotation and crop; Toggle Zoom (Z) centres on it; the Info panel +//! names the focus mode. + +use std::sync::Arc; +use std::time::Duration; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use lightcraft_meta::FocusPoint; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Zoom; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn focus(x0: f64, y0: f64, x1: f64, y1: f64) -> FocusPoint { + FocusPoint { + rect: lightcraft_geom::Rect { x0, y0, x1, y1 }, + mode: Some("AF-C".into()), + area: Some("Wide".into()), + tracking: Some("Animal Eye Tracking".into()), + } +} + +/// The demo library in Detail with the Info panel, its first photo active and given `f` as its focus point. +fn detail_with_focus(f: Option<FocusPoint>) -> (Headless, PhotoId) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = *h.app.session.visible_cloned().first().expect("a demo photo"); + let mut meta = h.app.session.catalog.photo(id).expect("photo").meta.clone(); + meta.focus = f; + h.app.session.catalog.apply(Op::SetMeta { id, meta: Box::new(meta) }).expect("set meta"); + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.set", json!({"view": "detail", "right": "info"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + (h, id) +} + +fn widget(h: &Headless, id: &str) -> Option<egui::Rect> { + h.app.widgets.iter().rev().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, cmd: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": cmd, "params": params}), T); + assert_eq!(r["ok"], true, "{cmd}: {r}"); + h.settle(SETTLE); + r["result"].clone() +} + +/// Where `(x, y)` (fractions of the drawn photo) is on screen. +fn on_image(h: &Headless, x: f32, y: f32) -> egui::Pos2 { + let img = h.app.image_rect.expect("the photo is drawn"); + egui::pos2(img.left() + x * img.width(), img.top() + y * img.height()) +} + +#[test] +fn the_overlay_draws_on_the_focus_point_and_follows_rotation() { + let (mut h, _) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + assert!(widget(&h, "canvas:focusPoint").is_none(), "off by default"); + // the Info panel names how the camera focused + assert!(widget(&h, "info:focus").is_some(), "no Focus row in the Info panel"); + assert_eq!(run(&mut h, "view.focusPoint", json!({}))["on"], true); + let b = widget(&h, "canvas:focusPoint").expect("the focus box is drawn"); + let want = on_image(&h, 0.23, 0.33); + assert!((b.center() - want).length() < 1.5, "box at {:?}, want {want:?}", b.center()); + let img = h.app.image_rect.unwrap(); + assert!((b.width() - 0.06 * img.width()).abs() < 1.5, "{b:?} in {img:?}"); + // Rotate Right turns the photo 90° clockwise: the upright point (x, y) is drawn at (1 − y, x) + run(&mut h, "photo.rotateRight", json!({})); + let b = widget(&h, "canvas:focusPoint").expect("still drawn after rotating"); + let want = on_image(&h, 0.67, 0.23); + assert!((b.center() - want).length() < 1.5, "rotated box at {:?}, want {want:?}", b.center()); + // and the command turns it off again + assert_eq!(run(&mut h, "view.focusPoint", json!({"on": false}))["on"], false); + assert!(widget(&h, "canvas:focusPoint").is_none()); +} + +#[test] +fn toggle_zoom_centres_on_the_focus_point() { + let (mut h, _) = detail_with_focus(Some(focus(0.70, 0.20, 0.74, 0.26))); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + let r = h.request("ui.key", json!({"key": "z"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_)), "{:?}", h.app.ui.zoom); + let (u, v) = h.app.ui.pan; + assert!((u - 0.72).abs() < 1e-3 && (v - 0.23).abs() < 1e-3, "pan {u} {v}"); + // the zoomed view is centred on it (the canvas centre shows the focus point) + let canvas = h.app.canvas_rect.expect("canvas"); + let at = on_image(&h, 0.72, 0.23); + let img = h.app.image_rect.unwrap(); + // (unless the photo's edge stops the pan: here the point is well inside, so it is centred) + assert!(img.width() > canvas.width(), "zoomed in"); + assert!((at.x - canvas.center().x).abs() < 3.0, "{at:?} vs {:?}", canvas.center()); + // Z again: back to Fit; with Zoom to Focus Point off, Z keeps the current pan + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.zoom, Zoom::Fit); + assert_eq!(run(&mut h, "view.zoomToFocus", json!({}))["on"], false); + h.app.ui.pan = (0.5, 0.5); + run(&mut h, "view.zoomToggle", json!({})); + assert_eq!(h.app.ui.pan, (0.5, 0.5)); +} + +#[test] +fn without_a_focus_point_zoom_and_info_are_unchanged() { + let (mut h, _) = detail_with_focus(None); + run(&mut h, "view.focusPoint", json!({"on": true})); + assert!(widget(&h, "canvas:focusPoint").is_none()); + assert!(widget(&h, "info:focus").is_none()); + h.app.ui.pan = (0.4, 0.6); + run(&mut h, "view.zoomToggle", json!({})); + assert!(matches!(h.app.ui.zoom, Zoom::Percent(_))); + assert_eq!(h.app.ui.pan, (0.4, 0.6), "no focus point: Z zooms where it did before"); +} + +/// The box maps through the crop (and a point cropped away gives no box, so Z zooms as before). +#[test] +fn focus_box_follows_the_crop_and_drops_a_point_cropped_away() { + let mut p = Photo::new(PhotoId(1), Source::Demo { scene: 1 }, "a.jpg", "JPEG", 6000, 4000, "2026-10-13T00:00:00"); + p.meta.focus = Some(focus(0.70, 0.45, 0.80, 0.55)); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.70).abs() < 1e-9 && (b.y1 - 0.55).abs() < 1e-9, "{b:?}"); + // keep the right half: x 0.5..1 becomes 0..1 + let mut d = (*p.develop).clone(); + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.5, y0: 0.0, x1: 1.0, y1: 1.0 }; + p.develop = Arc::new(d.clone()); + let b = crate::panels::detail::focus_box(&p).expect("box"); + assert!((b.x0 - 0.40).abs() < 1e-6 && (b.x1 - 0.60).abs() < 1e-6, "{b:?}"); + // keep the left half: the point is gone + d.crop.geometry.rect = lightcraft_geom::Rect { x0: 0.0, y0: 0.0, x1: 0.5, y1: 1.0 }; + p.develop = Arc::new(d); + assert!(crate::panels::detail::focus_box(&p).is_none()); + // a zero-size photo or a garbage box never panics + let mut z = Photo::new(PhotoId(2), Source::Demo { scene: 1 }, "z.jpg", "JPEG", 0, 0, "2026-10-13T00:00:00"); + z.meta.focus = Some(focus(f64::NAN, 0.0, f64::INFINITY, 1.0)); + assert!(crate::panels::detail::focus_box(&z).is_none()); + z.meta.focus = Some(focus(0.4, 0.4, 0.6, 0.6)); + assert!(crate::panels::detail::focus_box(&z).is_some()); +} + +#[test] +fn compare_and_survey_draw_each_photos_focus_point() { + let (mut h, id) = detail_with_focus(Some(focus(0.20, 0.30, 0.26, 0.36))); + let other = h.app.session.visible_cloned().get(1).copied().expect("a second photo"); + run(&mut h, "view.focusPoint", json!({"on": true})); + run(&mut h, "library.select", json!({"ids": [id.0, other.0], "active": id.0})); + for view in ["view.compare", "view.survey"] { + run(&mut h, view, json!({})); + assert!(widget(&h, &format!("compare:focusPoint:{}", id.0)).is_some(), "{view}: no focus box"); + assert!(widget(&h, &format!("compare:focusPoint:{}", other.0)).is_none(), "{view}: a box on a photo without one"); + } +} + +/// A photo imported before focus points were read gets its point from the background reader the frame loop drives +/// (`focus.pump`, started by itself once the library is open), and Photo ▸ Read Focus Points runs it on demand. +#[test] +fn the_frame_loop_reads_focus_points_for_photos_imported_earlier() { + let dir = std::env::temp_dir().join(format!("lc-ui-focus-scan-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).expect("temp dir"); + let add = |app: &mut LightcraftApp, id: u64, name: &str| { + let path = dir.join(name); + std::fs::write(&path, lightcraft_meta::focus::synthetic_arw(3, 200_000)).expect("write"); + let mut p = Photo::new(PhotoId(id), Source::File { path: path.to_string_lossy().into() }, name, "ARW", 8640, 5760, "2026-10-13T00:00:00"); + p.meta.camera = "SONY ILCE-1".into(); + app.session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).expect("add"); + }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + app.ui.settings.gpu = false; + add(&mut app, 9001, "old.ARW"); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let has_point = |h: &Headless, id: u64| h.app.session.catalog.photo(PhotoId(id)).is_some_and(|p| p.meta.focus.is_some()); + assert!(h.step_until(SETTLE, |h| has_point(h, 9001)), "read by itself"); + assert!(h.step_until(T, |h| h.app.session.activity.list().iter().all(|t| t.kind != "focus")), "its row goes when it is done"); + // a photo added later is read when asked (the menu item runs the command) + add(&mut h.app, 9002, "later.ARW"); + let spec = h.app.session.commands().into_iter().find(|c| c.id == "photo.readFocusPoints").expect("the command"); + assert_eq!(spec.menu, vec!["Photo"]); + let r = run(&mut h, "photo.readFocusPoints", json!({})); + assert_eq!(r["queued"], 1, "{r}"); + assert!(h.step_until(SETTLE, |h| has_point(h, 9002))); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/ui-egui/src/tests_folder_drop.rs b/crates/ui-egui/src/tests_folder_drop.rs new file mode 100644 index 000000000..9b4cb860a --- /dev/null +++ b/crates/ui-egui/src/tests_folder_drop.rs @@ -0,0 +1,343 @@ +//! Photos dragged from the grid onto a Folders row move there on disk: the row lights up, the +//! dragged label says where they will go, a closed folder held under the pointer opens, and a +//! release asks first (unless the person said not to), then moves the files (one undo step). +//! Photos that can't go there, or are there already, say so and stay where they are. + +use std::path::{Path, PathBuf}; + +use egui::{Pos2, Rect}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +/// Folders `trip` and `home` in a fresh temporary folder, with a photo file in each. +fn folders(tag: &str) -> (PathBuf, PathBuf, PathBuf) { + let root = std::env::temp_dir().join(format!("lc-folder-drop-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&root); + let (trip, home) = (root.join("trip"), root.join("home")); + for (dir, file) in [(&trip, "beach.png"), (&home, "garden.png")] { + std::fs::create_dir_all(dir).unwrap(); + let img = lightcraft_raster::Rgba8 { width: 8, height: 8, data: vec![[90, 120, 160, 255]; 64] }; + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + std::fs::write(dir.join(file), png).unwrap(); + } + (root, trip, home) +} + +/// The photos at `paths` in the catalog (`local`: browsed, not in the library), in the grid with +/// the sidebar out, tall enough for the Folders section. +fn app(paths: &[(PathBuf, bool)]) -> Headless { + use lightcraft_catalog::{Op, Photo, Source}; + let mut session = lightcraft_engine::Session::new().with_fs(); + for (path, local) in paths { + let id = session.catalog.alloc_photo_id(); + let name = path.file_name().unwrap().to_string_lossy().to_string(); + let mut p = Photo::new(id, Source::File { path: path.to_string_lossy().into() }, &name, "PNG", 8, 8, "2026-01-01T10:00:00"); + p.local = *local; + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + } + let mut h = Headless::new(LightcraftApp::new(session, Services { png: None, ..Default::default() }), [1300.0, 1600.0], 1.0); + let r = h.request("ui.set", json!({"view": "photoGrid", "leftPanel": true, "sidebar": ["folders"]}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..6 { + h.step(); + } + h +} + +fn widget(h: &Headless, id: &str) -> Option<Rect> { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r) +} + +fn folder_row(h: &Headless, dir: &Path) -> Option<Rect> { + widget(h, &format!("source:libfolder:{}", dir.to_string_lossy())) +} + +fn id_of(h: &Headless, file: &str) -> u64 { + h.app.session.catalog.photos().find(|p| p.file_name == file).map(|p| p.id.0).unwrap() +} + +fn press(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerMoved(at)); + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// Move the pointer, pressed, from `from` to `to` over a few frames. +fn glide(h: &mut Headless, from: Pos2, to: Pos2) { + for i in 1..=8 { + h.app.synthetic.push(egui::Event::PointerMoved(from + (to - from) * (i as f32 / 8.0))); + h.step(); + } +} + +fn release(h: &mut Headless, at: Pos2) { + h.app.synthetic.push(egui::Event::PointerButton { + pos: at, + button: egui::PointerButton::Primary, + pressed: false, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + h.step(); +} + +/// What the dragged label says this frame instead of the count, if a drop target set it. +fn hint(h: &Headless) -> Option<String> { + h.view.ctx.data(|d| d.get_temp::<(u64, String)>(egui::Id::new("drag-photos-hint"))).map(|(_, text)| text) +} + +/// Click a widget the way a person does (press and release on it). +fn click(h: &mut Headless, id: &str) { + let at = widget(h, id).unwrap_or_else(|| panic!("no {id}")).center(); + press(h, at); + release(h, at); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|(t, _, _)| t.clone()).unwrap_or_default() +} + +#[test] +fn photos_dropped_on_a_folder_move_there_on_disk() { + let (root, trip, home) = folders("move"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + // the two folders sit in one that starts closed: only it is there to drop on + let parent = folder_row(&h, &root).unwrap_or_else(|| { + panic!("the folder holding both: {:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("source:")).collect::<Vec<_>>()) + }); + assert!(folder_row(&h, &home).is_none(), "closed"); + // Given the beach photo dragged from the grid and held over the closed folder + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, parent.center()); + assert_eq!(h.app.ui.dragging_photos, Some(vec![beach])); + for _ in 0..60 { + h.step(); + } + // Then it opens, so a folder inside it can be reached + let target = folder_row(&h, &home).expect("held over, the folder opened"); + // When it is brought over home: the label says where it will go + glide(&mut h, parent.center(), target.center()); + assert_eq!(hint(&h).as_deref(), Some("Move 1 photo to “home”")); + // and released there: it asks first, nothing moved yet + release(&mut h, target.center()); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. })), "{:?}", h.app.ui.dialog); + assert!(trip.join("beach.png").is_file(), "not before Move"); + h.step(); + click(&mut h, "button:dialogOk"); + // Then the file moved, the photo follows it, and the toast says Undo moves it back + assert!(home.join("beach.png").is_file() && !trip.join("beach.png").exists(), "moved on disk"); + let photo = h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap(); + assert!(matches!(&photo.source, lightcraft_catalog::Source::File { path } if Path::new(path) == home.join("beach.png"))); + assert!(toast(&h).starts_with("Moved 1 photo to “home”. Edit → Undo moves them back."), "{}", toast(&h)); + assert!(h.app.ui.dragging_photos.is_none(), "the drag ended"); + // and one Undo moves it back + h.app.run("edit.undo", json!({})).unwrap(); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "back where it was"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn local_photos_are_refused_with_the_reason() { + let (root, trip, home) = folders("local"); + // the garden photo imported; the beach one only looked at under Local, where the grid shows it + let mut h = app(&[(home.join("garden.png"), false)]); + let r = h.request( + "engine.execute", + json!({"command": "library.browse", "params": {"path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + // (the folder is read in the background) + let shown = |h: &Headless| { + h.app.session.catalog.photos().any(|p| p.file_name == "beach.png") && h.app.widgets.iter().any(|(w, _)| w.starts_with("thumb:")) + }; + h.step_until(std::time::Duration::from_secs(20), |h| shown(h)); + let beach = id_of(&h, "beach.png"); + assert!(h.app.session.catalog.photo(lightcraft_catalog::PhotoId(beach)).unwrap().local); + let target = folder_row(&h, &home).expect("the imported folder"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + // Then the row says it can't take them, and a release says why + assert_eq!(hint(&h).as_deref(), Some("Can't be moved here")); + release(&mut h, target.center()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "stays"); + assert!(toast(&h).contains("Add to My Photos"), "{}", toast(&h)); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_already_in_the_folder_stay_put() { + let (root, trip, home) = folders("here"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + // open the folder holding both + let toggle = widget(&h, &format!("libraryFolderToggle:{}", root.to_string_lossy())).unwrap(); + h.app.synthetic.push(egui::Event::PointerMoved(toggle.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: toggle.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + release(&mut h, toggle.center()); + let target = folder_row(&h, &home).expect("opened"); + let garden = id_of(&h, "garden.png"); + h.app.ui.dragging_photos = Some(vec![garden]); + h.app.synthetic.push(egui::Event::PointerMoved(target.center())); + h.app.synthetic.push(egui::Event::PointerButton { + pos: target.center(), + button: egui::PointerButton::Primary, + pressed: true, + modifiers: egui::Modifiers::NONE, + }); + h.step(); + assert_eq!(hint(&h).as_deref(), Some("Already in “home”")); + let undo = h.app.session.undo.len(); + release(&mut h, target.center()); + assert!(home.join("garden.png").is_file()); + assert_eq!(h.app.session.undo.len(), undo, "nothing done"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn photos_that_leave_the_view_leave_the_selection() { + let (root, trip, home) = folders("selection"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + // shown by folder: trip's two photos, both selected + let r = h.request( + "engine.execute", + json!({"command": "library.source", "params": {"kind": "libraryFolder", "path": trip.to_string_lossy()}}), + std::time::Duration::from_secs(10), + ); + assert_eq!(r["ok"], true, "{r}"); + let (beach, dunes) = (id_of(&h, "beach.png"), id_of(&h, "dunes.png")); + h.app.session.selection = + lightcraft_engine::Selection { ids: vec![lightcraft_catalog::PhotoId(beach)], active: Some(lightcraft_catalog::PhotoId(beach)) }; + for _ in 0..4 { + h.step(); + } + let target = folder_row(&h, &home).expect("the other folder is listed beside it"); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + glide(&mut h, from, target.center()); + release(&mut h, target.center()); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file(), "moved"); + // it left trip's view, and the selection with it: a Delete next can't reach it unseen + assert!(!h.app.session.selection.contains(lightcraft_catalog::PhotoId(beach)), "{:?}", h.app.session.selection); + assert!(h.app.session.visible_cloned().contains(&lightcraft_catalog::PhotoId(dunes))); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_folder_left_and_come_back_to_waits_again_before_opening() { + let (root, trip, home) = folders("spring"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let parent = folder_row(&h, &root).unwrap(); + let from = widget(&h, &format!("thumb:{beach}")).unwrap().center(); + press(&mut h, from); + // over the closed folder for a moment, then away, longer than it waits + glide(&mut h, from, parent.center()); + glide(&mut h, parent.center(), from); + for _ in 0..60 { + h.step(); + } + // back over it: it doesn't open at once + glide(&mut h, from, parent.center()); + assert!(folder_row(&h, &home).is_none(), "came back to: it waits again"); + for _ in 0..60 { + h.step(); + } + assert!(folder_row(&h, &home).is_some(), "held: it opens"); + release(&mut h, parent.center()); + let _ = std::fs::remove_dir_all(&root); +} + +/// Drag the beach photo from the grid onto `target` and let go. +fn drop_beach(h: &mut Headless, beach: u64, target: Rect) { + let from = widget(h, &format!("thumb:{beach}")).unwrap().center(); + press(h, from); + glide(h, from, target.center()); + release(h, target.center()); + h.step(); +} + +#[test] +fn cancelling_the_question_moves_nothing() { + let (root, trip, home) = folders("cancel"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap_or_else(|| folder_row(&h, &root).unwrap()); + let target = if folder_row(&h, &home).is_some() { + target + } else { + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + folder_row(&h, &home).unwrap() + }; + let undo = h.app.session.undo.len(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + assert_eq!(h.app.session.undo.len(), undo); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn dont_ask_again_moves_the_next_drop_at_once() { + let (root, trip, home) = folders("dont-ask"); + std::fs::copy(trip.join("beach.png"), trip.join("dunes.png")).unwrap(); + let mut h = app(&[(trip.join("beach.png"), false), (trip.join("dunes.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let target = folder_row(&h, &home).unwrap(); + let beach = id_of(&h, "beach.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(beach)); + drop_beach(&mut h, beach, target); + // the question, with Don't ask again ticked + click(&mut h, "check:moveDontAsk"); + click(&mut h, "button:dialogOk"); + assert!(home.join("beach.png").is_file()); + assert!(!h.app.ui.settings.ask_before_move, "kept in Settings"); + // the next drop goes at once + let dunes = id_of(&h, "dunes.png"); + h.app.session.selection = lightcraft_engine::Selection::single(lightcraft_catalog::PhotoId(dunes)); + h.step(); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, dunes, target); + assert!(h.app.ui.dialog.is_none(), "not asked"); + assert!(home.join("dunes.png").is_file(), "moved at once"); + let _ = std::fs::remove_dir_all(&root); +} + +#[test] +fn a_question_left_open_while_another_library_opens_moves_nothing() { + let (root, trip, home) = folders("other-library"); + let mut h = app(&[(trip.join("beach.png"), false), (home.join("garden.png"), false)]); + click(&mut h, &format!("libraryFolderToggle:{}", root.to_string_lossy())); + let beach = id_of(&h, "beach.png"); + let target = folder_row(&h, &home).unwrap(); + drop_beach(&mut h, beach, target); + assert!(matches!(h.app.ui.dialog, Some(crate::state::Dialog::MovePhotos { .. }))); + // another library opens meanwhile (the menu bar and agents reach past a dialog) + let other = root.join("other-library"); + h.app.session.open_library(other.join("lib"), false).unwrap(); + let r = h.request("ui.dialog.confirm", json!({}), std::time::Duration::from_secs(10)); + assert_eq!(r["ok"], false, "{r}"); + assert!(r["error"].as_str().unwrap_or_default().contains("Another library was opened"), "{r}"); + assert!(trip.join("beach.png").is_file() && !home.join("beach.png").exists(), "nothing moved"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/ui-egui/src/tests_grading.rs b/crates/ui-egui/src/tests_grading.rs index 6abce5460..f6903550c 100644 --- a/crates/ui-egui/src/tests_grading.rs +++ b/crates/ui-egui/src/tests_grading.rs @@ -352,3 +352,26 @@ fn double_click_still_resets_the_wheel_with_a_modifier_held() { h.step(); assert_eq!(wheel_value(&h, "midtones"), (0.0, 0.0)); } + +/// The wheels used to look muddy. Painted on screen, the rim of each hue reaches full +/// saturation at normal brightness and the centre is neutral. +#[test] +fn wheel_rim_is_fully_saturated_and_the_centre_neutral() { + let mut h = demo(); + let img = h.snapshot(SETTLE); + let r = widget(&h, "wheel:midtones").unwrap(); + let at = |dx: f32, dy: f32| { + let p = r.center() + egui::vec2(dx, dy) * r.width() / 2.0; + img[(p.x as usize, p.y as usize)] + }; + for deg in [0.0f32, 60.0, 120.0, 180.0, 240.0, 300.0] { + let a = deg.to_radians(); + let p = at(a.cos() * 0.93, -a.sin() * 0.93); + let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); + assert!(hi >= 225 && lo <= 40, "rim at {deg}° is not a vivid hue: {p:?}"); + } + // just off the centre (the puck sits on it at zero), the disc is still close to neutral + let p = at(0.12, 0.12); + let (hi, lo) = (p.r().max(p.g()).max(p.b()), p.r().min(p.g()).min(p.b())); + assert!(hi - lo <= 40 && lo >= 100, "centre is not a neutral grey: {p:?}"); +} diff --git a/crates/ui-egui/src/tests_keymap.rs b/crates/ui-egui/src/tests_keymap.rs index 1b04a46bd..92997d941 100644 --- a/crates/ui-egui/src/tests_keymap.rs +++ b/crates/ui-egui/src/tests_keymap.rs @@ -237,3 +237,219 @@ fn opening_numeric_release_keeps_keys_in_the_editor() { } } } + +/// Press `key` with the named modifiers held (`cmd`, `shift`, `alt`, `ctrl`). +fn press(h: &mut Headless, key: &str, mods: &[&str]) { + let mut p = json!({ "key": key }); + for m in mods { + p[*m] = json!(true); + } + let r = h.request("ui.key", p, T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +fn view(h: &mut Headless, p: serde_json::Value) { + let r = h.request("ui.set", p, T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// Make `id` the only selected, active photo. +fn select(h: &mut Headless, id: lightcraft_catalog::PhotoId) { + let r = h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id.0], "active": id.0}}), T); + assert_eq!(r["ok"], true, "{r}"); +} + +/// The demo library in Photo Grid with its middle photo selected: the photos in view, and that one. +fn grid_demo() -> (Headless, Vec<lightcraft_catalog::PhotoId>, lightcraft_catalog::PhotoId) { + let mut h = demo(); + view(&mut h, json!({ "view": "photoGrid" })); + let vis = h.app.session.visible_cloned(); + assert!(vis.len() > 2, "{vis:?}"); + let middle = vis[vis.len() / 2]; + select(&mut h, middle); + (h, vis, middle) +} + +/// Count how often the folder picker is asked for; every ask is cancelled. +fn count_folder_picks(h: &mut Headless) -> std::sync::Arc<std::sync::atomic::AtomicUsize> { + let calls = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let observed = calls.clone(); + h.app.services.pick_folder = Some(Box::new(move || { + observed.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + None + })); + calls +} + +/// The declared key of Open Library. Visualize HDR has none by default: ⇧H cycles the Tool Overlay +/// (issue #224); the user can give Visualize HDR a key in the keymap. +#[test] +fn open_library_and_visualize_hdr_keys_are_declared() { + use crate::shortcuts::{Keymap, parse, shortcut_of}; + + let keymap = Keymap::new(); + assert_eq!(shortcut_of(&keymap, "app.openLibrary"), Some("Cmd+O")); + assert!(parse("Cmd+O").is_some()); + assert_eq!(shortcut_of(&keymap, "view.visualizeHdr"), None); +} + +/// Opening a library uses the same folder picker as File > Open Library. Cancelling it +/// leaves the current photo and library intact. +#[test] +fn cmd_o_opens_the_library_picker_once() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + let active = h.app.session.active(); + let photos = h.app.session.catalog.len(); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1, "one picker for Cmd+O"); + assert_eq!(h.app.session.active(), active); + assert_eq!(h.app.session.catalog.len(), photos); +} + +/// Home / End make the first / last photo of the view the active one, only in the library grids, +/// and yield to a key the user gave to another command. +#[test] +fn home_and_end_select_the_ends_of_the_grid() { + let (mut h, vis, _) = grid_demo(); + let (first, last) = (vis[0], *vis.last().unwrap()); + + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "Home goes to the first photo"); + assert_eq!(h.app.session.selection.ids, vec![first], "and selects only it"); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), Some(last), "End goes to the last photo"); + assert_eq!(h.app.session.selection.ids, vec![last]); + + view(&mut h, json!({ "view": "squareGrid" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(first), "the Square Grid has the keys too"); + + select(&mut h, last); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(last), "Home is a grid key"); + + view(&mut h, json!({ "view": "photoGrid" })); + let r = h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Home"})).unwrap(); + assert_eq!(r["shortcut"], "Home", "{r}"); + press(&mut h, "Home", &[]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Home binding fires"); + assert_eq!(h.app.session.active(), Some(last), "the grid key stayed out of the way"); +} + +/// Shift+Left / Shift+Right in a grid add the previous / next photo of the view to the selection and +/// make it active, stop at either end, stay out of the loupe and yield to the user's keymap. +#[test] +fn shift_arrows_extend_the_grid_selection() { + let (mut h, vis, middle) = grid_demo(); + let at = vis.iter().position(|p| *p == middle).unwrap(); + let (previous, next) = (vis[at - 1], vis[at + 1]); + + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next], "Shift+Right adds the next photo"); + assert_eq!(h.app.session.active(), Some(next), "the added photo becomes active"); + // Shift+Left steps back onto a photo that is already selected: the selection stays, the active moves + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next]); + assert_eq!(h.app.session.active(), Some(middle)); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle, next, previous], "Shift+Left adds the previous photo"); + assert_eq!(h.app.session.active(), Some(previous)); + + // the ends of the view do nothing + select(&mut h, vis[0]); + press(&mut h, "Left", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![vis[0]], "Shift+Left on the first photo is a no-op"); + let last = *vis.last().unwrap(); + select(&mut h, last); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![last], "Shift+Right on the last photo is a no-op"); + + // outside a grid it is not the extension key + select(&mut h, middle); + view(&mut h, json!({ "view": "detail" })); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids, vec![middle], "the loupe has no grid extension key"); + + // a key the user gave to a command wins + view(&mut h, json!({ "view": "photoGrid" })); + h.app.run("app.setShortcut", json!({"id": "view.survey", "shortcut": "Shift+Right"})).unwrap(); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.ui.view, ViewMode::Survey, "the user's Shift+Right binding fires"); + assert_eq!(h.app.session.selection.ids, vec![middle], "the grid key stayed out of the way"); +} + +/// A text field with the keyboard focus keeps every one of these keys. +#[test] +fn navigation_open_library_and_hdr_keys_wait_for_a_text_field() { + let (mut h, vis, middle) = grid_demo(); + let calls = count_folder_picks(&mut h); + let picks = || calls.load(std::sync::atomic::Ordering::SeqCst); + let keys: [(&str, &[&str]); 6] = [("Home", &[]), ("End", &[]), ("Right", &["shift"]), ("Left", &["shift"]), ("O", &["cmd"]), ("H", &["shift"])]; + + // with no text field focused, each of them acts + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); + press(&mut h, "End", &[]); + assert_eq!(h.app.session.active(), vis.last().copied()); + select(&mut h, middle); + press(&mut h, "Right", &["shift"]); + assert_eq!(h.app.session.selection.ids.len(), 2, "Shift+Right extends"); + press(&mut h, "O", &["cmd"]); + assert_eq!(picks(), 1, "Cmd+O asks for a folder"); + select(&mut h, middle); + + // with the search box focused, none does + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the search box has the keyboard"); + let before = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + for (key, mods) in keys { + press(&mut h, key, mods); + let after = (h.app.session.active(), h.app.session.selection.ids.clone(), h.app.ui.hdr_visualize, h.app.ui.view, picks()); + assert_eq!(after, before, "{mods:?}+{key} reached the shortcuts while the search box had the focus"); + } + assert!(h.app.ui.dialog.is_none()); +} + +/// Home / End and Shift+Left / Shift+Right belong to the grid: while the full-screen preview covers it +/// they leave the selection alone. +#[test] +fn grid_navigation_keys_do_nothing_in_the_full_screen_preview() { + let (mut h, vis, middle) = grid_demo(); + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(h.app.ui.fullscreen); + assert_eq!(h.app.ui.view, ViewMode::PhotoGrid, "the grid is the view behind the preview"); + for (key, mods) in [("Home", &[][..]), ("End", &[][..]), ("Right", &["shift"][..]), ("Left", &["shift"][..])] { + press(&mut h, key, mods); + assert_eq!(h.app.session.active(), Some(middle), "{mods:?}+{key} moved the active photo in the full-screen preview"); + assert_eq!(h.app.session.selection.ids, vec![middle], "{mods:?}+{key} changed the selection in the full-screen preview"); + } + // back in the grid the keys work again + h.app.run("view.fullScreenPreview", json!({})).unwrap(); + assert!(!h.app.ui.fullscreen); + press(&mut h, "Home", &[]); + assert_eq!(h.app.session.active(), Some(vis[0])); +} + +/// A dialog owns the keyboard: Cmd+O does not ask for a folder behind it. +#[test] +fn cmd_o_does_not_fire_while_a_dialog_is_open() { + let mut h = demo(); + let calls = count_folder_picks(&mut h); + h.app.run("app.shortcuts", json!({})).unwrap(); + h.step(); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 0, "Cmd+O reached the shortcuts behind a dialog"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Shortcuts))); + // with the dialog closed the same key asks for a folder + h.app.ui.dialog = None; + press(&mut h, "O", &["cmd"]); + assert_eq!(calls.load(std::sync::atomic::Ordering::SeqCst), 1); +} diff --git a/crates/ui-egui/src/tests_keyword_list.rs b/crates/ui-egui/src/tests_keyword_list.rs index 657e5f337..bdeaef0dc 100644 --- a/crates/ui-egui/src/tests_keyword_list.rs +++ b/crates/ui-egui/src/tests_keyword_list.rs @@ -428,3 +428,69 @@ fn dropping_photos_counts_those_that_get_the_keyword() { let toast = h.app.ui.toast.clone().map(|t| t.0).unwrap_or_default(); assert!(toast.contains("1 photo") && !toast.contains("2 photos"), "{toast}"); } + +/// Scrolled down a long list, ticking and unticking keywords leaves the tree where it is: the +/// Keywording box above grows and shrinks out of sight instead of pushing the rows down. +#[test] +fn ticking_a_keyword_keeps_the_list_in_place() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + assert!(rect(&h, "field:keyword").top() < 0.0, "the box is scrolled out of sight"); + let mut box_moved = false; + let picked: Vec<String> = (30..36).map(|i| format!("zz{i}")).collect(); + for (n, k) in picked.iter().chain(picked.iter().rev()).enumerate() { + let (before, box_top) = (rect(&h, &format!("keywordRow:{k}")), rect(&h, "field:keyword").top()); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + assert_eq!(rect(&h, &format!("keywordRow:{k}")), before, "{k} stayed under the pointer"); + // (the click found the row where it is drawn, not where a discarded pass had it) + assert_eq!(keywords_of(&h, ids[0]).contains(k), n < picked.len(), "{k} was the one ticked: {:?}", keywords_of(&h, ids[0])); + box_moved |= rect(&h, "field:keyword").top() != box_top; + } + assert!(keywords_of(&h, ids[0]).is_empty(), "ticked and unticked again"); + assert!(box_moved, "the box changed height (the list was held, not left alone)"); +} + +/// At the top of the panel the box is in sight: a ticked keyword's chip pushes the list down, +/// as before. +#[test] +fn at_the_top_the_box_grows_in_sight() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + let (title, box_top) = (rect(&h, "keywordList:topLevel"), rect(&h, "field:keyword")); + for k in ["aaa1", "aaa2", "aaa3", "aaa4", "aaa5", "aaa6"] { + run(&mut h, "keyword.create", json!({"name": k})); + ask(&mut h, "ui.clickWidget", json!({"id": format!("keywordCheck:{k}")})); + } + assert_eq!(rect(&h, "field:keyword"), box_top, "the panel stays at its top"); + assert!(rect(&h, "keywordList:topLevel").top() > title.top(), "the chips pushed the list down"); +} + +/// Ticks clicked one right after another land on the rows clicked: a click looks a row up where +/// it was last drawn, not where a pass drawn again (the list held in place) first had it. +#[test] +fn ticks_in_quick_succession_land_on_their_rows() { + let (mut h, ids) = keywords_panel_at([1400.0, 640.0]); + run(&mut h, "photo.setMeta", json!({"ids": ids, "keywords": []})); + for i in 0..40 { + run(&mut h, "keyword.create", json!({"name": format!("zz{i:02}")})); + } + ask(&mut h, "ui.hoverWidget", json!({"id": "keywordList:topLevel"})); + let row = rect(&h, "keywordRow:zz30"); + ask(&mut h, "ui.scroll", json!({"dx": 0, "dy": -(row.top() - 300.0)})); + let picked: Vec<String> = (30..36).map(|i| format!("zz{i}")).collect(); + for k in &picked { + // (no settling in between, as a script or a quick hand clicks) + let r = h.request("ui.clickWidget", json!({"id": format!("keywordCheck:{k}")}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.settle(SETTLE); + let mut have = keywords_of(&h, ids[0]); + have.sort(); + assert_eq!(have, picked); +} diff --git a/crates/ui-egui/src/tests_keywording.rs b/crates/ui-egui/src/tests_keywording.rs index a130b86b2..95b02967c 100644 --- a/crates/ui-egui/src/tests_keywording.rs +++ b/crates/ui-egui/src/tests_keywording.rs @@ -191,7 +191,7 @@ fn the_active_photo_alone_is_the_selection() { assert_eq!(h.app.session.selection.active.map(|p| p.0), Some(ids[0])); assert!(h.app.session.selection.ids.is_empty()); assert!(has(&h, "keywordChip:Weddings") && !has(&h, "keywordChipPartial:Weddings"), "the active photo's keywords"); - let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.targets(&json!({}))); + let ticks = crate::panels::keyword_list::Ticks::of(&h.app.session.catalog, &h.app.session.selected()); assert_eq!(ticks.tick("Weddings"), crate::panels::keyword_list::Tick::All); assert!(has(&h, "keywordCheck:beach")); } @@ -227,3 +227,589 @@ fn containing_keywords_view_lists_parents_too() { } assert!(!has(&h, "keywordChip:beach"), "the chips give way"); } + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn count(h: &Headless, prefix: &str) -> usize { + h.app.widgets.iter().filter(|(w, _)| w.starts_with(prefix)).count() +} + +/// Give both selected photos `keywords`, replacing what they had. +fn give(h: &mut Headless, ids: &[u64], keywords: &[String]) { + ask(h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "keywords": keywords}})); +} + +/// Sixteen keywords inside "Subjects", each long enough to take a line of the panel. +fn sixteen() -> Vec<String> { + (1..=16).map(|i| format!("Subjects|a keyword with quite a long name {i:02}")).collect() +} + +// Given keywords inside the same keyword and the box set to group them, +// when the panel is drawn, +// then they share a line naming that keyword and their chips say only what is below it, side by +// side where they fit; each chip still acts on its whole keyword. +#[test] +fn grouped_keywords_share_a_line_naming_their_parent() { + let (mut h, ids) = two_selected(); + let keywords: Vec<String> = ["Places|Italy|Rome", "Places|Italy|Venice", "Transport|Ferry", "sea"].iter().map(|s| s.to_string()).collect(); + give(&mut h, &ids, &keywords); + assert_eq!( + count(&h, "keywordGroup:"), + 0, + "whole names unless asked: {:?}", + h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordGroup")).collect::<Vec<_>>() + ); + let whole = rect(&h, "keywordChip:Places|Italy|Rome").width(); + + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + let heading = rect(&h, "keywordGroup:Places|Italy"); + let (rome, venice) = (rect(&h, "keywordChip:Places|Italy|Rome"), rect(&h, "keywordChip:Places|Italy|Venice")); + assert!(rome.top() >= heading.bottom(), "under its heading: {rome:?} {heading:?}"); + assert_eq!(rome.top(), venice.top(), "side by side"); + assert!(rome.width() < whole, "the chip says only “Rome”: {} < {whole}", rome.width()); + // a keyword that shares nothing has no heading and comes first + assert!(!has(&h, "keywordGroup:Transport") && !has(&h, "keywordGroup:"), "one keyword is no group"); + assert!(rect(&h, "keywordChip:sea").top() < heading.top()); + // the × still takes the whole keyword off both + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:Places|Italy|Rome"})); + assert!(ids.iter().all(|id| !keywords_of(&h, *id).iter().any(|k| k == "Places|Italy|Rome"))); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Italy|Venice"))); +} + +// Given sixteen keywords and the grouped box, +// when the panel is drawn, +// then eight lines show with "+N more" for the rest, which shows them all; "Show fewer" brings the +// short list back. The Keyword List is nearer the top for it. +#[test] +fn a_long_grouped_list_is_shortened_until_asked_for() { + let (mut h, ids) = two_selected(); + give(&mut h, &ids, &sixteen()); + assert_eq!(count(&h, "keywordChip:"), 16, "whole names: every keyword, as before"); + assert!(!has(&h, "keywordChipsMore")); + let list_before = rect(&h, "field:keywordFilter").top(); + + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert_eq!(count(&h, "keywordChip:"), 7, "a heading and seven chips"); + assert!(has(&h, "keywordChipsMore") && !has(&h, "keywordChipsFewer")); + assert!(rect(&h, "field:keywordFilter").top() < list_before - 80.0, "{} from {list_before}", rect(&h, "field:keywordFilter").top()); + + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsMore"})); + assert_eq!(count(&h, "keywordChip:"), 16); + assert!(has(&h, "keywordChipsFewer") && !has(&h, "keywordChipsMore")); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsFewer"})); + assert_eq!(count(&h, "keywordChip:"), 7); +} + +// Given the grouped box, chips stay inside the panel and never overlap, whatever their length. +#[test] +fn grouped_chips_stay_inside_the_panel() { + let (mut h, ids) = two_selected(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + let long = "Places|Portugal|Lisbon|Belém tower and the old town square on the river by the bridge"; + let keywords: Vec<String> = + ["Events|Weddings", "Events|Birthdays", "sunset", "Places|Portugal|Porto", long].iter().map(|s| s.to_string()).collect(); + give(&mut h, &ids, &keywords); + let panel = rect(&h, "panel:right_panel"); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert_eq!(chips.len(), 5, "{chips:?}"); + for (i, (id, r)) in chips.iter().enumerate() { + assert!(r.left() >= panel.left() && r.right() <= panel.right(), "{id} inside the panel: {r:?} in {panel:?}"); + for (other, o) in chips.iter().skip(i + 1) { + assert!(!r.intersects(o.shrink(0.5)), "{id} overlaps {other}"); + } + } +} + +// Given a selection without keywords, the box says so (as its other two views do). +#[test] +fn a_selection_without_keywords_says_so() { + let (mut h, ids) = two_selected(); + give(&mut h, &ids, &[]); + assert!(has(&h, "keywordChipsEmpty")); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert!(has(&h, "keywordChipsEmpty")); +} + +// Given the Keywords panel, +// when the title of Keywording or of the Keyword List is clicked, +// then that section folds shut and the other stays; a second click opens it, and what is folded is +// kept with the interface's settings. +#[test] +fn the_sections_fold_by_their_titles() { + let (mut h, _) = two_selected(); + assert!(has(&h, "field:keyword") && has(&h, "field:keywordFilter")); + let list_before = rect(&h, "field:keywordFilter").top(); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + assert!(!has(&h, "field:keyword") && !has(&h, "keywordChip:beach") && !has(&h, "kwSet:1"), "Keywording is shut"); + assert!(has(&h, "field:keywordFilter"), "the Keyword List stays"); + assert!(rect(&h, "field:keywordFilter").top() < list_before - 100.0); + let saved = serde_json::to_string(&h.app.ui).unwrap(); + let back = serde_json::from_str::<crate::UiState>(&saved).unwrap().sanitized(); + assert!(back.section_folded("keywording") && !back.section_folded("keywordList")); + assert!(!serde_json::from_str::<crate::UiState>("{}").unwrap().section_folded("keywording"), "settings from before: open"); + + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywordList"})); + assert!(!has(&h, "field:keywordFilter") && !has(&h, "keywordCheck:beach"), "the Keyword List is shut"); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywording"})); + ask(&mut h, "ui.clickWidget", json!({"id": "section:keywordList"})); + assert!(has(&h, "field:keyword") && has(&h, "field:keywordFilter") && has(&h, "keywordChip:beach")); +} + +// Given the grouped box, the suggestions say only the keyword's own name (its whole path when two +// of them have the same name) and still give the whole keyword. +#[test] +fn grouped_suggestions_name_the_keyword_alone() { + let (mut h, ids) = two_selected(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[0]], "addKeywords": ["Places|Portugal|Lisbon"]}})); + let whole = rect(&h, "kwSuggest:Places|Portugal|Lisbon").width(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + h.settle(SETTLE); + assert!(rect(&h, "kwSuggest:Places|Portugal|Lisbon").width() < whole); + ask(&mut h, "ui.clickWidget", json!({"id": "kwSuggest:Places|Portugal|Lisbon"})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k == "Places|Portugal|Lisbon"))); +} + +/// Two photos selected (beach + Weddings, beach), and a third photo of the library, not selected, +/// with Places|Portugal|Lisbon and Places|Portugal|Porto. +fn with_places() -> (Headless, Vec<u64>) { + let (mut h, ids) = two_selected(); + let third = h.app.session.visible_cloned().get(2).map(|p| p.0).expect("a third photo"); + ask( + &mut h, + "engine.execute", + json!({"command": "photo.setMeta", "params": {"ids": [third], "keywords": ["Places|Portugal|Lisbon", "Places|Portugal|Porto"]}}), + ); + (h, ids) +} + +fn typed(h: &mut Headless, text: &str) { + ask(h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(h, "ui.text", json!({"text": text})); +} + +fn all_have(h: &Headless, ids: &[u64], keyword: &str) -> bool { + ids.iter().all(|id| keywords_of(h, *id).iter().any(|k| k == keyword)) +} + +fn none_has(h: &Headless, ids: &[u64], keyword: &str) -> bool { + ids.iter().all(|id| !keywords_of(h, *id).iter().any(|k| k == keyword)) +} + +// Given a library with Places › Portugal › Lisbon, +// when "lis" is typed in the Add keyword box, +// then the keyword is offered right under the box, and the suggestions further down stay what +// they were (they used to turn into the completions, a long way from the box). +#[test] +fn typing_offers_the_librarys_keywords_under_the_box() { + let (mut h, _) = with_places(); + assert!(has(&h, "kwSuggest:Weddings")); + typed(&mut h, "lis"); + let (field, offer) = (rect(&h, "field:keyword"), rect(&h, "kwComplete:Places|Portugal|Lisbon")); + assert!(offer.top() >= field.bottom() && offer.top() - field.bottom() < 12.0, "right under the box: {offer:?} {field:?}"); + assert!(offer.left() >= field.left() - 1.0 && offer.right() <= field.right() + 1.0, "as wide as the box: {offer:?} {field:?}"); + assert!(!has(&h, "kwComplete:Places|Portugal|Porto"), "only what matches"); + assert!(has(&h, "kwSuggest:Weddings") && !has(&h, "kwSuggest:Places|Portugal|Lisbon"), "the suggestions stay"); + // nothing typed, nothing offered + ask(&mut h, "ui.key", json!({"key": "Escape"})); + assert_eq!(count(&h, "kwComplete"), 0); +} + +// Given the offers under the box, +// when ↓ then Return are pressed, +// then the highlighted keyword goes to every selected photo (not the letters typed), and the box +// is empty and still has the cursor for the next keyword. +#[test] +fn down_and_return_give_the_offered_keyword() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + assert!(has(&h, "kwCompleteAt:Places|Portugal|Lisbon"), "highlighted"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon")); + assert!(none_has(&h, &ids, "lis"), "the letters typed are not a keyword"); + assert_eq!(count(&h, "kwComplete"), 0, "the box is empty again"); + // still in the box: the next keyword is typed straight away + ask(&mut h, "ui.text", json!({"text": "harbour"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "harbour")); +} + +// Given the offers under the box, Return alone still gives what was typed, as it always did; ↑ +// from the first offer goes back to that. +#[test] +fn return_alone_gives_what_was_typed() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "lis") && none_has(&h, &ids, "Places|Portugal|Lisbon")); + + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "ArrowUp"})); + assert_eq!(count(&h, "kwCompleteAt:"), 0, "none highlighted"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "por") && none_has(&h, &ids, "Places|Portugal|Porto")); +} + +// Given a name the library doesn't have, +// when it is typed, +// then the box says Return will create it (and inside which keyword, with a default parent); a +// keyword the library has is not announced as new. +#[test] +fn a_new_name_is_announced_as_new() { + let (mut h, _) = with_places(); + typed(&mut h, "Lisbn"); + assert!(has(&h, "kwCompleteCreate"), "a misspelling is a new keyword: say so"); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + typed(&mut h, "weddings"); + assert!(!has(&h, "kwCompleteCreate"), "the library has Weddings"); + assert!(has(&h, "kwComplete:Weddings"), "one of the two lacks it"); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + typed(&mut h, "Places|Portugal"); + assert!(!has(&h, "kwCompleteCreate"), "a keyword with keywords inside it is there too"); +} + +// Given the offers under the box, a click on one gives that keyword to the selected photos; a +// click on the "create" line gives what was typed. Keywords typed before a comma go along. +#[test] +fn a_click_on_an_offer_gives_it() { + let (mut h, ids) = with_places(); + typed(&mut h, "sea, lis"); + assert!(has(&h, "kwComplete:Places|Portugal|Lisbon"), "the offers are for what follows the comma"); + ask(&mut h, "ui.clickWidget", json!({"id": "kwComplete:Places|Portugal|Lisbon"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon") && all_have(&h, &ids, "sea") && none_has(&h, &ids, "lis")); + + typed(&mut h, "harbour"); + ask(&mut h, "ui.clickWidget", json!({"id": "kwCompleteCreate"})); + assert!(all_have(&h, &ids, "harbour")); +} + +// Given more offers than there is room for, ↓ stops at the last and never runs off the list. +#[test] +fn the_highlight_stays_on_the_list() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + for _ in 0..40 { + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + } + let lit: Vec<String> = h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("kwCompleteAt:").map(str::to_string)).collect(); + assert_eq!(lit.len(), 1, "{lit:?}"); + let offered = count(&h, "kwComplete:"); + assert!(offered >= 2, "Portugal and Porto both match"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, &lit[0]), "the last offer, {}, was given", lit[0]); + assert!(none_has(&h, &ids, "por")); +} + +fn toast(h: &Headless) -> String { + h.app.ui.toast.as_ref().map(|t| t.0.clone()).unwrap_or_default() +} + +// Given several photos selected, the box says how many its keywords are of; with one photo there +// is nothing to say. +#[test] +fn the_box_says_how_many_photos_it_is_for() { + let (mut h, ids) = two_selected(); + assert!(has(&h, "keywordScope"), "two photos are selected"); + assert!(rect(&h, "keywordScope").bottom() <= rect(&h, "keywordChip:beach").top(), "above the chips it is about"); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": [ids[0]]}})); + assert!(!has(&h, "keywordScope"), "one photo"); +} + +// The × is a target as tall as its chip and as wide as that, apart from the name next to it. +#[test] +fn the_cross_is_as_tall_as_its_chip() { + let (h, _) = two_selected(); + let (name, cross) = (rect(&h, "keywordChip:beach"), rect(&h, "keywordChipRemove:beach")); + assert!(cross.height() >= 22.0 && cross.width() >= 22.0, "{cross:?}"); + assert_eq!(cross.height(), name.height()); + assert!(cross.left() >= name.right() - 0.5, "beside the name, not over it: {cross:?} {name:?}"); +} + +// Given a keyword on several selected photos, +// when its × takes it off them, +// then a message names the keyword and how many photos lost it and says how to get it back, and +// Undo does. Taking a keyword off one photo says nothing: the chip going is enough. +#[test] +fn taking_a_keyword_off_several_photos_says_so_and_undo_puts_it_back() { + let (mut h, ids) = two_selected(); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:Weddings"})); + assert_eq!(toast(&h), "", "only one of the two had Weddings"); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipRemove:beach"})); + assert!(none_has(&h, &ids, "beach")); + let said = toast(&h); + assert!(said.contains("beach") && said.contains('2') && said.contains("Undo"), "{said}"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo", "params": {}})); + assert!(all_have(&h, &ids, "beach"), "one undo step puts it back on both"); + + // the menu's Remove from Selected Photos says the same + h.app.ui.toast = None; + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChip:beach", "button": "right"})); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipMenu:remove"})); + assert!(none_has(&h, &ids, "beach") && toast(&h).contains("beach"), "{}", toast(&h)); +} + +// Names of any length and script stay inside the panel in both layouts, and nothing breaks: a +// name of 400 letters, one of emoji and right-to-left text, levels of one letter. +#[test] +fn any_name_fits_the_panel() { + let (mut h, ids) = two_selected(); + let long = "x".repeat(400); + let keywords: Vec<String> = + vec![long.clone(), format!("a|{long}"), "🙂|العربية|שלום".into(), "🙂|العربية|مرحبا".into(), "a|b|c|d|e|f|g|h".into(), "İ|ı".into()]; + give(&mut h, &ids, &keywords); + for layout in [crate::state::KeywordChips::Paths, crate::state::KeywordChips::Grouped] { + h.app.ui.settings.keyword_chips = layout; + h.settle(SETTLE); + let panel = rect(&h, "panel:right_panel"); + let chips: Vec<(String, egui::Rect)> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("keywordChip:")).cloned().collect(); + assert_eq!(chips.len(), keywords.len(), "{layout:?}"); + for (id, r) in &chips { + assert!( + r.left() >= panel.left() && r.right() <= panel.right(), + "{layout:?}: {} inside the panel: {r:?}", + id.chars().take(40).collect::<String>() + ); + assert!(r.width() > 0.0 && r.height() < 30.0); + } + } + // and typing them offers them without trouble: the photos here all have them, so another + // photo's emoji keyword is what is offered + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[0]], "keywords": ["🙂|العربية|سلام"]}})); + typed(&mut h, &"x".repeat(300)); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + typed(&mut h, "🙂"); + assert!(has(&h, "kwComplete:🙂|العربية|سلام"), "{:?}", h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete")).collect::<Vec<_>>()); +} + +fn lit(h: &Headless) -> Vec<String> { + h.app.widgets.iter().filter_map(|(w, _)| w.strip_prefix("kwCompleteAt:").map(str::to_string)).collect() +} + +/// Ids of the library's photos after the two selected ones. +fn others(h: &mut Headless) -> Vec<u64> { + h.app.session.visible_cloned().iter().skip(2).take(4).map(|p| p.0).collect() +} + +// Given a long grouped list shown whole after "+N more", +// when other photos with a long list are selected, +// then theirs is short again: asking for more was for the photos it was asked of. +#[test] +fn asking_for_more_is_for_the_selection_it_was_asked_of() { + let (mut h, ids) = two_selected(); + h.app.ui.settings.keyword_chips = crate::state::KeywordChips::Grouped; + give(&mut h, &ids, &sixteen()); + let next = others(&mut h); + give(&mut h, &next, &sixteen()); + ask(&mut h, "ui.clickWidget", json!({"id": "keywordChipsMore"})); + assert_eq!(count(&h, "keywordChip:"), 16); + // taking a keyword off keeps the list whole: it is still theirs + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": ids, "removeKeywords": [sixteen()[15]]}})); + assert_eq!(count(&h, "keywordChip:"), 15); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": next}})); + assert_eq!(count(&h, "keywordChip:"), 7, "short again"); + assert!(has(&h, "keywordChipsMore") && !has(&h, "keywordChipsFewer")); +} + +// Given a keyword two photos spell differently (Lisbon, lisbon), +// when it is typed, +// then it is offered once, and a click gives it. +#[test] +fn a_keyword_spelled_two_ways_is_offered_once() { + let (mut h, ids) = two_selected(); + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[0]], "keywords": ["Lisbon"]}})); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [next[1]], "keywords": ["lisbon"]}})); + typed(&mut h, "lis"); + let offered: Vec<String> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete:")).map(|(w, _)| w.clone()).collect(); + assert_eq!(offered.len(), 1, "{offered:?}"); + ask(&mut h, "ui.clickWidget", json!({"id": offered[0]})); + assert!(ids.iter().all(|id| keywords_of(&h, *id).iter().any(|k| k.eq_ignore_ascii_case("lisbon")))); +} + +// Given a highlighted offer, +// when the library changes under it and the offers come in another order, +// then the highlight is still on that keyword (it used to stay on the line, and Return gave +// whatever keyword had moved there). +#[test] +fn the_highlight_stays_with_its_keyword() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let before = lit(&h); + assert_eq!(before.len(), 1); + let next = others(&mut h); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": next, "addKeywords": ["Portraits"]}})); + assert!(has(&h, "kwComplete:Portraits"), "the offers changed"); + assert_eq!(lit(&h), before, "the highlight did not move to another keyword"); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, &before[0]) && none_has(&h, &ids, "Portraits")); +} + +// Given an offer taken with ↓ and Return, Esc afterwards does not bring back the keywords that +// were just added: the box stays empty. +#[test] +fn escape_after_an_offer_keeps_the_box_empty() { + let (mut h, ids) = with_places(); + typed(&mut h, "sea"); + ask(&mut h, "ui.key", json!({"key": "Tab"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": ", lis"})); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "sea") && all_have(&h, &ids, "Places|Portugal|Lisbon")); + ask(&mut h, "ui.key", json!({"key": "Escape"})); + ask(&mut h, "ui.clickWidget", json!({"id": "field:keyword"})); + ask(&mut h, "ui.key", json!({"key": "End"})); + ask(&mut h, "ui.text", json!({"text": "x"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(all_have(&h, &ids, "x") && none_has(&h, &ids, "seax"), "{:?}", keywords_of(&h, ids[0])); +} + +// ↓ / ↑ / Return with Shift or Alt held are the text box's own (select to the start of the text): +// they leave the offers alone. +#[test] +fn modified_arrows_are_the_text_boxs() { + let (mut h, ids) = with_places(); + typed(&mut h, "por"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown", "shift": true})); + assert!(lit(&h).is_empty(), "Shift+↓ highlights nothing: {:?}", lit(&h)); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let before = lit(&h); + ask(&mut h, "ui.key", json!({"key": "ArrowUp", "shift": true})); + assert_eq!(lit(&h), before); + let _ = ids; +} + +// Given the box scrolled out of the panel with the cursor still in it, its offers are not drawn +// (they used to float over the top bar, where Return still took one). +#[test] +fn offers_go_when_the_box_scrolls_out_of_sight() { + let (mut h, ids) = with_places(); + give(&mut h, &ids, &sixteen()); + typed(&mut h, "lis"); + assert!(has(&h, "kwComplete:Places|Portugal|Lisbon")); + let panel = rect(&h, "panel:right_panel"); + ask(&mut h, "ui.move", json!({"x": panel.center().x, "y": panel.center().y})); + for _ in 0..8 { + ask(&mut h, "ui.scroll", json!({"dy": -400.0})); + } + assert!(rect(&h, "field:keyword").bottom() < panel.top(), "scrolled out: {:?}", rect(&h, "field:keyword")); + assert_eq!(count(&h, "kwComplete"), 0); +} + +// Settings files with something else under the two new keys keep every other setting (and these +// two at their defaults): a wrong value used to discard the whole file. +#[test] +fn wrong_values_for_the_new_settings_keep_the_rest() { + let ui: crate::UiState = serde_json::from_str(r#"{"foldedSections": "oops", "rightWidth": 300.0}"#).unwrap(); + assert!(!ui.section_folded("keywording") && ui.right_width == 300.0); + let ui: crate::UiState = + serde_json::from_str(r#"{"foldedSections": [1, "keywordList", null], "settings": {"keywordChips": "bogus", "filmNames": false}}"#).unwrap(); + assert!(ui.section_folded("keywordList") && !ui.section_folded("keywording")); + assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Paths); + assert!(!ui.settings.film_names, "the other settings are read"); + let ui: crate::UiState = serde_json::from_str(r#"{"settings": {"keywordChips": "grouped"}}"#).unwrap(); + assert_eq!(ui.settings.keyword_chips, crate::state::KeywordChips::Grouped); +} + +// Given an offer taken with ↓ and Return, +// when the panel is switched away at once and comes back much later, +// then the box does not take the cursor unasked: keys typed then are not the box's. +#[test] +fn an_offer_taken_does_not_claim_the_cursor_later() { + let (mut h, ids) = with_places(); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + // (Return and the switch on consecutive frames, as a script does them) + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + ask(&mut h, "ui.set", json!({"right": "info"})); + assert!(all_have(&h, &ids, "Places|Portugal|Lisbon")); + ask(&mut h, "ui.set", json!({"right": "keywords"})); + ask(&mut h, "ui.text", json!({"text": "zz"})); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "zz"), "typed with the cursor nowhere: {:?}", keywords_of(&h, ids[0])); +} + +// Given a highlighted offer and the box scrolled out of sight, Return gives nothing: what isn't +// drawn can't be taken, not even on the frame the box leaves. +#[test] +fn an_offer_out_of_sight_is_not_taken() { + let (mut h, ids) = with_places(); + give(&mut h, &ids, &sixteen()); + typed(&mut h, "lis"); + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + let panel = rect(&h, "panel:right_panel"); + ask(&mut h, "ui.move", json!({"x": panel.center().x, "y": panel.center().y})); + for _ in 0..8 { + // the wheel and Return together, frame after frame + let r = h.request("ui.scroll", json!({"dy": -400.0}), T); + assert_eq!(r["ok"], true, "{r}"); + if rect(&h, "field:keyword").bottom() < panel.top() { + let r = h.request("ui.key", json!({"key": "Enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + } + } + h.settle(SETTLE); + ask(&mut h, "ui.key", json!({"key": "Enter"})); + assert!(none_has(&h, &ids, "Places|Portugal|Lisbon"), "{:?}", keywords_of(&h, ids[0])); +} + +// Given a window too short for the offers under the box, they never cover the box being typed +// in: they go where there is more room and show as many lines as fit there. +#[test] +fn offers_never_cover_the_box() { + for height in [1000.0, 420.0, 300.0, 230.0, 180.0] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, height], 1.0); + let ids: Vec<u64> = h.app.session.visible_cloned().iter().take(12).map(|p| p.0).collect(); + ask(&mut h, "ui.set", json!({"view": "photoGrid", "right": "keywords"})); + // nine keywords to offer for "pa", on photos that are not selected + let many: Vec<String> = (1..=9).map(|i| format!("Parks|park {i}")).collect(); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": &ids[2..], "keywords": many}})); + ask(&mut h, "engine.execute", json!({"command": "library.select", "params": {"ids": &ids[..2]}})); + typed(&mut h, "pa"); + let field = rect(&h, "field:keyword"); + let offers: Vec<egui::Rect> = h.app.widgets.iter().filter(|(w, _)| w.starts_with("kwComplete")).map(|(_, r)| *r).collect(); + assert!(!offers.is_empty(), "{height}: something is offered"); + for r in &offers { + assert!(!r.intersects(field.shrink(0.5)), "{height}: an offer at {r:?} covers the box at {field:?}"); + assert!(r.top() >= -0.5 && r.bottom() <= height + 0.5, "{height}: an offer at {r:?} is off the window"); + } + // ↓ still reaches every offer drawn, and the highlighted one is among them + for _ in 0..12 { + ask(&mut h, "ui.key", json!({"key": "ArrowDown"})); + assert_eq!(lit(&h).len(), 1, "{height}"); + } + } +} + +/// Rename Keyword's "used by n photos" and Create Smart Album's live count scan the library once +/// per change to it, not every frame the dialog is open. +#[test] +fn open_dialogs_count_once_per_library_change() { + let (mut h, ids) = two_selected(); + h.app.ui.dialog = Some(crate::state::Dialog::RenameKeyword { from: "beach".into(), to: "beach".into() }); + let before = h.app.caches.dialog_count_scans; + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 1, "unchanged frames reuse the count"); + ask(&mut h, "engine.execute", json!({"command": "photo.setMeta", "params": {"ids": [ids[1]], "keywords": ["sea"]}})); + h.step(); + assert_eq!(h.app.caches.dialog_count_scans, before + 2, "a library change counts again"); + h.app.ui.dialog = Some(crate::state::Dialog::NewSmartAlbum { name: "Beach".into(), parent: None }); + for _ in 0..10 { + h.step(); + } + assert_eq!(h.app.caches.dialog_count_scans, before + 3, "another dialog counts its own photos, once"); +} diff --git a/crates/ui-egui/src/tests_layout_image.rs b/crates/ui-egui/src/tests_layout_image.rs new file mode 100644 index 000000000..f07b391ca --- /dev/null +++ b/crates/ui-egui/src/tests_layout_image.rs @@ -0,0 +1,181 @@ +//! Headless tests of View ▸ Loupe Overlay ▸ Layout Image: a PNG with transparency drawn over the +//! photo in the loupe, fitted and centred, its opaque parts covering the photo and its transparent +//! parts leaving it alone; opacity, matte, ⌥⌘O, a file that can't be read, and the choice saved +//! with the UI state. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +/// The graphic's opaque band: magenta, unlike anything in the demo photos. +const BAND: [u8; 4] = [255, 0, 255, 255]; + +fn loupe() -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail", "right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + h +} + +/// A portrait "cover": an opaque magenta masthead across its top quarter, transparent below. +fn cover(tag: &str) -> std::path::PathBuf { + let dir = std::env::temp_dir().join(format!("lc-layout-image-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(300, 400, |_, y| if y < 100 { BAND } else { [0, 0, 0, 0] }); + let png = lightcraft_codecs::encode_png(&lightcraft_codecs::EncodeImage::rgba8(&img), &lightcraft_codecs::EncodeMeta::default()).unwrap(); + let path = dir.join("cover.png"); + std::fs::write(&path, png).unwrap(); + path +} + +fn widget(h: &Headless, name: &str) -> Option<egui::Rect> { + h.app.widgets.iter().find(|(w, _)| w == name).map(|(_, r)| *r) +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + h.step(); + h.step(); + r +} + +fn px(img: &egui::ColorImage, p: egui::Pos2) -> [u8; 3] { + let c = img.pixels[(p.y as usize) * img.size[0] + p.x as usize]; + [c.r(), c.g(), c.b()] +} + +fn close(a: [u8; 3], b: [u8; 3], tol: i32) -> bool { + a.iter().zip(b).all(|(x, y)| (*x as i32 - y as i32).abs() <= tol) +} + +#[test] +fn the_layout_image_covers_the_photo_where_opaque_and_follows_opacity_matte_and_the_toggle() { + let mut h = loupe(); + let path = cover("draw"); + let photo = widget(&h, "canvas:image").expect("the loupe shows a photo"); + let before = h.snapshot(SETTLE); + + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.layout_image.show, "choosing a layout image shows it"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some()), "the layout image is drawn once read"); + let shown = h.snapshot(SETTLE); + let r = widget(&h, "canvas:layoutImage").unwrap(); + // fitted inside the photo, centred, with the graphic's 3:4 shape + assert!(photo.expand(1.0).contains_rect(r), "{r:?} inside {photo:?}"); + assert!((r.center().x - photo.center().x).abs() < 1.5 && (r.center().y - photo.center().y).abs() < 1.5, "{r:?} centred on {photo:?}"); + assert!((r.width() / r.height() - 0.75).abs() < 0.02, "{r:?}"); + // the masthead covers the photo; below it the photo shows through untouched + let masthead = egui::pos2(r.center().x, r.top() + r.height() * 0.12); + let clear = egui::pos2(r.center().x, r.top() + r.height() * 0.7); + assert!(close(px(&shown, masthead), [255, 0, 255], 12), "masthead {:?}", px(&shown, masthead)); + assert!(close(px(&shown, clear), px(&before, clear), 3), "{:?} vs {:?}", px(&shown, clear), px(&before, clear)); + + // half opacity: the masthead blends with the photo + let r2 = run(&mut h, "view.layoutImageOpacity", json!({"opacity": 0.5})); + assert_eq!(r2["ok"], true, "{r2}"); + let half = h.snapshot(SETTLE); + let m = px(&half, masthead); + assert!(!close(m, [255, 0, 255], 12) && !close(m, px(&before, masthead), 12), "half-opaque masthead {m:?}"); + + // the matte darkens the photo outside the graphic's box, and only there + // (a 3:4 graphic leaves the sides of a wider photo, the top and bottom of a taller one) + let side = if r.left() - photo.left() > 8.0 { + egui::pos2((photo.left() + r.left()) / 2.0, photo.center().y) + } else { + egui::pos2(photo.center().x, (photo.top() + r.top()) / 2.0) + }; + assert!(!r.expand(2.0).contains(side) && photo.contains(side), "{side:?}: in the photo, outside {r:?}"); + run(&mut h, "view.layoutImageMatte", json!({"matte": 0.75})); + let matte = h.snapshot(SETTLE); + let (lit, dark) = (px(&before, side), px(&matte, side)); + assert!(dark.iter().zip(lit).all(|(d, l)| *d as i32 <= l as i32 / 2 + 2), "matte {dark:?} vs {lit:?}"); + assert!(close(px(&matte, clear), px(&before, clear), 3), "inside the box the matte leaves the photo alone"); + assert!(run(&mut h, "view.layoutImageMatte", json!({}))["ok"] == false, "a matte is required"); + + // ⌥⌘O hides it and shows it again + let k = h.request("ui.key", json!({"key": "o", "cmd": true, "alt": true}), T); + assert_eq!(k["ok"], true, "{k}"); + h.step(); + h.step(); + assert!(!h.app.ui.layout_image.show); + assert!(widget(&h, "canvas:layoutImage").is_none(), "hidden: not drawn"); + let back = h.snapshot(SETTLE); + assert!(close(px(&back, masthead), px(&before, masthead), 3), "hidden: the photo is untouched"); + run(&mut h, "view.layoutImage", json!({"show": true})); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + + // saved with the UI state (the revision is not) + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["layoutImage"]["path"], json!(path.to_string_lossy())); + assert_eq!(saved["layoutImage"]["show"], true); + assert_eq!(saved["layoutImage"]["opacity"], json!(0.5)); + assert!(saved["layoutImage"].get("revision").is_none()); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.layout_image.path, path.to_string_lossy()); + // an older ui.json without it opens with nothing chosen + let old: crate::state::UiState = serde_json::from_value(json!({})).unwrap(); + assert_eq!(old.layout_image, crate::state::LayoutImage::default()); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} + +#[test] +fn a_layout_image_that_cant_be_read_says_so_and_showing_none_asks_for_one() { + let mut h = loupe(); + // nothing chosen: showing it asks for a file, which headless has no dialog for + let r = run(&mut h, "view.layoutImage", json!({})); + assert_eq!(r["ok"], false, "{r}"); + assert!(!h.app.ui.layout_image.show); + assert!(run(&mut h, "view.chooseLayoutImage", json!({"path": " "}))["ok"] == false, "an empty path is refused"); + + let dir = cover("missing"); + let missing = dir.parent().unwrap().join("no-such-cover.png"); + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": missing.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImageError").is_some()), "the canvas says the file can't be read"); + assert!(widget(&h, "canvas:layoutImage").is_none()); + // choosing a readable one replaces the error + let r = run(&mut h, "view.chooseLayoutImage", json!({"path": dir.to_string_lossy()})); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(T, |h| widget(h, "canvas:layoutImage").is_some())); + h.step(); + assert!(widget(&h, "canvas:layoutImageError").is_none()); + let _ = std::fs::remove_dir_all(dir.parent().unwrap()); +} + +#[test] +fn view_loupe_overlay_lists_the_layout_image_commands() { + let mut h = loupe(); + let names = |h: &Headless| -> Vec<(String, bool, Option<bool>)> { + let menu = crate::menubar::expanded(&h.app, "Loupe Overlay").unwrap_or_default(); + menu.into_iter() + .filter_map(|n| match n { + crate::menubar::MenuNode::Item { id, enabled, checked, .. } => Some((id, enabled, checked)), + _ => None, + }) + .collect() + }; + let before = names(&h); + assert_eq!(before[0], ("view.chooseLayoutImage".to_string(), true, None)); + // nothing chosen: only Choose… is enabled + assert!(before[1..].iter().all(|(_, enabled, _)| !enabled), "{before:?}"); + let path = cover("menu"); + run(&mut h, "view.chooseLayoutImage", json!({"path": path.to_string_lossy()})); + let after = names(&h); + assert_eq!(after[1], ("view.layoutImage".to_string(), true, Some(true))); + // 100% opacity and no matte are checked by default + let checked: Vec<&String> = after.iter().filter(|(_, _, c)| *c == Some(true)).map(|(id, ..)| id).collect(); + assert_eq!(checked, ["view.layoutImage", "view.layoutImageOpacity", "view.layoutImageMatte"]); + let _ = std::fs::remove_dir_all(path.parent().unwrap()); +} diff --git a/crates/ui-egui/src/tests_mask_effects.rs b/crates/ui-egui/src/tests_mask_effects.rs new file mode 100644 index 000000000..53377c7cd --- /dev/null +++ b/crates/ui-egui/src/tests_mask_effects.rs @@ -0,0 +1,215 @@ +//! Headless tests of mask effect presets: the Effect menu (groups, search, keys, hover preview, +//! right-click actions), the New Effect Preset dialog and the Manage Effect Presets window. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 900.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + exec(&mut h, "panel.masking", json!({})); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn req(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params, T); + assert_eq!(r["ok"], true, "{method}: {r}"); +} + +fn click(h: &mut Headless, id: &str) { + req(h, "ui.clickWidget", json!({"id": id})); +} + +fn mask(h: &Headless) -> lightcraft_develop::Mask { + let id = h.app.session.active().expect("active photo"); + let d = h.app.session.develop_of(id).unwrap_or_default(); + d.masks.iter().find(|m| Some(m.id) == h.app.session.active_mask).cloned().expect("a selected mask") +} + +/// Three effects in two groups, one starred; a radial mask selected. +fn setup() -> Headless { + let mut h = detail(); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + for (name, values, group) in [ + ("01 Dodge", json!({"exposure": 0.5}), ""), + ("12 Sky", json!({"highlights": -60}), "Detail"), + ("21 Skin", json!({"clarity": -40}), "Retouch"), + ] { + exec(&mut h, "mask.adjust", json!({"values": values})); + exec(&mut h, "mask.saveEffect", json!({"name": name, "group": group})); + let zero: serde_json::Map<String, serde_json::Value> = + lightcraft_engine::cmd::mask_effects::FIELDS.iter().map(|k| (k.to_string(), json!(0))).collect(); + exec(&mut h, "mask.adjust", json!({"values": zero})); + } + exec(&mut h, "mask.favoriteEffect", json!({"name": "21 Skin"})); + h.step(); + h +} + +#[test] +fn effect_menu_groups_searches_and_applies_with_keys() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec<String> = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + for id in ["maskEffect:01 Dodge", "maskEffect:12 Sky", "maskEffect:21 Skin", "maskEffectFav:21 Skin", "maskEffectSearch", "maskEffectMenu:manage"] + { + assert!(ids.iter().any(|x| x == id), "{id} in {ids:?}"); + } + // the pointer resting on an effect previews it on the photo + req(&mut h, "ui.hoverWidget", json!({"id": "maskEffect:12 Sky"})); + h.step(); + let preview = h.app.hover_preview.as_ref().expect("a hover preview"); + assert_eq!(preview.settings.masks.last().map(|m| m.adjust.highlights), Some(-60.0)); + assert_eq!(mask(&h).adjust.highlights, 0.0, "only previewed"); + // typing filters; ↓ and Return apply the first match + req(&mut h, "ui.text", json!({"text": "skin"})); + h.step(); + let widgets = h.request("ui.widgets", json!({}), T); + let ids: Vec<String> = + widgets["result"].as_array().map(|a| a.iter().filter_map(|w| w["id"].as_str().map(str::to_string)).collect()).unwrap_or_default(); + assert!(!ids.iter().any(|x| x == "maskEffect:12 Sky"), "filtered out: {ids:?}"); + req(&mut h, "ui.key", json!({"key": "ArrowDown"})); + h.step(); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert_eq!(mask(&h).adjust.clarity, -40.0); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "21 Skin"); + // a new radial mask now starts with it (the menu remembers per tool) + exec(&mut h, "mask.add", json!({"kind": "radial"})); + assert_eq!(mask(&h).adjust.clarity, -40.0); +} + +#[test] +fn effect_menu_right_click_and_delete_asks_first() { + let mut h = setup(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + // the row's actions are commands; delete goes through a confirmation + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3, "nothing deleted before confirming"); + click(&mut h, "button:dialogCancel"); + assert_eq!(h.app.session.mask_effects.len(), 3); + h.app.ui.dialog = Some(Dialog::DeleteEffects { names: vec!["01 Dodge".into()] }); + h.step(); + req(&mut h, "ui.dialog.confirm", json!({})); + assert_eq!(h.app.session.mask_effects.len(), 2); + // Reset Sliders zeroes the mask and forgets the tool's effect + exec(&mut h, "mask.applyEffect", json!({"name": "12 Sky"})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:reset"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, 0.0); + assert!(h.app.session.mask_tool_effects.is_empty()); +} + +#[test] +fn save_dialog_picks_sliders_amount_and_brush() { + let mut h = setup(); + exec(&mut h, "mask.adjust", json!({"values": {"shadows": 30, "texture": 15, "amount": 70}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + h.step(); + click(&mut h, "maskEffectMenu:save"); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::SaveEffect { .. })), "{:?}", h.app.ui.dialog); + // nothing named: an error, the dialog stays + let r = h.request("ui.dialog.confirm", json!({}), T); + assert_eq!(r["ok"], false, "{r}"); + click(&mut h, "effectInclude:changed"); + h.step(); + click(&mut h, "check:effectAmount"); + h.step(); + req(&mut h, "ui.clickWidget", json!({"id": "field:effectName"})); + req(&mut h, "ui.text", json!({"text": "Lift"})); + h.step(); + let Some(Dialog::SaveEffect { fields, amount, .. }) = &h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + assert_eq!((fields.clone(), *amount), (vec!["shadows".to_string(), "texture".into()], true)); + req(&mut h, "ui.dialog.confirm", json!({})); + let e = h.app.session.mask_effects.iter().find(|e| e.name == "Lift").expect("saved"); + assert_eq!((e.fields.clone(), e.with_amount, e.adjust.amount), (vec!["shadows".to_string(), "texture".into()], true, 70.0)); + assert!(e.brush.is_none(), "not the brush tool"); +} + +#[test] +fn manage_window_renames_groups_stars_duplicates_and_deletes() { + let mut h = setup(); + exec(&mut h, "dialog.manageMaskEffects", json!({})); + h.step(); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. }))); + // select and rename inline + click(&mut h, "effectRow:12 Sky"); + h.step(); + click(&mut h, "button:effectRename"); + h.step(); + req(&mut h, "ui.key", json!({"key": "A", "modifiers": {"command": true}})); + req(&mut h, "ui.text", json!({"text": "12 Sky Darken"})); + req(&mut h, "ui.key", json!({"key": "Enter"})); + h.step(); + h.step(); + assert!( + h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken"), + "{:?}", + h.app.session.mask_effects.iter().map(|e| &e.name).collect::<Vec<_>>() + ); + // duplicate selects the copy; star it; apply it to the mask + click(&mut h, "button:effectDuplicate"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["12 Sky Darken copy".to_string()]); + click(&mut h, "effectStar:12 Sky Darken copy"); + h.step(); + assert!(h.app.session.mask_effects.iter().any(|e| e.name == "12 Sky Darken copy" && e.favorite)); + click(&mut h, "button:effectApply"); + h.step(); + assert_eq!(mask(&h).adjust.highlights, -60.0); + // search narrows the list; a group heading selects the group + req(&mut h, "ui.clickWidget", json!({"id": "field:effectSearch"})); + req(&mut h, "ui.text", json!({"text": "retouch"})); + h.step(); + click(&mut h, "effectGroup:Retouch"); + h.step(); + let Some(Dialog::ManageEffects { selected, .. }) = &h.app.ui.dialog else { panic!() }; + assert_eq!(selected, &vec!["21 Skin".to_string()]); + // delete asks first, inside the window + click(&mut h, "button:effectDelete"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 4); + click(&mut h, "button:effectDeleteConfirm"); + h.step(); + assert_eq!(h.app.session.mask_effects.len(), 3); + assert!(matches!(h.app.ui.dialog, Some(Dialog::ManageEffects { .. })), "stays open"); +} + +#[test] +fn imported_effects_toast_reports_groups_and_what_was_kept() { + let v = json!({"effects": [ + {"name": "a", "group": "Detail", "unmapped": ["Grain"]}, + {"name": "b", "group": "Retouch", "unmapped": ["Point Color", "Grain"]}, + {"name": "c", "group": "", "unmapped": []}, + ]}); + let msg = crate::panels::mask_effects::import_toast(&v); + assert!(msg.contains("3 mask effects") && msg.contains("2 groups") && msg.contains("Grain, Point Color"), "{msg}"); +} diff --git a/crates/ui-egui/src/tests_masking.rs b/crates/ui-egui/src/tests_masking.rs index 43e53932d..4e397208e 100644 --- a/crates/ui-egui/src/tests_masking.rs +++ b/crates/ui-egui/src/tests_masking.rs @@ -343,6 +343,32 @@ fn holding_the_brush_still_does_not_harden_the_spot() { h.settle(SETTLE); } +/// A ⇧-click with the brush paints a straight line from where the last stroke ended (issue #106 of the Classic +/// fork), one undo step; a plain click only paints a dab where it lands. +#[test] +fn shift_click_paints_a_straight_line_from_the_last_stroke() { + let mut h = detail("panel.masking"); + exec(&mut h, "tool.brush", json!({})); + brush_at(&mut h, 0.2, 0.5); + brush_at(&mut h, 0.8, 0.5); + assert!(mask_coverage(&h, 0, 50, 50) < 0.01, "two plain clicks leave the space between them alone"); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.5, "y": 0.2}, {"kind": "up", "x": 0.5, "y": 0.2}], "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 3, "{strokes:?}"); + assert_eq!(strokes[2].points.len(), 2, "from the last stroke's end to the click: {strokes:?}"); + for (x, y) in [(65, 35), (58, 27)] { + assert!(mask_coverage(&h, 0, x, y) > 0.5, "the line from (0.8, 0.5) to (0.5, 0.2) passes ({x}, {y})"); + } + assert!(mask_coverage(&h, 0, 35, 35) < 0.01, "and nowhere else"); + exec(&mut h, "edit.undo", json!({})); + let d = develop(&h); + let MaskShape::Brush { strokes } = &d.masks[0].components[0].shape else { panic!("brush") }; + assert_eq!(strokes.len(), 2, "one undo step takes the line back"); + h.settle(SETTLE); +} + #[test] fn brush_strokes_carry_auto_mask() { let mut h = detail("panel.masking"); @@ -832,6 +858,31 @@ fn g_toggles_grids_and_shift_g_starts_guided_upright() { h.settle(SETTLE); } +#[test] +fn luminance_range_drag_keeps_the_grabbed_handle() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "luminanceRange", "lo": 0.2, "hi": 0.8})); + h.settle(SETTLE); + let original = develop(&h); + for (start, dx, steps) in [(0.2, 180.0, 10), (0.8, -180.0, 10), (0.2, 120.0, 1), (0.8, -120.0, 1)] { + let undo0 = h.app.session.undo.len(); + let r = h.request("ui.dragWidget", json!({"id": "lumRange:0", "fx": start, "fy": 0.5, "dx": dx, "steps": steps}), T); + assert_eq!(r["ok"], true, "{r}"); + let d = develop(&h); + let MaskShape::LuminanceRange { lo, hi, .. } = &d.masks[0].components[0].shape else { panic!() }; + if start < 0.5 { + assert_eq!(*hi, 0.8, "dragging low must leave high fixed, including past high"); + assert!(*lo > 0.6 && *lo < *hi, "{lo}..{hi}"); + } else { + assert_eq!(*lo, 0.2, "dragging high must leave low fixed, including past low"); + assert!(*hi < 0.4 && *hi > *lo, "{lo}..{hi}"); + } + assert_eq!(h.app.session.undo.len(), undo0 + 1); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(develop(&h), original); + } +} + #[test] fn luminance_range_controls_and_map() { let mut h = detail("panel.masking"); @@ -947,38 +998,6 @@ fn local_folder_listed_twice_opens_and_closes_each_row() { h.settle(SETTLE); } -#[test] -fn slideshow_advances_pauses_and_ends() { - let mut h = detail("panel.edit"); - let first = h.app.session.active(); - exec(&mut h, "view.slideshow", json!({"interval": 0.5})); - assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); - // simulated time runs with the frames - let mut moved = false; - for _ in 0..200 { - h.step(); - if h.app.session.active() != first { - moved = true; - break; - } - } - assert!(moved, "the next photo comes up"); - // Space pauses: nothing moves - let r = h.request("ui.key", json!({"key": "space"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(h.app.ui.slideshow.is_some_and(|s| s.2), "paused"); - let held = h.app.session.active(); - for _ in 0..120 { - h.step(); - } - assert_eq!(h.app.session.active(), held); - // Esc ends it - let r = h.request("ui.key", json!({"key": "escape"}), T); - assert_eq!(r["ok"], true, "{r}"); - assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); - h.settle(SETTLE); -} - #[test] fn geometry_slider_drag_marks_the_grid() { let mut h = detail("panel.crop"); @@ -1158,6 +1177,70 @@ fn open_original_in_external_editor_hands_over_the_raw_itself() { h.settle(SETTLE); } +/// Issue #2135: a library record whose original is a program (an imported `.lrcat` names the +/// paths) is never handed to the opener — the launcher is asked for nothing — and the user is +/// told why. The path LightCraft does hand over is absolute, whatever the record's spelling. +#[test] +fn open_original_refuses_anything_that_is_not_a_photo_file() { + use lightcraft_catalog::{Op, Photo, Source}; + let dir = std::env::temp_dir().join(format!("lc-ui-gate-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let opened: std::sync::Arc<std::sync::Mutex<Vec<(String, String)>>> = Default::default(); + let batches: std::sync::Arc<std::sync::Mutex<Vec<Vec<String>>>> = Default::default(); + let (o, bt) = (opened.clone(), batches.clone()); + let services = Services { + png: None, + open_with: Some(Box::new(move |path: &str, app: &str| { + o.lock().unwrap().push((path.to_string(), app.to_string())); + Ok(()) + })), + open_all_with: Some(Box::new(move |paths: &[String], _: &str| { + bt.lock().unwrap().push(paths.to_vec()); + Ok(()) + })), + check_editor: Some(Box::new(|_: &str| Ok(()))), + ..Default::default() + }; + let mut session = lightcraft_engine::Session::with_demo().with_fs(); + let mut record = |name: &str| { + let path = dir.join(name); + std::fs::write(&path, b"MZ").unwrap(); + let id = session.catalog.alloc_photo_id(); + let p = Photo::new(id, Source::File { path: path.to_string_lossy().into_owned() }, name, "ARW", 60, 40, "2026-01-01T10:00:00"); + session.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); + id + }; + // what a crafted catalog can name as a photo's original + let programs = ["payload.exe", "start.lnk", "run.bat", "page.hta", "go.command", "noext"]; + let bad: Vec<_> = programs.iter().map(|n| record(n)).collect(); + let good = record("DSC_0001.NEF"); + let app = LightcraftApp::new(session, services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + h.app.ui.settings.external_editor = "DxO PhotoLab".into(); + + for (id, name) in bad.iter().zip(programs) { + let r = h.request("engine.execute", json!({"command": "photo.openOriginalInExternal", "params": {"ids": [id.0]}}), T); + assert_eq!(r["ok"], false, "{name} was opened: {r}"); + assert!(r.to_string().contains("file types LightCraft reads"), "{name}: {r}"); + // the menu item is greyed out for such a record, so it is never offered either + exec(&mut h, "library.select", json!({"ids": [id.0]})); + assert!(!crate::menus::ui_enabled(&h.app, "photo.openOriginalInExternal"), "{name} is offered in the menu"); + } + assert!(opened.lock().unwrap().is_empty() && batches.lock().unwrap().is_empty(), "the launcher was asked to open a program"); + + // a program selected together with a photo: only the photo is handed over, by absolute path + let r = exec(&mut h, "photo.openOriginalInExternal", json!({"ids": [bad[0].0, good.0]})); + assert_eq!(r["opened"].as_array().map(Vec::len), Some(1), "{r}"); + assert!(r["skipped"].to_string().contains("payload.exe"), "{r}"); + let handed = batches.lock().unwrap().clone(); + assert_eq!(handed.len(), 1); + assert_eq!(handed[0].len(), 1, "{handed:?}"); + assert!(std::path::Path::new(&handed[0][0]).is_absolute() && handed[0][0].ends_with("DSC_0001.NEF"), "{handed:?}"); + let _ = std::fs::remove_dir_all(&dir); + h.settle(SETTLE); +} + #[test] fn second_window_shows_the_active_photo() { let mut h = detail("panel.edit"); @@ -1501,6 +1584,36 @@ fn a_selected_develop_slider_accepts_wheel_nudges() { assert!(after > before, "selected exposure did not increase: {before} -> {after}"); } +/// Scrolling sideways over a slider adjusts it without selecting it first: a trackpad swipe +/// right raises the value, left lowers it, and Shift+wheel (sideways scrolling with a mouse) does +/// the same. A mostly vertical swipe still scrolls the panel and leaves the slider alone. +#[test] +fn sideways_scrolling_adjusts_the_slider_under_the_pointer() { + let mut h = detail("panel.edit"); + let r = h.request("ui.set", json!({"right": "edit"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(SETTLE); + let exposure = |h: &Headless| lightcraft_develop::controls::get(&develop(h), "light.exposure").expect("exposure"); + let scroll = |h: &mut Headless, params: serde_json::Value| { + // the pointer rests on the track, the slider is not clicked + let r = h.request("ui.hoverWidget", json!({"id": "slider:light.exposure"}), T); + assert_eq!(r["ok"], true, "{r}"); + let before = exposure(h); + let r = h.request("ui.scroll", params, T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..10 { + h.step(); + } + exposure(h) - before + }; + assert!(scroll(&mut h, json!({"dx": 120.0})) > 0.0, "a swipe right raises the value"); + assert!(scroll(&mut h, json!({"dx": -120.0})) < 0.0, "a swipe left lowers it"); + assert!(scroll(&mut h, json!({"dy": 120.0, "shift": true})) > 0.0, "Shift+wheel scrolls sideways"); + let undo = h.app.session.undo.len(); + assert_eq!(scroll(&mut h, json!({"dx": 30.0, "dy": -120.0})), 0.0, "a mostly vertical swipe leaves the slider alone"); + assert_eq!(h.app.session.undo.len(), undo); +} + /// Issue #517: a fast brush stroke (pointer samples several brush widths apart) shows on the photo /// while the button is held as the continuous, feathered band it will be once committed, not as /// separate dabs at the samples with gaps between them. @@ -1558,3 +1671,30 @@ fn a_fast_brush_stroke_shows_without_gaps_before_the_button_is_released() { assert!((0..3).all(|i| (l[i] - k[i]).abs() < 24.0), "live {l:?} vs committed {k:?} at ({x}, {y})"); } } + +#[test] +fn effect_dropdown_applies_saves_and_shows_the_matching_effect() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial"})); + exec(&mut h, "mask.adjust", json!({"values": {"exposure": 0.7, "shadows": 25, "amount": 80}})); + h.step(); + // save the mask's sliders from the menu (the New Effect Preset dialog) + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffectMenu:save"); + let mut dlg = h.app.ui.dialog.take().expect("the save dialog opens"); + match &mut dlg { + crate::state::Dialog::SaveEffect { name, .. } => *name = "Lift".into(), + d => panic!("the save dialog opens: {d:?}"), + } + let r = crate::panels::dialogs::confirm_dialog(&mut h.app, &dlg); + assert!(r.is_ok(), "{r:?}"); + assert_eq!(h.app.session.mask_effects.len(), 1); + // change the sliders, then pick the effect again: sliders back, Amount kept + exec(&mut h, "mask.adjust", json!({"values": {"exposure": -1, "clarity": 40, "amount": 50}})); + h.step(); + click(&mut h, "dropdown:maskEffect"); + click(&mut h, "maskEffect:Lift"); + let a = develop(&h).masks.last().expect("a mask").adjust; + assert_eq!((a.exposure, a.shadows, a.clarity, a.amount), (0.7, 25.0, 0.0, 50.0)); + assert_eq!(exec(&mut h, "mask.effects", json!({}))["current"], "Lift"); +} diff --git a/crates/ui-egui/src/tests_model_setup.rs b/crates/ui-egui/src/tests_model_setup.rs index 3068d8221..1366b745a 100644 --- a/crates/ui-egui/src/tests_model_setup.rs +++ b/crates/ui-egui/src/tests_model_setup.rs @@ -69,7 +69,9 @@ fn accepting_a_local_model_installs_in_background_and_resumes_the_pending_action assert_eq!(result["started"], "local-install"); assert_eq!(app.ui.dialog, Some(Dialog::Settings { tab: "denoise".into() })); let ctx = egui::Context::default(); - for _ in 0..600 { + // a deadline, not a count of tries: the install copies on a background thread, slow on a busy machine + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30); + while std::time::Instant::now() < deadline { crate::panels::denoise::pump(&mut app, &ctx); if app.session.denoise_photo_state(raw) != lightcraft_engine::denoise::PhotoState::NoModel { break; diff --git a/crates/ui-egui/src/tests_offline.rs b/crates/ui-egui/src/tests_offline.rs index 3e3ed49d6..9e0b85b78 100644 --- a/crates/ui-egui/src/tests_offline.rs +++ b/crates/ui-egui/src/tests_offline.rs @@ -1,10 +1,14 @@ //! Slow or offline drives never stall a frame (issue #104): with an injected file-system probe -//! that takes far longer than a frame (a sleeping NAS), the grid, the Info panel, the Missing -//! Photos view, the Rename dialog and an import keep drawing frames quickly, the probe never runs -//! on the UI thread, and the background answers still arrive. +//! that does not answer at all until the test lets it (a sleeping NAS), the grid, the Info panel, +//! the Rename dialog, the Missing Photos view and an import keep drawing frames, the probe never +//! runs on the UI thread, and the background answers arrive once the drive wakes up. +//! +//! The NAS is a gate, not a sleep: a frame that waited for the drive would wait for good (until a +//! watchdog gives up and records it), so the tests need no frame-time budget and hold on a slow +//! or loaded machine (the FreeBSD CI VM) as they do on a fast one. -use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Mutex}; +use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; +use std::sync::{Arc, Condvar, Mutex}; use std::thread::ThreadId; use std::time::{Duration, Instant}; @@ -16,11 +20,9 @@ use crate::headless::Headless; use crate::state::{RightPanel, ViewMode}; use crate::{LightcraftApp, Services}; -/// What one file-system call takes on the "NAS". -const SLOW: Duration = Duration::from_millis(400); -/// A frame that waited for one such call would take at least this long. -const FRAME_LIMIT: Duration = Duration::from_millis(300); const N: u64 = 12; +/// How long the drive stays asleep at most: only a frame stuck waiting for it gets this far. +const WATCHDOG: Duration = Duration::from_secs(120); /// Threads the probe ran on, and how often. #[derive(Clone, Default)] @@ -39,9 +41,59 @@ impl Calls { } } +/// The sleeping drive: every file-system call waits until [`Nas::wake`] (or takes one of the +/// calls [`Nas::let_through`] allows). A watchdog wakes it after [`WATCHDOG`] and records that it +/// had to (some frame was waiting for the drive). +#[derive(Default)] +struct Nas { + /// (awake, calls let through while asleep) + state: Mutex<(bool, usize)>, + changed: Condvar, + forced: AtomicBool, +} + +impl Nas { + fn asleep() -> Arc<Self> { + let nas = Arc::new(Nas::default()); + let dog = nas.clone(); + std::thread::spawn(move || { + let state = dog.state.lock().unwrap(); + let (mut state, _) = dog.changed.wait_timeout_while(state, WATCHDOG, |s| !s.0).unwrap(); + if !state.0 { + dog.forced.store(true, Ordering::SeqCst); + state.0 = true; + dog.changed.notify_all(); + } + }); + nas + } + fn wait(&self) { + let state = self.state.lock().unwrap(); + let mut state = self.changed.wait_while(state, |s| !s.0 && s.1 == 0).unwrap(); + if !state.0 { + state.1 -= 1; + } + } + /// Answer `n` more calls, then sleep again. + fn let_through(&self, n: usize) { + self.state.lock().unwrap().1 += n; + self.changed.notify_all(); + } + fn wake(&self) { + self.state.lock().unwrap().0 = true; + self.changed.notify_all(); + } + /// The watchdog had to wake the drive: a frame waited for it. + fn forced(&self) -> bool { + self.forced.load(Ordering::SeqCst) + } +} + /// `N` photos whose originals are on an unreachable share, a smart previews folder, and a probe -/// that sleeps like a NAS waking up before saying "not there". -fn offline_library(calls: &Calls) -> Session { +/// that waits for the NAS to wake up before saying "not there". Called on the UI thread: a call +/// from that thread is recorded and answered at once, so a regression fails the thread check +/// instead of hanging the test. +fn offline_library(calls: &Calls, nas: &Arc<Nas>) -> Session { let mut s = Session::new(); for i in 0..N { let path = format!("/lightcraft-offline-nas/IMG_{i:03}.jpg"); @@ -49,94 +101,96 @@ fn offline_library(calls: &Calls) -> Session { s.catalog.apply(Op::AddPhoto { photo: Box::new(p) }).unwrap(); } s.media.smart_dir = Some(std::env::temp_dir().join("lightcraft-offline-smart-previews-none")); - let c = calls.clone(); + let (c, nas, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.availability.set_probe(Arc::new(move |_| { c.record(); - std::thread::sleep(SLOW); + if std::thread::current().id() != ui_thread { + nas.wait(); + } false })); s } -fn max_frame(h: &mut Headless, frames: usize) -> Duration { - let mut worst = Duration::ZERO; +/// Run `frames` frames while the drive sleeps; each one has to finish without it. +fn frames_while_asleep(h: &mut Headless, nas: &Nas, frames: usize, what: &str) { for _ in 0..frames { - let t0 = Instant::now(); h.step(); - worst = worst.max(t0.elapsed()); + assert!(!nas.forced(), "a {what} frame waited for the sleeping drive"); } - worst } #[test] fn offline_originals_never_block_a_frame() { let calls = Calls::default(); - let app = LightcraftApp::new(offline_library(&calls), Services { png: None, ..Default::default() }); + let nas = Nas::asleep(); + let app = LightcraftApp::new(offline_library(&calls, &nas), Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); let ui_thread = std::thread::current().id(); h.app.ui.view = ViewMode::PhotoGrid; h.step(); - // The first frame installs fonts; finish the first layout before timing the views. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before frame timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); assert!(h.app.session.media.availability.is_background(), "the app checks files off the UI thread"); // the grid: every cell asks for its thumbnail (which asks whether the original is there) h.app.session.execute("library.select", &json!({"ids": [1]})).unwrap(); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "grid frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "grid"); // the Info panel of the active photo, in the loupe h.app.ui.view = ViewMode::Detail; h.app.ui.right = RightPanel::Info; - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Info panel frame took {worst:?}"); + frames_while_asleep(&mut h, &nas, 20, "Info panel"); - // the Missing Photos view fills in as the background checks find the files gone + // the Rename dialog's preview (it checks which names are taken on disk) h.app.ui.view = ViewMode::PhotoGrid; h.app.ui.right = RightPanel::None; + h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); + h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); + frames_while_asleep(&mut h, &nas, 20, "Rename dialog"); + h.app.ui.dialog = None; + + // the Missing Photos view: nothing is known yet, so nothing is listed, and frames go on h.app.session.execute("library.source", &json!({"kind": "missing"})).unwrap(); assert_eq!(h.app.session.source, LibrarySource::Missing); + frames_while_asleep(&mut h, &nas, 20, "Missing Photos"); + assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); + assert_eq!(h.app.session.visible().len(), 0, "unknown files count as there"); + + // the drive answers: the view fills in as the background checks find the files gone + nas.wake(); let t0 = Instant::now(); - let mut worst = Duration::ZERO; while h.app.session.visible().len() < N as usize && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); h.step(); - worst = worst.max(f.elapsed()); std::thread::sleep(Duration::from_millis(5)); } assert_eq!(h.app.session.visible().len(), N as usize, "every photo found missing in the end"); - assert!(worst < FRAME_LIMIT, "Missing Photos frame took {worst:?}"); - - // the Rename dialog's preview (it checks which names are taken on disk) - h.app.session.execute("library.select", &json!({"ids": [1, 2, 3]})).unwrap(); - h.app.ui.dialog = Some(crate::state::Dialog::Rename { template: "Trip-{seq:3}".into(), start: 1 }); - let worst = max_frame(&mut h, 20); - assert!(worst < FRAME_LIMIT, "Rename dialog frame took {worst:?}"); - - assert!(calls.n.load(Ordering::SeqCst) > 0, "the probe was asked"); assert_eq!(calls.on(ui_thread), 0, "the slow probe never ran on the UI thread"); } /// An import whose files take long to read (probe: decoding headers and hashing on a slow drive) -/// runs on a worker thread: frames stay quick, the probe never runs on the UI thread, and Cancel -/// stops it with what was read so far added. +/// runs on a worker thread: frames go on while the drive holds every read, the probe never runs +/// on the UI thread, and Cancel stops it with what was read so far added. #[test] fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { let dir = std::env::temp_dir().join(format!("lc-ui-slow-import-{}", std::process::id())); let _ = std::fs::remove_dir_all(&dir); std::fs::create_dir_all(&dir).unwrap(); - // enough files that the import is still running when its activity row shows (after 0.5 s) let n = 120; for i in 0..n { std::fs::write(dir.join(format!("IMG_{i:03}.jpg")), format!("not really a jpeg {i}")).unwrap(); } let calls = Calls::default(); + let nas = Nas::asleep(); let mut s = Session::new(); - let c = calls.clone(); + let (c, drive, ui_thread) = (calls.clone(), nas.clone(), std::thread::current().id()); s.media.file_probe = Some(Arc::new(move |p: &str| { c.record(); - std::thread::sleep(Duration::from_millis(150)); + // on the UI thread: answered at once, so a regression fails the thread check, not by hanging + if std::thread::current().id() != ui_thread { + drive.wait(); + } Ok(lightcraft_engine::media::ProbeInfo { width: 60, height: 40, @@ -147,32 +201,35 @@ fn import_reads_files_off_the_ui_thread_and_can_be_cancelled() { })); let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); let mut h = Headless::new(app, [1200.0, 800.0], 1.0); - let ui_thread = std::thread::current().id(); h.step(); - // The first frame installs fonts; finish the first layout before timing the import. + // The first frame installs fonts; finish the first layout before the drive matters. h.step(); - assert!(h.app.fonts_ready, "the UI is initialized before import timing starts"); + assert!(h.app.fonts_ready, "the UI is initialized"); let undo0 = h.app.session.undo.len(); crate::import::start_paths(&mut h.app, vec![dir.to_string_lossy().to_string()]).unwrap(); - // some batches arrive; meanwhile every frame is quick - let t0 = Instant::now(); - let mut worst = Duration::ZERO; - while h.app.import.as_ref().is_some_and(|t| t.imported < 8) && t0.elapsed() < Duration::from_secs(60) { - let f = Instant::now(); - h.step(); - worst = worst.max(f.elapsed()); - std::thread::sleep(Duration::from_millis(5)); - } - assert!(worst < FRAME_LIMIT, "import frame took {worst:?}"); - assert!(h.app.import.is_some(), "still importing"); - let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); - assert!(status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); - // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts + // every read is held: frames go on, and the import's row shows (after 0.5 s) with its ✕ + frames_while_asleep(&mut h, &nas, 20, "import"); + assert!(h.app.import.is_some(), "importing"); let id = h.app.session.activity.list().first().map(|t| t.id).expect("an import row"); let cross = format!("activity:cancel:{id}"); - assert!(h.step_until(Duration::from_secs(10), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.widgets.iter().any(|(w, _)| *w == cross)), "the row shows"); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + // the drive answers one batch of reads: it joins the catalog while the next batch waits + let batch = lightcraft_engine::import::batch_size(); + nas.let_through(batch); + let t0 = Instant::now(); + while h.app.import.as_ref().is_some_and(|t| t.imported < batch) && t0.elapsed() < Duration::from_secs(60) { + h.step(); + assert!(!nas.forced(), "an import frame waited for the sleeping drive"); + std::thread::sleep(Duration::from_millis(5)); + } + assert!(h.app.import.is_some(), "still importing"); + let status = h.app.import.as_ref().map(crate::import::ImportTask::status).unwrap_or_default(); + assert!(status["imported"].as_u64().unwrap_or(0) >= 8 && status["total"].as_u64().unwrap_or(0) >= 8, "{status}"); + // Cancel (the ✕ of its row in the activity stack): the files being read finish, nothing new starts let r = h.request("ui.clickWidget", json!({"id": cross}), Duration::from_secs(10)); assert_eq!(r["ok"], true, "{r}"); + nas.wake(); let t0 = Instant::now(); while h.app.import.is_some() && t0.elapsed() < Duration::from_secs(60) { h.step(); diff --git a/crates/ui-egui/src/tests_open_lightroom.rs b/crates/ui-egui/src/tests_open_lightroom.rs index 028801b56..cddfa2bef 100644 --- a/crates/ui-egui/src/tests_open_lightroom.rs +++ b/crates/ui-egui/src/tests_open_lightroom.rs @@ -88,8 +88,10 @@ fn open_library_on_a_lightroom_catalog_offers_its_import() { // library open now. (This fake catalog then fails to read: only its path matters here.) assert_eq!(h.request("ui.clickWidget", json!({"id": "button:dialogOk"}), T)["ok"], true); h.step(); - assert!(h.app.ui.dialog.is_none()); - h.step_until(T, |h| h.app.lightroom.is_none()); + // A fast read failure can already have replaced the offer with its lasting result. + assert!(!matches!(h.app.ui.dialog, Some(Dialog::LightroomCatalog { .. }))); + assert!(h.step_until(T, |h| h.app.lightroom.is_none())); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::LightroomResult { summary, first: None, .. }) if summary.contains("failed"))); let last = h.app.lightroom_last.clone().unwrap_or_default(); assert_eq!(last["path"], json!(lrcat), "{last}"); assert_eq!(h.app.session.library.as_ref().map(|l| l.dir.clone()), Some(lib.clone())); diff --git a/crates/ui-egui/src/tests_panels.rs b/crates/ui-egui/src/tests_panels.rs index b18c4e968..8e360f862 100644 --- a/crates/ui-egui/src/tests_panels.rs +++ b/crates/ui-egui/src/tests_panels.rs @@ -2365,3 +2365,47 @@ fn new_album_can_leave_the_selected_photos_out() { confirm(&mut h); assert!(photos(&h, "Nothing").is_empty()); } + +/// Histogram corner controls stay usable even before a histogram is available, and each +/// click invokes the same clipping-warning command as the J shortcut exactly once. +#[test] +fn histogram_corners_toggle_clipping_warnings() { + let mut h = demo([1400.0, 1000.0], json!({"view": "detail", "right": "edit"})); + for texture in h.app.renderer.textures.values_mut() { + texture.histogram = None; + } + h.step(); + h.app.ui.show_clipping = false; + let histogram = h.app.widgets.iter().find(|(id, _)| id == "histogram").unwrap().1; + for id in ["histogram:shadows", "histogram:highlights"] { + let rect = h.app.widgets.iter().find(|(widget, _)| widget == id).unwrap().1; + assert!(histogram.shrink(8.0).contains_rect(rect), "{id} keeps clear space to the histogram edges: {rect:?} in {histogram:?}"); + } + for (id, enabled) in [("histogram:shadows", true), ("histogram:highlights", false), ("histogram", true)] { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.ui.show_clipping, enabled, "{id} must toggle exactly once"); + } + let r = h.request("ui.key", json!({"key": "J"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.show_clipping); +} + +#[test] +fn the_histogram_clipping_buttons_stay_clear_of_the_hovered_scroll_bar() { + // every section open: the Edit panel scrolls, so its scroll bar runs past the histogram + let h = demo( + [1400.0, 700.0], + json!({"view": "detail", "right": "edit", "histogram": true, "openSections": ["light", "color", "effects", "detail", "optics"]}), + ); + let hist = widget(&h, "histogram"); + let (low, high) = (widget(&h, "histogram:shadows"), widget(&h, "histogram:highlights")); + // a hovered floating scroll bar grows to its full width at the panel's right edge, over the content + let scroll = h.view.ctx.global_style().spacing.scroll; + let bar_left = hist.right() - scroll.bar_outer_margin - scroll.bar_width; + assert!(high.right() <= bar_left, "the highlight clipping button {high:?} is under the scroll bar (from x = {bar_left})"); + // and the shadow one keeps the same inset from its edge + assert!(((low.left() - hist.left()) - (hist.right() - high.right())).abs() < 0.5, "{low:?} {high:?} in {hist:?}"); +} diff --git a/crates/ui-egui/src/tests_preview_limit.rs b/crates/ui-egui/src/tests_preview_limit.rs index 838781268..e87e52355 100644 --- a/crates/ui-egui/src/tests_preview_limit.rs +++ b/crates/ui-egui/src/tests_preview_limit.rs @@ -227,6 +227,40 @@ mod in_the_loupe { assert_eq!(h.app.region_view.map(|r| r.settings), Some(plain.settings), "back to the plain window"); } + // Given the Show All Masks view (issue #1712) at 1:1, the zoomed loupe is a canvas-sized frame + // plus a native window carrying the overlay, as a selected mask's is: the union is evaluated + // from the same mask alphas, so it must not fall back to a whole-frame render at the zoomed + // size (slower, and its detail stops at the frame render's resolution) + #[test] + fn an_all_masks_overlay_at_one_to_one_is_a_window() { + let (mut h, native) = detail(); + for (command, params) in [ + ("panel.masking", json!({})), + ("mask.add", json!({"kind": "radial", "center": [0.4, 0.5], "rx": 0.2, "ry": 0.2})), + ("mask.add", json!({"kind": "linear", "start": [0.2, 0.3], "end": [0.8, 0.7]})), + ] { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + } + let r = h.request("engine.execute", json!({"command": "view.maskOverlayAll", "params": {}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.mask_overlay_all, "the all-masks overlay is on"); + h.request("engine.execute", json!({"command": "view.zoom100"}), T); + h.settle(SETTLE); + let id = h.app.session.active().expect("a photo"); + let d = h.app.session.develop_of(id).expect("settings"); + assert!( + matches!(crate::panels::detail::view_overlay(&h.app, &d), lightcraft_pipeline::Overlay::AllMasks { .. }), + "the all-masks overlay is shown" + ); + let main = rendered_long_edge(&h); + assert!(main <= 2560 && main < native, "native {native}: the whole-frame render is {main}"); + let region = h.app.region_view.expect("a window render with the all-masks overlay"); + assert_eq!(region.full.0.max(region.full.1), native, "cut from the photo's own pixels"); + let (w, hh) = region_tile(&h).expect("its texture"); + assert!(w >= 1300 && hh >= 700, "it covers the canvas: {w}×{hh}"); + } + // Given soft proofing on at 1:1 (LR-VIEW-ZOOM), the view is a canvas-sized frame plus a // native window carrying the proof, as without it, instead of a whole-frame render at the // photo's full size; switching the proof off swaps the window back to the plain look @@ -480,13 +514,57 @@ mod in_the_loupe { h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); h.settle(SETTLE); let main = rendered_long_edge(&h); - assert!(main <= 1000 && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); + // canvas-sized: the whole canvas on the GPU, a 0.6 draft of it on the CPU (#330) + let canvas = h.app.canvas_rect.expect("the loupe was drawn"); + let canvas_long = canvas.width().max(canvas.height()) as usize + crate::state::LOUPE_EDGE_STEP; + assert!(main <= canvas_long && main * 4 < native, "the whole-frame draft is {main} px for a {native} px photo"); let region = h.app.region_view.expect("the window is drafted too"); assert_eq!(region.full.0.max(region.full.1), native, "at 100 %, magnified by the GPU"); let (w, hh) = region_tile(&h).expect("a window draft"); assert!(w * hh <= 3_000_000, "{w}×{hh}: the window holds what is on screen, not the frame"); } + /// Drag Exposure in the loupe and return the long edge of the whole-frame draft shown, with + /// the largest a small (CPU-sized) draft of the photo as drawn can be. + fn drag_exposure(h: &mut Headless) -> (usize, usize) { + h.app.session.begin_interaction("Exposure").unwrap(); + h.request("engine.execute", json!({"command": "develop.set", "params": {"control": "light.exposure", "value": 0.5}}), T); + h.settle(SETTLE); + let image = h.app.image_rect.expect("the photo was drawn"); + let small = (image.width().max(image.height()) * crate::region::CPU_DRAFT_SCALE) as usize + crate::state::LOUPE_EDGE_STEP; + (rendered_long_edge(h), small) + } + + // Given a drag whose full-size drafts were too slow before (the CPU, a LUT profile, a slow + // GPU), when the same slider is dragged again, then it drafts small (#330) + #[test] + fn a_slow_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, it is the earlier + // drag's time that makes this drag small + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + let id = h.app.session.active().expect("a photo is open"); + h.app.drag_scale.remember_slow(id, "Exposure"); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + + // Given soft proofing against an output-device profile (rendered on the CPU even with a GPU), a + // drag drafts small (#330); proofs into an output space run on the GPU like any render + #[test] + fn a_soft_proofed_drag_drafts_small() { + // the GPU device up front (the loupe never waits for it): with one, this is the case where + // the GPU is there and the render still goes to the CPU + let _ = lightcraft_engine::gpu::available(); + let (mut h, _) = detail(); + let lut = lightcraft_pipeline::ProofLut::new(2, vec![[0.5, 0.5, 0.5, 0.0]; 8]).expect("a valid table"); + h.app.ui.proof.profile = lightcraft_pipeline::register_proof_profile(0x5eed_9f01, lut); + h.app.ui.soft_proof = true; + h.settle(SETTLE); + let (main, small) = drag_exposure(&mut h); + assert!(main <= small, "the draft is {main} px, a small one at most {small}"); + } + fn tile(h: &Headless, slot: Slot) -> Option<(usize, usize)> { h.app.renderer.textures.get(&slot).map(|t| (t.size[0], t.size[1])) } @@ -918,9 +996,18 @@ mod in_the_loupe { assert!(window[0].0 > whole[0].0, "over the whole-frame render"); } + /// The engine caches' share of the default memory budget (1.5 GiB) on a machine with 6 GiB of + /// RAM or more: room for both panes' 24 MP originals (288 MB each as linear float). + const COMPARE_CACHE: usize = 768 << 20; + /// Compare view of the demo library at `zoom`, settled. + /// + /// The session's cache budget is set here rather than taken from the process-wide memory + /// budget, which other tests change while they run (Settings ▸ Performance ▸ 1 GB leaves a + /// 512 MiB share, too small for two originals, so one pane's was evicted). fn compare_at(zoom: crate::state::Zoom) -> Headless { let (mut h, _) = detail(); + h.app.session.media.set_budget(COMPARE_CACHE); let r = h.request("engine.execute", json!({"command": "view.compare"}), T); assert_eq!(r["ok"], true, "{r}"); assert_eq!(h.app.ui.view, crate::state::ViewMode::Compare); diff --git a/crates/ui-egui/src/tests_print.rs b/crates/ui-egui/src/tests_print.rs new file mode 100644 index 000000000..28838f409 --- /dev/null +++ b/crates/ui-egui/src/tests_print.rs @@ -0,0 +1,409 @@ +//! The Print dialog, driven through the control channel: styles, cells dragged and resized with +//! real pointer input, Auto Layout, pages, templates and the PDF. + +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use serde_json::{Value, json}; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(30); + +fn demo(services: Services) -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn rect(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn has(h: &Headless, id: &str) -> bool { + h.app.widgets.iter().any(|(w, _)| w == id) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn drag(h: &mut Headless, id: &str, dx: f32, dy: f32) { + let r = h.request("ui.dragWidget", json!({"id": id, "dx": dx, "dy": dy, "steps": 8}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); +} + +fn cmd(h: &mut Headless, id: &str, params: Value) -> Value { + let r = h.request("engine.execute", json!({"command": id, "params": params}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.settle(T); + r["result"].clone() +} + +/// The first page's MediaBox width and height in points. +fn media_box(pdf: &[u8]) -> (f64, f64) { + let text = String::from_utf8_lossy(pdf); + let at = text.find("/MediaBox [").expect("a MediaBox") + "/MediaBox [".len(); + let nums: Vec<f64> = text[at..].split(']').next().unwrap().split_whitespace().map(|v| v.parse().unwrap()).collect(); + (nums[2], nums[3]) +} + +fn open_print(h: &mut Headless) { + cmd(h, "dialog.print", json!({})); + assert!(matches!(h.app.ui.dialog, Some(Dialog::Print))); + assert!(has(h, "print:page"), "the page is on screen"); +} + +/// Screen points per inch of the page shown. +fn scale(h: &Headless) -> f32 { + rect(h, "print:page").width() / h.app.session.print.layout.page_size().unwrap().0 as f32 +} + +fn cell_rect(h: &Headless, id: u32) -> lightcraft_engine::print::layout::Rect { + h.app.session.print.layout.cell(id).unwrap().rect +} + +#[test] +fn the_print_dialog_opens_from_the_menu_command_and_closes() { + let mut h = demo(Services::default()); + open_print(&mut h); + assert!(has(&h, "button:printStyle-0") && has(&h, "check:printZoomFill") && has(&h, "button:dialogOk")); + assert!(rect(&h, "print:pageLabel").width() > 0.0); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); +} + +#[test] +fn style_buttons_and_checkboxes_run_print_set() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + assert_eq!(h.app.session.print.layout.style, lightcraft_engine::print::layout::Style::ContactSheet); + assert!(has(&h, "drag:printColumns") && has(&h, "print:gridcell:0")); + click(&mut h, "check:printZoomFill"); + click(&mut h, "check:printStroke"); + assert!(h.app.session.print.image.zoom_to_fill && h.app.session.print.image.stroke_enabled); + click(&mut h, "button:printOrientation-1"); + assert_eq!(h.app.session.print.layout.page.orientation, lightcraft_engine::print::layout::Orientation::Landscape); + let p = rect(&h, "print:page"); + assert!(p.width() > p.height(), "the page is drawn landscape"); + click(&mut h, "button:printUnits-2"); + assert_eq!(h.app.session.print.guides.units, lightcraft_engine::print::layout::Unit::Millimeters); + click(&mut h, "check:printShowGrid"); + assert!(h.app.session.print.guides.show_page_grid); +} + +#[test] +fn cells_are_added_dragged_resized_and_arranged() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + // snapping off, so the pointer maths is exact + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + click(&mut h, "button:printAddCell-4"); + click(&mut h, "button:printAddCell-1"); + let cells: Vec<u32> = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(cells.len(), 2); + assert_eq!(h.app.session.print.layout.cells[0].rect.w, 4.0); + assert!(has(&h, &format!("print:cell:{}", cells[0]))); + + // drag the first cell with the pointer: the move is in inches, through print.cell.move + let before = cell_rect(&h, cells[0]); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", cells[0]), 0.0, 100.0); + let after = cell_rect(&h, cells[0]); + assert!((after.y - before.y - f64::from(100.0 / s)).abs() < 0.05, "moved {} in, expected {}", after.y - before.y, 100.0 / s); + assert!((after.x - before.x).abs() < 0.05); + assert_eq!(h.app.ui.print.selected, Some(cells[0])); + + // resize with its corner handle + let r0 = cell_rect(&h, cells[0]); + drag(&mut h, &format!("print:resize:{}", cells[0]), 30.0, 20.0); + let r1 = cell_rect(&h, cells[0]); + assert!((r1.w - r0.w - f64::from(30.0 / s)).abs() < 0.05 && (r1.h - r0.h - f64::from(20.0 / s)).abs() < 0.05, "{r0:?} → {r1:?}"); + assert_eq!((r1.x, r1.y), (r0.x, r0.y), "the top-left corner stays"); + + // arrange: Front moves it last + click(&mut h, "button:printStack-3"); + assert_eq!(h.app.session.print.layout.cells.last().unwrap().id, cells[0]); + click(&mut h, "button:printStack-0"); + assert_eq!(h.app.session.print.layout.cells.first().unwrap().id, cells[0]); + + // the inspector's delete + click(&mut h, "button:printCellDelete"); + assert_eq!(h.app.session.print.layout.cells.len(), 1); + assert_eq!(h.app.ui.print.selected, None); +} + +#[test] +fn dragging_a_cell_snaps_to_its_neighbour_when_snap_is_on() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-3"); + cmd(&mut h, "print.set", json!({"layout": {"spacing": 0.0}})); + click(&mut h, "button:printAddCell-0"); + click(&mut h, "button:printAddCell-0"); + let ids: Vec<u32> = h.app.session.print.layout.cells.iter().map(|c| c.id).collect(); + assert_eq!(h.app.session.print.guides.snap, lightcraft_engine::print::layout::Snap::Cells); + let a = cell_rect(&h, ids[0]); + // park the second cell away from the first, then drag it back to 0.06 in from the first's right edge + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + let s = scale(&h); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - a.right()).abs() < 1e-6, "snapped to the neighbour's edge: {} vs {}", r.x, a.right()); + // with snapping off the same drag lands where the pointer put it + cmd(&mut h, "print.set", json!({"guides": {"snap": "off"}})); + cmd(&mut h, "print.cell.move", json!({"id": ids[1], "x": a.right() + 0.8, "y": a.y})); + drag(&mut h, &format!("print:cell:{}", ids[1]), -(0.74 * s), 0.0); + let r = cell_rect(&h, ids[1]); + assert!((r.x - (a.right() + 0.06)).abs() < 0.05 && r.x > a.right() + 0.01, "unsnapped: {}", r.x - a.right()); +} + +#[test] +fn auto_layout_new_page_and_clear_buttons() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-2"); + for _ in 0..3 { + click(&mut h, "button:printAddCell-4"); + } + for _ in 0..3 { + click(&mut h, "button:printAddCell-5"); + } + let before_pages = h.app.session.print.layout.pages; + // scramble everything onto one spot, then pack + cmd(&mut h, "print.layout.autoLayout", json!({})); + click(&mut h, "button:printAutoLayout"); + assert!(!h.app.session.print.layout.has_overlaps()); + assert!(h.app.session.print.layout.pages >= before_pages.min(2)); + let pages = h.app.session.print.layout.pages; + click(&mut h, "button:printNewPage"); + assert_eq!(h.app.session.print.layout.pages, pages + 1); + assert_eq!(h.app.ui.print.page as u32, pages, "the new page is shown"); + // the next-page button stays on the last page + click(&mut h, "button:printPageNext"); + assert_eq!(h.app.ui.print.page as u32, pages); + click(&mut h, "button:printPagePrev"); + assert_eq!(h.app.ui.print.page as u32, pages - 1); + click(&mut h, "button:printClear"); + assert_eq!((h.app.session.print.layout.cells.len(), h.app.session.print.layout.pages), (0, 1)); + assert_eq!(h.app.ui.print.page, 0); +} + +#[test] +fn templates_save_apply_and_delete_from_the_dialog() { + let mut h = demo(Services::default()); + open_print(&mut h); + click(&mut h, "button:printStyle-1"); + cmd(&mut h, "print.set", json!({"layout": {"grid": {"columns": 5, "rows": 6}}})); + h.app.ui.print.template_name = "Sheet 5x6".into(); + click(&mut h, "button:printSaveTemplate"); + assert_eq!(h.app.session.print_templates.len(), 1); + cmd(&mut h, "print.reset", json!({})); + assert!(has(&h, "button:printTemplateApply-0")); + click(&mut h, "button:printTemplateApply-0"); + assert_eq!(h.app.session.print.layout.grid.columns, 5); + click(&mut h, "button:printTemplateDelete-0"); + assert!(h.app.session.print_templates.is_empty()); +} + +#[test] +fn print_to_pdf_picks_a_file_and_runs_in_the_background() { + let asked = Arc::new(Mutex::new(Vec::<crate::pick::PickRequest>::new())); + let requests = asked.clone(); + let bytes = Arc::new(Mutex::new(Vec::new())); + let written = bytes.clone(); + let services = Services { + picker: Some(Box::new(move |req| { + requests.lock().unwrap().push(req); + let (tx, rx) = std::sync::mpsc::channel(); + tx.send(vec!["Print.pdf".into()]).unwrap(); + Ok(rx) + })), + write_shared: Some(Arc::new(move |path, data| { + assert_eq!(path, "Print.pdf"); + *written.lock().unwrap() = data.to_vec(); + Ok(()) + })), + ..Default::default() + }; + let mut h = demo(services); + open_print(&mut h); + cmd(&mut h, "print.set", json!({"layout": {"page": {"paper": "5x7", "orientation": "landscape"}}, "job": {"ppi": 100.0}})); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "the dialog closes once the print has started"); + assert!(h.step_until(Duration::from_secs(60), |h| h.app.last_export_result.is_some()), "the print did not finish"); + let req = asked.lock().unwrap(); + assert_eq!(req[0].file_name.as_deref(), Some("Print.pdf")); + let b = bytes.lock().unwrap(); + assert!(b.starts_with(b"%PDF-")); + let (pw, ph) = media_box(&b); + assert!((pw - 504.0).abs() < 1.0 && (ph - 360.0).abs() < 1.0, "7 x 5 in, got {pw} x {ph} pt"); + assert_eq!(h.app.last_export_result.as_ref().unwrap()["files"][0]["print"], true); +} + +#[test] +fn printing_shows_a_row_in_the_activity_stack_and_stops() { + let mut app = LightcraftApp::new( + lightcraft_engine::Session::with_demo(), + Services { write_shared: Some(Arc::new(|_, _| panic!("a stopped print wrote a file"))), ..Default::default() }, + ); + app.session.execute("print.set", &json!({"layout": {"style": "contactSheet"}, "job": {"ppi": 300.0}})).unwrap(); + let ids: Vec<_> = app.session.catalog.photos().take(12).map(|p| p.id.0).collect(); + crate::export_task::start_print(&mut app, &json!({"path": "Stop.pdf", "ids": ids})).unwrap(); + let rows = app.session.activity.list(); + assert_eq!(rows.len(), 1, "{rows:?}"); + assert_eq!((rows[0].kind, rows[0].label.as_str()), ("export", "Printing to PDF")); + assert!(rows[0].cancellable); + app.session.activity.cancel(rows[0].id).unwrap(); + let task = app.export.take().unwrap(); + assert!(task.cancel.load(std::sync::atomic::Ordering::Relaxed)); + drop(task); + assert!(app.session.activity.list().is_empty(), "the row goes with the print"); +} + +#[test] +fn the_print_menu_item_needs_a_photo_and_is_listed() { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + assert!(crate::menus::ui_enabled(&app, "dialog.print")); + let empty = LightcraftApp::new(lightcraft_engine::Session::new(), Services::default()); + assert!(!crate::menus::ui_enabled(&empty, "dialog.print")); +} + +#[test] +fn the_print_dialog_fits_the_window_with_its_buttons_on_screen() { + for size in [[1400.0, 900.0], [1100.0, 760.0]] { + for style in ["singleImage", "customPackage"] { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + let mut h = Headless::new(app, size, 1.0); + h.settle(T); + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "customPackage" { + cmd(&mut h, "print.cell.add", json!({"width": 4, "height": 6})); + } + open_print(&mut h); + let screen = egui::Rect::from_min_size(egui::Pos2::ZERO, h.size); + let dialog = rect(&h, "dialog:window"); + assert!(screen.contains_rect(dialog), "{style} at {size:?}: dialog {dialog:?} runs off the window"); + for b in ["button:dialogOk", "button:dialogCancel"] { + assert!(dialog.contains_rect(rect(&h, b)), "{style} at {size:?}: {b} is outside the dialog"); + } + } + } +} + +#[test] +fn print_job_colour_controls_reach_the_job_and_the_pdf() { + use lightcraft_engine::print_color::{PrintIntent, PrintMedia, PrintProfile, PrintSharpen}; + let dir = std::env::temp_dir().join(format!("lightcraft-print-ui-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let icc = dir.join("Baryta Paper.icc"); + std::fs::write(&icc, lightcraft_codecs::icc::write_named(lightcraft_codecs::NamedSpace::AdobeRgb)).unwrap(); + let picked = icc.to_string_lossy().into_owned(); + let services = Services { png: None, pick_display_profile: Some(Box::new(move || vec![picked.clone()])), ..Default::default() }; + let mut h = demo(services); + open_print(&mut h); + // the Print Job panel is at the bottom of the settings column: scroll down to it, as a person would + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": -600.0}), T); + h.settle(T); + } + let job = |h: &Headless| h.app.session.print.job.color.clone(); + assert_eq!(job(&h).intent, PrintIntent::Perceptual); + assert!(!has(&h, "item:printProfile-other"), "the menu is closed"); + + click(&mut h, "button:printIntent-1"); + assert_eq!(job(&h).intent, PrintIntent::Relative); + click(&mut h, "check:printBpc"); + assert!(!job(&h).black_point_compensation); + click(&mut h, "button:printSharpen-3"); + click(&mut h, "button:printMedia-0"); + assert_eq!((job(&h).sharpen, job(&h).media), (PrintSharpen::High, PrintMedia::Matte)); + click(&mut h, "check:print16bit"); + assert!(!job(&h).sixteen_bit); + drag(&mut h, "drag:printBrightness", 30.0, 0.0); + assert!(job(&h).brightness > 0.0, "{}", job(&h).brightness); + + // a printer profile from the picker + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-other"); + assert_eq!(job(&h).profile, PrintProfile::Icc { path: icc.clone() }); + + // the PDF uses it: converted pages, the profile named as the output intent, 8 bits + cmd( + &mut h, + "print.set", + json!({"layout": {"style": "contactSheet", "grid": {"columns": 1, "rows": 1}, "page": {"paper": "4x6"}}, "job": {"ppi": 72.0}}), + ); + let ids: Vec<u64> = h.app.session.catalog.photos().take(1).map(|p| p.id.0).collect(); + let out = dir.join("colour.pdf"); + cmd(&mut h, "print.exportPdf", json!({"path": out, "ids": ids})); + let text = String::from_utf8_lossy(&std::fs::read(&out).unwrap()).into_owned(); + assert!(text.contains("/OutputIntents") && text.contains("/BitsPerComponent 8") && text.contains("/ICCBased")); + + // back to Managed by Printer + click(&mut h, "combo:printProfile"); + click(&mut h, "item:printProfile-adobeRgb"); + assert_eq!(job(&h).profile, PrintProfile::Managed { space: lightcraft_engine::print_color::ManagedSpace::AdobeRgb }); +} + +#[test] +fn the_print_preview_does_not_rebuild_thumbnails_every_frame() { + let mut h = demo(Services { png: None, ..Default::default() }); + let before = h.app.renderer.thumb_jobs_built; + cmd(&mut h, "dialog.print", json!({})); + for style in ["contactSheet", "picturePackage", "singleImage"] { + cmd(&mut h, "print.set", json!({"layout": {"style": style}})); + if style == "picturePackage" { + // the same photo in cells of different sizes + cmd(&mut h, "print.cell.add", json!({"width": 2.0, "height": 2.5})); + cmd(&mut h, "print.cell.add", json!({"width": 5.0, "height": 7.0})); + } + assert!(h.settle(Duration::from_secs(5)), "{style}: the dialog settles"); + let built = h.app.renderer.thumb_jobs_built; + for _ in 0..60 { + h.step(); + } + assert_eq!(h.app.renderer.thumb_jobs_built, built, "{style}: no thumbnail is rebuilt while nothing changes"); + assert_eq!(h.app.renderer.in_flight(), 0, "{style}"); + } + assert!(h.app.renderer.thumb_jobs_built - before < 50, "{} jobs", h.app.renderer.thumb_jobs_built - before); +} + +#[test] +fn page_options_checkboxes_set_the_page_and_hide_for_packages() { + let mut h = demo(Services::default()); + open_print(&mut h); + let scroll = |h: &mut Headless, dy: f32| { + let r = h.request("ui.hoverWidget", json!({"id": "combo:printPaper"}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..4 { + h.request("ui.scroll", json!({"dy": dy}), T); + h.settle(T); + } + }; + // the Page panel is below Page Setup: scroll down to it + scroll(&mut h, -600.0); + for (id, on) in [("check:printPageNumbers", 0), ("check:printPageInfo", 1), ("check:printCropMarks", 2)] { + click(&mut h, id); + let p = &h.app.session.print.page; + assert_eq!([p.page_numbers, p.page_info, p.crop_marks].iter().filter(|v| **v).count(), on + 1, "{id}"); + } + let p = &h.app.session.print.page; + assert!(p.page_numbers && p.page_info && p.crop_marks); + scroll(&mut h, 600.0); + click(&mut h, "button:printStyle-1"); + assert!(has(&h, "check:printCropMarks"), "a contact sheet has Page Options"); + // Lightroom's packages have no Page Options + click(&mut h, "button:printStyle-2"); + assert!(!has(&h, "check:printPageNumbers") && !has(&h, "check:printCropMarks")); +} diff --git a/crates/ui-egui/src/tests_profiles_layout.rs b/crates/ui-egui/src/tests_profiles_layout.rs new file mode 100644 index 000000000..ccc521151 --- /dev/null +++ b/crates/ui-egui/src/tests_profiles_layout.rs @@ -0,0 +1,70 @@ +//! The profile browser in a narrow right panel: names stay inside their cells (#1634). + +use egui::{Rect, pos2, vec2}; +use serde_json::json; + +use crate::{LightcraftApp, Services, headless::HeadlessView}; + +const LONG_ID: &str = "lut:kodak-portra-400-plus-one-stop-overexposed"; +const LONG_NAME: &str = "Kodak Portra 400 Plus One Stop Overexposed"; + +fn text_shapes(shape: &egui::Shape, out: &mut Vec<egui::epaint::TextShape>) { + match shape { + egui::Shape::Text(text) => out.push(text.clone()), + egui::Shape::Vec(shapes) => shapes.iter().for_each(|s| text_shapes(s, out)), + _ => {} + } +} + +fn panel(width: f32) -> (Vec<egui::epaint::TextShape>, Vec<(String, Rect)>) { + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services::default()); + app.session.lut_profiles.push(lightcraft_engine::cmd::lut_profiles::LutProfile { + id: LONG_ID.into(), + name: LONG_NAME.into(), + group: "Film Stocks".into(), + file: String::new(), + }); + app.run("develop.profile", json!({"id": LONG_ID})).unwrap(); + let photo = app.session.active().unwrap(); + let ctx = egui::Context::default(); + crate::theme::install_fonts(&ctx); + crate::theme::apply(&ctx); + let mut texts = Vec::new(); + let mut widgets = Vec::new(); + for frame in 0..3 { + let raw = HeadlessView::raw_input(vec2(width, 4000.0), 1.0, frame as f64 / 60.0, vec![]); + let mut out = ctx.run_ui(raw, |ui| { + egui::CentralPanel::default().frame(egui::Frame::NONE).show(ui, |ui| { + crate::panels::profiles::show(&mut app, ui, photo); + }); + }); + out.textures_delta.clear(); + texts.clear(); + for shape in &out.shapes { + text_shapes(&shape.shape, &mut texts); + } + widgets = crate::widgets::take_registry(&ctx); + } + (texts, widgets) +} + +#[test] +fn profile_names_stay_inside_their_cells_in_a_narrow_panel() { + let width = 250.0; + let (texts, widgets) = panel(width); + let cell = widgets.iter().find(|(id, _)| id == &format!("profileCell:{LONG_ID}")).unwrap().1; + // the cell's name sits below its thumbnail; the header's copy is above every cell + let name = texts.iter().find(|t| t.galley.job.text == LONG_NAME && t.pos.y >= cell.top()).unwrap(); + assert!(name.galley.elided, "a name wider than its cell is cut short"); + let bounds = name.visual_bounding_rect(); + assert!(bounds.left() >= cell.left() && bounds.right() <= cell.right(), "{bounds:?} spills out of {cell:?}"); + let applied = texts.iter().find(|t| t.galley.job.text == LONG_NAME && t.pos.y < cell.top()).unwrap(); + assert!(applied.visual_bounding_rect().right() <= width, "the applied profile's name leaves the panel"); + // built-in names too: each one stays out of its neighbour's cell + for p in lightcraft_engine::presets::PROFILES { + let cell = widgets.iter().find(|(id, _)| id == &format!("profileCell:{}", p.id)).unwrap().1; + let name = texts.iter().find(|t| t.galley.job.text == p.name && cell.contains(pos2(cell.center().x, t.pos.y))).unwrap(); + let bounds = name.visual_bounding_rect(); + assert!(bounds.left() >= cell.left() && bounds.right() <= cell.right(), "{}: {bounds:?} spills out of {cell:?}", p.name); + } +} diff --git a/crates/ui-egui/src/tests_recheck.rs b/crates/ui-egui/src/tests_recheck.rs new file mode 100644 index 000000000..88149e464 --- /dev/null +++ b/crates/ui-egui/src/tests_recheck.rs @@ -0,0 +1,90 @@ +//! Issue #662: opening a library rechecks its preview-only raws in the background. + +use std::path::{Path, PathBuf}; +use std::time::Duration; + +use lightcraft_catalog::{Op, PhotoId}; +use serde_json::json; + +use crate::LightcraftApp; + +struct Dir(PathBuf); + +impl Drop for Dir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +/// An app on a library at `dir/lib` with one photo whose preview-only flag an older version set. +/// Its file (a PNG) reads fine, as a raw a newer decoder handles would. +fn app_with_stale_photo(dir: &Path) -> (LightcraftApp, PhotoId) { + let _ = std::fs::remove_dir_all(dir); + std::fs::create_dir_all(dir.join("files")).unwrap(); + let img = lightcraft_raster::Rgba8::from_fn(16, 12, |x, y| [(x * 9) as u8, (y * 11) as u8, 50, 255]); + let o = lightcraft_engine::export::ExportOptions { format: lightcraft_engine::export::ExportFormat::Png, ..Default::default() }; + let file = dir.join("files/one.png"); + std::fs::write(&file, lightcraft_engine::export::encode_image(&img, &o).unwrap()).unwrap(); + let mut session = lightcraft_engine::Session::new().with_fs(); + session.open_library(dir.join("lib"), false).unwrap(); + let r = session.execute("library.import", &json!({"paths": [file.to_string_lossy()]})).unwrap(); + let id = PhotoId(r["imported"][0].as_u64().unwrap()); + let p = session.catalog.photo(id).unwrap().clone(); + session + .catalog + .apply(Op::SetContent { + id, + width: p.width, + height: p.height, + file_size: p.file_size, + content_hash: p.content_hash.clone(), + preview_only: Some("Cr3 files are not decoded yet".into()), + preview_checked: "0.5.0".into(), + }) + .unwrap(); + (LightcraftApp::new(session, Default::default()), id) +} + +#[test] +fn opening_a_library_rechecks_stale_preview_only_raws_in_the_background() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::recheck::tick(&mut app); + // it shows in the activity stack while it runs + let rows = app.session.activity.list(); + assert!(rows.iter().any(|r| r.kind == "recheck" && r.label == crate::recheck::LABEL), "{rows:?}"); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.session.activity.list().iter().all(|r| r.kind != "recheck"), "the row goes with the task"); + let p = app.session.catalog.photo(id).unwrap(); + assert_eq!(p.preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "1 raw can now be developed from its raw data"), "{:?}", app.ui.toast); + // once per opened library + app.ui.toast = None; + crate::recheck::tick(&mut app); + assert!(app.tasks.is_empty()); +} + +/// A result for a library that was closed meanwhile is dropped, not applied to the new one. +#[test] +fn recheck_result_is_dropped_after_switching_libraries() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-switch-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::recheck::tick(&mut app); + app.run("app.openLibrary", json!({"path": dir.0.join("other").to_string_lossy()})).unwrap(); + assert!(crate::tasks::wait(&mut app, &ctx, Duration::from_secs(10))); + assert!(app.ui.toast.is_none(), "{:?}", app.ui.toast); + assert!(app.session.catalog.photo(id).is_none(), "the other library is untouched"); +} + +/// The notice's Reload from Disk re-reads the file and says what came of it. +#[test] +fn notice_reload_button_rereads_the_file() { + let dir = Dir(std::env::temp_dir().join(format!("lc-ui-recheck-reload-{}", std::process::id()))); + let (mut app, id) = app_with_stale_photo(&dir.0); + let ctx = egui::Context::default(); + crate::panels::edit::reload_preview_only(&mut app, &ctx, id); + assert_eq!(app.session.catalog.photo(id).unwrap().preview_only, None); + assert!(app.ui.toast.as_ref().is_some_and(|(t, _, _)| t == "This raw is now developed from its raw data"), "{:?}", app.ui.toast); +} diff --git a/crates/ui-egui/src/tests_rename_person.rs b/crates/ui-egui/src/tests_rename_person.rs new file mode 100644 index 000000000..4ad1f5871 --- /dev/null +++ b/crates/ui-egui/src/tests_rename_person.rs @@ -0,0 +1,138 @@ +//! Rename Person from the People overview and from a person's page. + +use std::time::Duration; + +use lightcraft_catalog::Op; +use serde_json::json; + +use crate::headless::Headless; +use crate::state::Dialog; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn demo() -> Headless { + let services = Services { png: None, write_shared: Some(std::sync::Arc::new(|_: &str, _: &[u8]| Ok(()))), ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.settle(T); + h +} + +fn face(x: f64, name: &str) -> lightcraft_meta::Region { + lightcraft_meta::Region { + rect: lightcraft_geom::Rect { x0: x, y0: 0.2, x1: x + 0.25, y1: 0.6 }, + kind: lightcraft_meta::RegionKind::Face, + name: Some(name.to_string()), + description: None, + } +} + +fn name_faces(h: &mut Headless, id: lightcraft_catalog::PhotoId, regions: Vec<lightcraft_meta::Region>) { + let mut meta = h.app.session.catalog.photo(id).unwrap().meta.clone(); + meta.regions = regions; + h.app.session.commit("setup", Op::SetMeta { id, meta: Box::new(meta) }).unwrap(); +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn click(h: &mut Headless, id: &str) { + let r = h.request("ui.clickWidget", json!({"id": id}), T); + assert_eq!(r["ok"], true, "{id}: {r}"); + h.step(); + h.step(); +} + +fn right_click(h: &mut Headless, id: &str) { + let c = widget(h, id).center(); + let r = h.request("ui.click", json!({"x": c.x, "y": c.y, "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +fn open_people(h: &mut Headless) { + let r = h.request("engine.execute", json!({"command": "view.people"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.settle(T); + h.step(); +} + +fn stored(h: &Headless, id: lightcraft_catalog::PhotoId) -> Vec<Option<String>> { + h.app.session.catalog.photo(id).unwrap().meta.regions.iter().map(|r| r.name.clone()).collect() +} + +/// Both entry points open the dialog. Cancel and an empty name change nothing; a real rename updates +/// the person page, the way back to it, and a person filter. +#[test] +fn rename_person_from_the_card_and_the_page() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe"), face(0.5, "jane doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "Jane Doe")]); + open_people(&mut h); + right_click(&mut h, "person:Jane Doe"); + click(&mut h, "personMenu:rename"); + assert!(matches!(&h.app.ui.dialog, Some(Dialog::RenamePerson { from, to, .. }) if from == "Jane Doe" && to == "Jane Doe")); + click(&mut h, "button:dialogCancel"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); + + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + to.clear(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "an empty name does not rename"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("dialog closed") }; + *to = "Ada Lovelace".into(); + h.app.ui.last_person = Some("Jane Doe".into()); + h.app.ui.person_from = Some((Some("Jane Doe".into()), ids[0].0)); + h.app.session.execute("library.filter", &json!({"person": "jane doe"})).unwrap(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none(), "{:?}", h.app.ui.dialog); + assert!(stored(&h, ids[0]).iter().all(|n| n.as_deref() == Some("Ada Lovelace"))); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.last_person.as_deref(), Some("Ada Lovelace")); + assert_eq!(h.app.ui.person_from.as_ref().and_then(|(n, _)| n.as_deref()), Some("Ada Lovelace")); + assert_eq!(h.app.session.filter.person.as_deref(), Some("Ada Lovelace")); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0]), vec![Some("Jane Doe".into()), Some("jane doe".into())]); +} + +/// Renaming onto someone else waits for Combine, then one undo step restores both people. +#[test] +fn rename_person_asks_before_merging() { + let mut h = demo(); + let ids: Vec<_> = h.app.session.catalog.photos().map(|p| p.id).take(2).collect(); + name_faces(&mut h, ids[0], vec![face(0.1, "Jane Doe")]); + name_faces(&mut h, ids[1], vec![face(0.3, "John Roe")]); + open_people(&mut h); + h.request("engine.execute", json!({"command": "view.person", "params": {"name": "Jane Doe"}}), T); + h.step(); + click(&mut h, "person:rename"); + let Some(Dialog::RenamePerson { to, .. }) = &mut h.app.ui.dialog else { panic!("{:?}", h.app.ui.dialog) }; + *to = "John Roe".into(); + h.step(); + click(&mut h, "button:dialogOk"); + assert!(matches!(h.app.ui.dialog, Some(Dialog::RenamePerson { .. })), "a merge is not silent"); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + click(&mut h, "check:personMerge"); + click(&mut h, "button:dialogOk"); + assert!(h.app.ui.dialog.is_none()); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("John Roe")); + assert_eq!(h.app.ui.person_page.as_deref(), Some("John Roe")); + let names: Vec<String> = h.app.session.catalog.people().into_iter().map(|p| p.name).collect(); + assert_eq!(names, vec!["John Roe".to_string()]); + h.app.session.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(stored(&h, ids[0])[0].as_deref(), Some("Jane Doe")); + assert_eq!(stored(&h, ids[1])[0].as_deref(), Some("John Roe")); +} diff --git a/crates/ui-egui/src/tests_scroll.rs b/crates/ui-egui/src/tests_scroll.rs index d04a818a4..dd999108a 100644 --- a/crates/ui-egui/src/tests_scroll.rs +++ b/crates/ui-egui/src/tests_scroll.rs @@ -209,24 +209,63 @@ fn filmstrip_shows_every_selected_photo() { assert_eq!(fill(&mut h, second), active); } -/// A mouse wheel notch (a line) scrolls `theme::WHEEL_LINE_POINTS`, not egui's default 40: long -/// panels took 50+ notches. +/// One notch of a mouse wheel (a line). +fn notch(h: &mut Headless, dy: f32) { + h.events.push(egui::Event::MouseWheel { + unit: egui::MouseWheelUnit::Line, + delta: egui::vec2(0.0, dy), + phase: egui::TouchPhase::Move, + modifiers: egui::Modifiers::NONE, + }); + h.step(); +} + +/// A mouse wheel notch scrolls the grid a third of its height, as in Lightroom (two rows of six), not the +/// `theme::WHEEL_LINE_POINTS` a side panel scrolls: at big thumbnails that was a quarter of a row. Exactly +/// a third, not whole rows, so the photos don't land where the last ones were, and at once, without a +/// glide that blurs them. A trackpad still scrolls the points it reports. #[test] -fn a_wheel_notch_scrolls_a_hundred_points() { +fn a_wheel_notch_scrolls_a_third_of_the_grid() { let mut h = demo("photoGrid"); - let canvas = h.app.canvas_rect.expect("grid canvas"); - h.request("ui.move", json!({"x": canvas.center().x, "y": canvas.center().y}), T); + let view = widget(&h, "grid:scroll"); + h.request("ui.move", json!({"x": view.center().x, "y": view.center().y}), T); let y0 = grid_y(&h); - for _ in 0..3 { - h.events.push(egui::Event::MouseWheel { - unit: egui::MouseWheelUnit::Line, - delta: egui::vec2(0.0, -1.0), - phase: egui::TouchPhase::Move, - modifiers: egui::Modifiers::NONE, - }); - h.step(); + notch(&mut h, -1.0); + // at once, no glide: the frame after the notch draws it there + h.step(); + h.step(); + let moved = grid_y(&h) - y0; + assert!((moved - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", view.height()); + idle(&mut h, 120); + assert!((grid_y(&h) - y0 - moved).abs() < 0.5, "and no further: {} after the glide", grid_y(&h) - y0); + let y1 = grid_y(&h); + for _ in 0..2 { + notch(&mut h, -1.0); } idle(&mut h, 120); - let moved = grid_y(&h) - y0; - assert!(moved >= 3.0 * crate::theme::WHEEL_LINE_POINTS - 1.0, "three notches scrolled {moved} points"); + let moved = grid_y(&h) - y1; + assert!((moved - 2.0 * view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "two notches scrolled {moved} of {} points", view.height()); + // back up + let y2 = grid_y(&h); + notch(&mut h, 1.0); + idle(&mut h, 120); + assert!((y2 - grid_y(&h) - view.height() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch up scrolled back a third"); + // a trackpad (points) scrolls what it reports + let y3 = grid_y(&h); + h.request("ui.scroll", json!({"dy": -60.0}), T); + idle(&mut h, 120); + assert!((grid_y(&h) - y3 - 60.0).abs() < 1.0, "a 60-point trackpad scroll moved {}", grid_y(&h) - y3); +} + +/// The same in the filmstrip: a notch scrolls it sideways by a third of its width (about five photos). +#[test] +fn a_wheel_notch_scrolls_a_third_of_the_filmstrip() { + let mut h = demo("detail"); + let strip = widget(&h, "filmstrip"); + h.request("ui.move", json!({"x": strip.center().x, "y": strip.center().y}), T); + let x0 = film_x(&h); + notch(&mut h, -1.0); + idle(&mut h, 120); + let moved = film_x(&h) - x0; + assert!((moved - strip.width() * crate::theme::WHEEL_STRIP_SHARE).abs() < 1.0, "a notch scrolled {moved} of {} points", strip.width()); } diff --git a/crates/ui-egui/src/tests_slider_reset.rs b/crates/ui-egui/src/tests_slider_reset.rs new file mode 100644 index 000000000..ead2491b1 --- /dev/null +++ b/crates/ui-egui/src/tests_slider_reset.rs @@ -0,0 +1,151 @@ +//! Headless tests of resetting a slider (issue #1436): double-clicking its name or its thumb puts it +//! back to its default in one undo step, as in Lightroom, and resting the pointer on either says so. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); +/// The sliders the issue names, each set away from its default. +const EDITED: [(&str, f64); 2] = [("light.exposure", 1.25), ("light.contrast", 40.0)]; + +/// The demo library in Detail with the Edit panel's Light section open. +fn edit_panel() -> Headless { + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + let id = h.app.session.visible_cloned()[0].0; + assert_eq!(h.request("engine.execute", json!({"command": "library.select", "params": {"ids": [id]}}), T)["ok"], true); + assert_eq!(h.request("ui.set", json!({"view": "detail", "right": "edit"}), T)["ok"], true); + h.app.ui.open_sections = vec!["light".to_string()]; + h.settle(SETTLE); + h +} + +fn widget(h: &Headless, id: &str) -> egui::Rect { + h.app.widgets.iter().find(|(w, _)| w == id).map(|(_, r)| *r).unwrap_or_else(|| panic!("no widget {id}")) +} + +fn value(h: &Headless, control: &str) -> f64 { + let id = h.app.session.selection.active.unwrap(); + lightcraft_develop::controls::get(&h.app.session.develop_of(id).unwrap_or_default(), control).unwrap() +} + +fn default(control: &str) -> f64 { + lightcraft_develop::controls::find(control).unwrap().default +} + +fn ask(h: &mut Headless, method: &str, params: serde_json::Value) { + let r = h.request(method, params.clone(), T); + assert_eq!(r["ok"], true, "{method} {params}: {r}"); + h.settle(SETTLE); +} + +fn set(h: &mut Headless, control: &str, value: f64) { + ask(h, "engine.execute", json!({"command": "develop.set", "params": {"control": control, "value": value}})); +} + +/// The thumb's place along the track (0..1), plus `dx` points: a hand lands near its centre, not on it. +fn thumb_fx(h: &Headless, control: &str, dx: f32) -> f32 { + let spec = lightcraft_develop::controls::find(control).unwrap(); + let track = widget(h, &format!("slider:{control}")); + spec.position(value(h, control)) as f32 + dx / track.width() +} + +/// A double-click on a widget: the control channel plays each press and release in a frame of its +/// own, so the slider sees the first click before the second, as with a hand on a mouse. +fn double_click(h: &mut Headless, id: &str, fx: f32) { + ask(h, "ui.clickWidget", json!({"id": id, "count": 2, "fx": fx})); +} + +#[test] +fn double_clicking_a_sliders_name_resets_it_in_one_step() { + let mut h = edit_panel(); + for (control, edited) in EDITED { + set(&mut h, control, edited); + let undo = h.app.session.undo.len(); + double_click(&mut h, &format!("sliderLabel:{control}"), 0.1); + assert_eq!(value(&h, control), default(control), "{control} is back at its default"); + assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo"})); + assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back"); + } +} + +/// The first click of the double-click lands a few points off the thumb's centre: it leaves the value +/// alone (it used to move it there, a history step of its own before the reset). +#[test] +fn double_clicking_a_sliders_thumb_resets_it_in_one_step() { + let mut h = edit_panel(); + for (control, edited) in EDITED { + set(&mut h, control, edited); + let undo = h.app.session.undo.len(); + let fx = thumb_fx(&h, control, 4.0); + double_click(&mut h, &format!("slider:{control}"), fx); + assert_eq!(value(&h, control), default(control), "{control} is back at its default"); + assert_eq!(h.app.session.undo.len() - undo, 1, "{control}: one undo step"); + ask(&mut h, "engine.execute", json!({"command": "edit.undo"})); + assert_eq!(value(&h, control), edited, "{control}: undo brings the edit back"); + } +} + +/// A click on the thumb leaves the value alone; a click elsewhere on the track still moves it there. +#[test] +fn a_click_on_the_thumb_keeps_the_value() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + let fx = thumb_fx(&h, "light.exposure", -4.0); + ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": fx})); + assert_eq!(value(&h, "light.exposure"), 1.25); + ask(&mut h, "ui.clickWidget", json!({"id": "slider:light.exposure", "fx": 0.25})); + assert!((value(&h, "light.exposure") - -2.5).abs() < 0.02, "{}", value(&h, "light.exposure")); +} + +#[test] +fn a_sliders_name_and_thumb_say_a_double_click_resets_it() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + let hint = "Exposure — double-click to reset it"; + let shown = |h: &Headless| h.painted_text().iter().any(|t| t == hint); + let thumb = thumb_fx(&h, "light.exposure", 0.0); + for (what, id, fx) in [("name", "sliderLabel:light.exposure", 0.1), ("thumb", "slider:light.exposure", thumb)] { + // somewhere else first, so the hint of the step before goes + ask(&mut h, "ui.hoverWidget", json!({"id": "sliderLabel:light.contrast", "fx": 0.9})); + assert!(!shown(&h), "{what}: no hint left over"); + ask(&mut h, "ui.hoverWidget", json!({"id": id, "fx": fx})); + // tooltips wait for the pointer to rest + assert!( + h.step_until(Duration::from_secs(5), shown), + "{what}: {:?}", + h.painted_text().iter().filter(|t| t.contains("reset")).collect::<Vec<_>>() + ); + } + // the rest of the track is for dragging and clicking: no hint there + ask(&mut h, "ui.hoverWidget", json!({"id": "slider:light.exposure", "fx": 0.1})); + for _ in 0..90 { + h.step(); + } + assert!(!shown(&h), "no hint away from the thumb"); + // the value keeps its own hint + ask(&mut h, "ui.hoverWidget", json!({"id": "sliderValue:light.exposure"})); + assert!(h.step_until(Duration::from_secs(5), |h| h.painted_text().iter().any(|t| t == "Click to type a value, drag to change it"))); +} + +/// Typing a value (KEY-SLIDER-TYPE, #1362) still works next to the reset. +#[test] +fn a_typed_value_still_applies_after_a_reset() { + let mut h = edit_panel(); + set(&mut h, "light.exposure", 1.25); + double_click(&mut h, "sliderLabel:light.exposure", 0.1); + assert_eq!(value(&h, "light.exposure"), 0.0); + let undo = h.app.session.undo.len(); + ask(&mut h, "ui.clickWidget", json!({"id": "sliderValue:light.exposure"})); + for (method, params) in [("ui.text", json!({"text": "0.5"})), ("ui.key", json!({"key": "Enter"}))] { + ask(&mut h, method, params); + } + assert_eq!(value(&h, "light.exposure"), 0.5); + assert_eq!(h.app.session.undo.len() - undo, 1, "one undo step"); +} diff --git a/crates/ui-egui/src/tests_slideshow.rs b/crates/ui-egui/src/tests_slideshow.rs new file mode 100644 index 000000000..24aa0d728 --- /dev/null +++ b/crates/ui-egui/src/tests_slideshow.rs @@ -0,0 +1,725 @@ +//! The slideshow in the app (View ▸ Slideshow): playback with the library's settings, slides +//! prepared ahead off the UI thread, transitions only between ready slides, keys and controls. +//! Simulated time: every headless frame is 1/60 s, so nothing here sleeps. + +use std::time::Duration; + +use lightcraft_catalog::PhotoId; +use lightcraft_engine::slideshow::Item; +use serde_json::json; + +use crate::headless::Headless; +use crate::render::Slot; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); +const SETTLE: Duration = Duration::from_secs(120); + +fn grid() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1000.0, 700.0], 1.0); + h.step(); + h +} + +fn run(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +fn play(h: &Headless) -> &lightcraft_engine::slideshow::Playback { + &h.app.ui.slideshow.as_ref().expect("a slideshow is playing").play +} + +fn has_slide(h: &Headless, item: impl Into<Item>) -> bool { + let item = item.into(); + let tex = h.app.renderer.textures.get(&Slot::Slide(item)); + match item { + Item::Photo(id) => tex.is_some_and(|t| t.photo == id), + Item::Title(_) => tex.is_some(), + } +} + +/// The photo up now (none while a title screen is). +fn photo_up(h: &Headless) -> PhotoId { + play(h).current_photo().expect("a photo is up") +} + +fn slides_held(h: &Headless) -> usize { + h.app.renderer.textures.keys().filter(|s| matches!(s, Slot::Slide(_))).count() +} + +#[test] +fn slideshow_advances_pauses_and_ends() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0})); + assert!(h.app.ui.fullscreen && h.app.ui.slideshow.is_some()); + assert_eq!(h.app.session.active(), Some(first), "starts at the first photo in view"); + assert!(h.step_until(SETTLE, |h| h.app.session.active() != Some(first)), "the next photo comes up and is selected"); + // Space pauses: nothing moves + let r = h.request("ui.key", json!({"key": "space"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "paused"); + let held = h.app.session.active(); + for _ in 0..120 { + h.step(); + } + assert_eq!(h.app.session.active(), held); + // Esc ends it, and the slides are let go + let r = h.request("ui.key", json!({"key": "escape"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!h.app.ui.fullscreen && h.app.ui.slideshow.is_none()); + assert_eq!(slides_held(&h), 0, "slide textures freed"); + h.settle(SETTLE); +} + +#[test] +fn next_slides_are_prepared_before_they_are_due() { + let mut h = grid(); + // a long slide: anything rendered for the next ones is rendered ahead of time + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 1, "preparePreviews": true, "seed": 7})); + let ahead = play(&h).upcoming(crate::panels::slideshow::AHEAD); + assert_eq!(ahead.len(), 2); + let start = h.time(); + // the slide up and the next two; their renders can finish in any order + let current = photo_up(&h); + assert!( + h.step_until(SETTLE, |h| has_slide(h, current) && ahead.iter().all(|id| has_slide(h, *id))), + "the slide up and the next two slides are rendered" + ); + assert!(h.time() - start < 60.0, "before the first is due"); + assert_eq!(photo_up(&h), current, "still on the first slide"); + h.settle(SETTLE); + + // off: only the slide up is rendered until the next one is due + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60, "preparePreviews": false})); + let next = play(&h).upcoming(1)[0]; + let current = photo_up(&h); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + for _ in 0..30 { + h.step(); + } + assert!(!has_slide(&h, next) && !h.app.renderer.is_pending(Slot::Slide(next)), "not prepared"); + h.settle(SETTLE); +} + +#[test] +fn a_fade_only_runs_between_rendered_slides() { + let mut h = grid(); + // without previews prepared, each slide renders when it is due: the transition must wait + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.4, "preparePreviews": false})); + let mut fades = 0; + let t0 = std::time::Instant::now(); + while fades < 20 && t0.elapsed() < SETTLE { + h.step(); + let f = play(&h).frame(h.time()); + if let Some(from) = f.from { + fades += 1; + assert!(has_slide(&h, f.current), "fading in a slide that isn't rendered"); + assert!(has_slide(&h, from), "fading out a slide that was let go"); + } + } + assert!(fades >= 20, "fades ran ({fades})"); + h.settle(SETTLE); +} + +#[test] +fn arrows_cut_and_repeat_off_ends_after_the_last_slide() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 2, "repeat": false})); + let r = h.request("ui.key", json!({"key": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(photo_up(&h), photos[1]); + assert_eq!(h.app.session.active(), Some(photos[1]), "the selection follows"); + assert!(play(&h).frame(h.time()).from.is_none(), "a cut, not a fade"); + let r = h.request("ui.key", json!({"key": "left"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(photo_up(&h), photos[0]); + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + } + assert_eq!(play(&h).position(), (photos.len(), photos.len())); + run(&mut h, "slideshow.next", json!({})); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "no repeat: past the last one it ends"); + h.settle(SETTLE); +} + +#[test] +fn random_order_starts_at_the_selection_and_is_fixed_by_the_seed() { + let order = |seed: u64| { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "library.select", json!({"ids": [photos[3].0]})); + run(&mut h, "view.slideshow", json!({"randomOrder": true, "seed": seed, "interval": 60})); + let mut seen = vec![photo_up(&h)]; + for _ in 1..photos.len() { + run(&mut h, "slideshow.next", json!({})); + seen.push(photo_up(&h)); + } + h.settle(SETTLE); + (photos, seen) + }; + let (photos, a) = order(11); + assert_eq!(a[0], photos[3]); + let in_view_order: Vec<PhotoId> = photos.iter().copied().filter(|p| *p != photos[3]).collect(); + assert_ne!(a[1..].to_vec(), in_view_order, "shuffled"); + let mut sorted = a.clone(); + sorted.sort(); + let mut all = photos.clone(); + all.sort(); + assert_eq!(sorted, all, "every photo once"); + assert_eq!(order(11).1, a, "same seed, same order"); +} + +#[test] +fn playback_controls_show_on_pointer_movement_and_hide() { + let mut h = grid(); + run(&mut h, "view.slideshow", json!({"interval": 60})); + h.step(); + let shown = |h: &Headless| h.app.widgets.iter().any(|(w, _)| w == "icon:slideshowPlayPause"); + assert!(!shown(&h), "hidden until the pointer moves"); + for fx in [0.3, 0.6] { + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); + assert!(shown(&h), "the pointer moved: the controls show"); + assert!(h.painted_text().iter().any(|t| t.contains(&format!("1 / {}", h.app.session.visible_cloned().len()))), "slide n of N"); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowPlayPause"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(play(&h).is_paused(), "the play / pause button pauses"); + // the pointer rests away from the bar: gone after a few seconds + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.5, "fy": 0.2}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..(60.0 * (crate::panels::slideshow::CONTROLS_SECS + 0.5)) as usize { + h.step(); + } + assert!(!shown(&h), "hidden again"); + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": 0.55, "fy": 0.25}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "icon:slideshowEnd"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "the end button ends it"); + h.settle(SETTLE); +} + +#[test] +fn playback_settings_drive_the_slideshow_and_bad_ones_are_refused() { + let mut h = grid(); + let r = run(&mut h, "slideshow.playback", json!({"slideDuration": 9, "fadeDuration": 0.5, "repeat": false})); + assert_eq!(r["slideDuration"], 9.0); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"slideDuration": "slow"}}), T); + assert_ne!(r["ok"], true, "{r}"); + assert!(h.app.ui.slideshow.is_none()); + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {"seed": -1}}), T); + assert_ne!(r["ok"], true, "{r}"); + let r = run(&mut h, "view.slideshow", json!({})); + assert_eq!(r["settings"]["slideDuration"], 9.0); + assert_eq!(r["settings"]["repeat"], false); + // `interval` (the older parameter) is this run's slide duration; the saved one stays + run(&mut h, "slideshow.end", json!({})); + let r = run(&mut h, "view.slideshow", json!({"interval": 2})); + assert_eq!(r["settings"]["slideDuration"], 2.0); + assert_eq!(h.app.session.slideshow.slide_duration, 9.0); + // the options open in Settings + run(&mut h, "slideshow.end", json!({})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + assert!(h.app.widgets.iter().any(|(w, _)| w == "check:settings.slideshowRandom")); + // (below the Templates at the top of the tab) + reveal(&mut h, "check:settings.slideshowRandom"); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowRandom"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slideshow.random_order, "the checkbox goes through slideshow.playback"); + h.settle(SETTLE); +} + +#[test] +fn a_photo_deleted_while_it_plays_does_not_stall_the_slideshow() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0, "preparePreviews": false})); + // the next slide goes away before it is due + let next = play(&h).upcoming(1)[0]; + let next = next.photo().unwrap(); + run(&mut h, "photo.delete", json!({"ids": [next.0]})); + run(&mut h, "photo.deletePermanently", json!({"ids": [next.0]})); + assert!(h.app.session.catalog.photo(next).is_none()); + assert!(h.step_until(SETTLE, |h| h.app.ui.slideshow.as_ref().is_some_and(|r| r.play.position().0 >= 3)), "it plays on"); + assert!(photos.len() > 3); + h.settle(SETTLE); +} + +/// Scroll the Settings ▸ Slideshow column until widget `id` is in view (near its top): the +/// sections are taller than the dialog. +fn reveal(h: &mut Headless, id: &str) { + let find = |h: &Headless, w: &str| h.app.widgets.iter().find(|(i, _)| i == w).map(|(_, r)| *r); + for _ in 0..40 { + h.step(); + let col = find(h, "slideshow:preview").expect("the Slideshow settings are open"); + let dy = match find(h, id) { + Some(w) if w.center().y >= col.top() + 10.0 && w.center().y <= col.top() + 340.0 => return, + Some(w) => (col.top() + 80.0 - w.center().y).clamp(-500.0, 500.0), + None => -300.0, + }; + // (the pointer over the settings column, left of the preview) + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:preview", "fx": -1.0, "fy": 0.5}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.scroll", json!({"dy": dy}), T); + assert_eq!(r["ok"], true, "{r}"); + for _ in 0..8 { + h.step(); + } + } + panic!("{id} never came into view"); +} + +fn painted(img: &egui::ColorImage, x: usize, y: usize) -> [u8; 3] { + let c = img.pixels[y * img.size[0] + x]; + [c.r(), c.g(), c.b()] +} + +#[test] +fn a_composed_slide_shows_during_playback() { + let mut h = grid(); + let r = run(&mut h, "slideshow.layout", json!({"margins": 0.1, "backdropColor": "#ff0000"})); + assert_eq!(r["marginTop"], 0.1); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = photo_up(&h); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap(); + assert_eq!(tex.size, [1000, 700], "a composed slide fills the screen"); + let img = h.paint(); + assert_eq!(img.size, [1000, 700]); + // 10 % of the 700 px short side = 70 px of red backdrop around the image cell + for (x, y) in [(5, 5), (995, 5), (500, 30), (500, 690), (30, 350), (985, 350)] { + assert_eq!(painted(&img, x, y), [255, 0, 0], "margin at ({x}, {y})"); + } + let mid = painted(&img, 500, 350); + assert_ne!(mid, [255, 0, 0], "the photo in the cell"); + // the next slide is composed too, and stepping shows it + let next = play(&h).upcoming(1)[0]; + assert!(h.step_until(SETTLE, |h| has_slide(h, next))); + run(&mut h, "slideshow.next", json!({})); + h.step(); + assert_eq!(painted(&h.paint(), 5, 5), [255, 0, 0]); + h.settle(SETTLE); +} + +#[test] +fn the_default_layout_plays_the_photo_as_before() { + let mut h = grid(); + assert!(h.app.session.slide_layout.is_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = photo_up(&h); + assert!(h.step_until(SETTLE, |h| has_slide(h, current))); + h.step(); + // the photo's own render (its aspect, not the screen's), fitted on black, as before + let tex = h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap(); + let aspect = h.app.session.slide_photo_aspect(current).unwrap(); + let tex_aspect = tex.size[0] as f64 / tex.size[1] as f64; + assert!((tex_aspect - aspect).abs() < 0.02, "{tex_aspect} vs {aspect}"); + assert_ne!(tex.size, [1000, 700]); + let img = h.paint(); + let r = crate::panels::detail::fit_texture_rect(egui::Rect::from_min_size(egui::Pos2::ZERO, egui::vec2(1000.0, 700.0)), tex.size); + if r.left() > 2.0 { + assert_eq!(painted(&img, 1, 350), [0, 0, 0], "black beside the photo"); + } + if r.top() > 2.0 { + assert_eq!(painted(&img, 500, 1), [0, 0, 0], "black above the photo"); + } + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_slide_layout_and_a_live_preview() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + h.step(); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + for w in [ + "slider:slideshowMargins", + "check:settings.slideshowZoomToFill", + "check:settings.slideshowStroke", + "check:settings.slideshowShadow", + "button:slideshowBackdropColor", + "check:settings.slideshowWash", + "slideshow:preview", + ] { + assert!(has(&h, w), "{w}"); + } + // the preview is the composed slide, rendered off the UI thread + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + // a checkbox goes through slideshow.layout, and its controls appear (scrolled into view) + reveal(&mut h, "check:settings.slideshowShadow"); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowShadow"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_layout.cast_shadow); + assert!(has(&h, "slider:slideshowShadowAngle")); + // a changed layout renders the preview again (once it has settled) + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.layout", json!({"margins": 0.2, "backdropColor": "#00ff00"})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.step(); + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + let c = painted(&img, (rect.left() + 3.0) as usize, (rect.center().y) as usize); + assert!(c[1] > 200 && c[0] < 60, "the green backdrop in the preview margin: {c:?}"); + h.settle(SETTLE); +} + +#[test] +fn overlays_show_during_playback() { + let mut h = grid(); + assert!(h.app.session.slides_plain()); + // the default layout with an identity plate: playback composes the slide to draw it + run( + &mut h, + "slideshow.overlays", + json!({"identityPlate": {"enabled": true, "text": "LC", "size": 0.12, "color": "#00ff00", "opacity": 1, "anchor": "topLeft", "offsetX": 0.02, "offsetY": 0.02}}), + ); + assert!(!h.app.session.slides_plain()); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0})); + let current = photo_up(&h); + assert!(h.step_until(SETTLE, |h| has_slide(h, current)), "the slide is composed and ready"); + h.step(); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(current.into())).unwrap().size, [1000, 700]); + let img = h.paint(); + // the plate's box: 14 px in from the top-left corner, 84 px high + let green = (14..98).flat_map(|y| (14..120).map(move |x| (x, y))).filter(|&(x, y)| { + let c = painted(&img, x, y); + c[1] > 200 && c[0] < 80 && c[2] < 80 + }); + assert!(green.count() > 200, "the identity plate is on the slide"); + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_overlays_and_the_preview_follows_them() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + // scroll down to the Overlays section + reveal(&mut h, "button:slideshowAddText"); + for w in ["check:settings.slideshowPlate", "check:settings.slideshowStars", "button:slideshowAddText", "check:settings.slideshowWatermark"] { + assert!(has(&h, w), "{w}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowAddText"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts.len(), 1); + h.step(); + assert!(has(&h, "field:slideshowText0"), "the text overlay's template field"); + // its rows are below the fold: scroll on down + reveal(&mut h, "button:slideshowText0Anchor-0"); + assert!(has(&h, "button:slideshowText0Anchor-4"), "its anchor grid"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Anchor-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_overlays.texts[0].place.anchor, lightcraft_engine::export::Anchor::TopLeft, "picked in the grid"); + // removed from its row the text overlay from its row + reveal(&mut h, "button:slideshowText0Remove"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowText0Remove"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.texts.is_empty()); + h.step(); + reveal(&mut h, "check:settings.slideshowPlate"); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowPlate"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.identity_plate.enabled); + // the preview is rendered again with the overlays + let before = h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key); + run(&mut h, "slideshow.overlays", json!({"identityPlate": {"text": "Studio", "color": "#ff00ff"}})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).map(|t| t.key) != before)); + h.settle(SETTLE); +} + +/// Shows the playback controls (the pointer moves over the canvas). +fn show_controls(h: &mut Headless) { + for fx in [0.3, 0.6] { + let r = h.request("ui.hoverWidget", json!({"id": "slideshow:canvas", "fx": fx, "fy": 0.3}), T); + assert_eq!(r["ok"], true, "{r}"); + } + h.step(); +} + +#[test] +fn the_intro_plays_before_the_first_photo_and_the_ending_closes_the_show() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + let n = photos.len(); + run( + &mut h, + "slideshow.titles", + json!({ + "intro": {"enabled": true, "color": "#ff00ff", "content": "text", "text": "Hello", "overrideColor": true, "textColor": "#00ff00", "scale": 0.15, "duration": 0.5}, + "ending": {"enabled": true, "color": "#00ffff", "content": "none"} + }), + ); + run(&mut h, "view.slideshow", json!({"interval": 60, "fadeDuration": 0, "repeat": false})); + assert_eq!(play(&h).current(), Item::INTRO, "the intro comes first"); + assert_eq!(h.app.session.active(), Some(photos[0]), "the selection is the first photo"); + let st = run(&mut h, "slideshow.togglePause", json!({})); + assert_eq!( + (st["title"].as_str(), st["current"].as_u64(), st["slide"].as_u64(), st["of"].as_u64()), + (Some("intro"), None, Some(0), Some(n as u64)), + "{st}" + ); + run(&mut h, "slideshow.togglePause", json!({})); + // the intro screen is composed off the UI thread at the screen's size, then shown + assert!(h.step_until(SETTLE, |h| has_slide(h, Item::INTRO)), "the intro is composed"); + assert_eq!(h.app.renderer.textures.get(&Slot::Slide(Item::INTRO)).unwrap().size, [1000, 700]); + h.step(); + let img = h.paint(); + assert_eq!(painted(&img, 5, 5), [255, 0, 255], "the intro's colour"); + let green = (280..420).flat_map(|y| (300..700).map(move |x| (x, y))).filter(|&(x, y)| { + let c = painted(&img, x, y); + c[1] > 200 && c[0] < 80 && c[2] < 80 + }); + assert!(green.count() > 300, "its text, centred"); + show_controls(&mut h); + let label = h.painted_text(); + assert!(label.iter().any(|t| t == "Intro"), "the controls say Intro, not a photo number: {label:?}"); + assert!(!label.iter().any(|t| t.contains(&format!("0 / {n}")))); + // after its own 0.5 s, the first photo (plain layout: the photo on black) + assert!(h.step_until(SETTLE, |h| play(h).current_photo() == Some(photos[0]) && has_slide(h, photos[0])), "the first photo follows"); + h.step(); + let img = h.paint(); + for (x, y) in [(5, 5), (500, 350)] { + assert_ne!(painted(&img, x, y), [255, 0, 255], "the intro is gone at ({x}, {y})"); + } + show_controls(&mut h); + assert!(h.painted_text().iter().any(|t| t == &format!("1 / {n}")), "titles aren't counted: photo 1 of {n}"); + // to the last photo, then the ending + for _ in 1..n { + run(&mut h, "slideshow.next", json!({})); + } + let st = run(&mut h, "slideshow.next", json!({})); + assert_eq!((st["title"].as_str(), st["slide"].as_u64()), (Some("ending"), Some(n as u64)), "{st}"); + assert_eq!(h.app.session.active(), Some(photos[n - 1]), "the selection stays on the last photo"); + assert!(h.step_until(SETTLE, |h| has_slide(h, Item::ENDING))); + h.step(); + assert_eq!(painted(&h.paint(), 500, 350), [0, 255, 255], "the ending's colour alone"); + show_controls(&mut h); + assert!(h.painted_text().iter().any(|t| t == "End")); + // ← goes back to the last photo, → past the ending ends the show + let st = run(&mut h, "slideshow.previous", json!({})); + assert_eq!(st["current"].as_u64(), Some(photos[n - 1].0)); + run(&mut h, "slideshow.next", json!({})); + run(&mut h, "slideshow.next", json!({})); + assert!(h.app.ui.slideshow.is_none() && !h.app.ui.fullscreen, "ended after the ending"); + h.settle(SETTLE); +} + +#[test] +fn titles_survive_deleting_the_only_photo_and_need_photos_to_start() { + let mut h = grid(); + let photos = h.app.session.visible_cloned(); + let rest: Vec<u64> = photos[1..].iter().map(|p| p.0).collect(); + run(&mut h, "photo.delete", json!({"ids": rest})); + assert_eq!(h.app.session.visible_cloned(), vec![photos[0]]); + run(&mut h, "slideshow.titles", json!({"intro": {"enabled": true, "duration": 0.5}, "ending": {"enabled": true, "duration": 0.5}})); + run(&mut h, "slideshow.overlays", json!({"identityPlate": {"image": "/no/such/plate.png"}})); + run(&mut h, "view.slideshow", json!({"interval": 0.5, "fadeDuration": 0.2, "repeat": false})); + assert_eq!(play(&h).current(), Item::INTRO); + // the only photo goes away while the intro is up + assert_eq!(h.app.session.active(), Some(photos[0])); + run(&mut h, "photo.deletePermanently", json!({"ids": [photos[0].0]})); + assert!(h.app.session.catalog.photo(photos[0]).is_none()); + let mut seen = Vec::new(); + let t0 = std::time::Instant::now(); + while let Some(r) = h.app.ui.slideshow.as_ref() { + if !seen.contains(&r.play.current()) { + seen.push(r.play.current()); + } + assert!(t0.elapsed() < SETTLE, "the show plays on to its end: {seen:?}"); + h.step(); + } + assert_eq!(seen, vec![Item::INTRO, Item::Photo(photos[0]), Item::ENDING], "intro, the (gone) photo, ending"); + // no photos in view: nothing to show, titles or not (as before) + let r = h.request("engine.execute", json!({"command": "view.slideshow", "params": {}}), T); + assert_ne!(r["ok"], true, "{r}"); + assert!(r.to_string().contains("no photos to show"), "{r}"); + assert!(h.app.ui.slideshow.is_none()); + h.settle(SETTLE); +} + +#[test] +fn settings_shows_the_titles_and_the_preview_shows_a_title_screen() { + let mut h = grid(); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + for w in ["button:settingsSlideshowPreviewShows-0", "button:settingsSlideshowPreviewShows-1", "button:settingsSlideshowPreviewShows-2"] { + assert!(has(&h, w), "{w}"); + } + // scroll down to the Titles section, at the end + reveal(&mut h, "check:settings.slideshowIntro"); + assert!(has(&h, "check:settings.slideshowEnding")); + let r = h.request("ui.clickWidget", json!({"id": "check:settings.slideshowIntro"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_titles.intro.enabled, "the checkbox goes through slideshow.titles"); + h.step(); + for w in [ + "button:slideshowIntroColor", + "button:settingsSlideshowIntroContent-1", + "slider:slideshowIntroScale", + "check:settings.slideshowIntroOwnDuration", + ] { + assert!(has(&h, w), "{w}"); + } + // the preview switched to the intro screen + assert_eq!(crate::panels::slide_titles::preview_shows(&h.view.ctx), Some(lightcraft_engine::slideshow::Title::Intro)); + run(&mut h, "slideshow.titles", json!({"intro": {"color": "#ff8000", "content": "none"}})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == PhotoId(0)))); + assert!(h.step_until(SETTLE, |h| { + let rect = h.app.widgets.iter().find(|(id, _)| id == "slideshow:preview").map(|(_, r)| *r).unwrap(); + let img = h.paint(); + painted(&img, rect.center().x as usize, rect.center().y as usize) == [255, 128, 0] + })); + // Text: its field shows + reveal(&mut h, "button:settingsSlideshowIntroContent-1"); + let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowIntroContent-1"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_titles.intro.content, lightcraft_engine::slide_title::TitleContent::Text); + h.step(); + assert!(has(&h, "field:slideshowIntroText")); + // back to the photo's slide + let r = h.request("ui.clickWidget", json!({"id": "button:settingsSlideshowPreviewShows-0"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + h.settle(SETTLE); +} + +/// Settings ▸ Slideshow ▸ Templates: built-ins listed, hover previews on the selected photo +/// without changing anything, a click applies, and the user's own templates are saved, updated, +/// renamed and deleted from the browser. +#[test] +fn template_browser_previews_on_hover_applies_and_manages_user_templates() { + use lightcraft_engine::slide_template::SlideLook; + let mut h = grid(); + let dir = std::env::temp_dir().join(format!("lc-ui-sstpl-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + h.app.session.slide_templates_dir = Some(dir.clone()); + let first = h.app.session.visible_cloned()[0]; + run(&mut h, "library.select", json!({"ids": [first.0]})); + run(&mut h, "view.slideshowOptions", json!({})); + assert!(h.step_until(SETTLE, |h| h.app.renderer.textures.get(&Slot::SlidePreview).is_some_and(|t| t.photo == first))); + let has = |h: &Headless, w: &str| h.app.widgets.iter().any(|(id, _)| id == w); + let rect = |h: &Headless, w: &str| h.app.widgets.iter().find(|(id, _)| id == w).map(|(_, r)| *r).unwrap(); + for w in [ + "template:builtin:plain-black", + "template:builtin:gallery-wall", + "template:builtin:edge-to-edge", + "template:builtin:cinema-wide", + "template:builtin:shooting-data", + "field:slideshowTemplateName", + "button:slideshowTemplateSave", + ] { + assert!(has(&h, w), "{w}"); + } + // hover: the preview shows Gallery Wall (its light grey wall in the margin), nothing changes + let before = SlideLook::of(&h.app.session); + let r = h.request("ui.hoverWidget", json!({"id": "template:builtin:gallery-wall"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(crate::panels::slide_templates::hovered(&h.view.ctx).as_deref(), Some("builtin:gallery-wall")); + let grey = |h: &Headless| { + let r = rect(h, "slideshow:preview"); + let img = h.paint(); + let c = painted(&img, (r.left() + 3.0) as usize, r.center().y as usize); + c.iter().all(|v| (200..=230).contains(v)) + }; + assert!(h.step_until(SETTLE, grey), "the hovered template on the selected photo"); + assert_eq!(SlideLook::of(&h.app.session), before, "hovering changes nothing"); + assert!(h.app.session.slide_templates_previous.is_none()); + // a click applies it + let r = h.request("ui.clickWidget", json!({"id": "template:builtin:gallery-wall"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(h.app.session.slide_overlays.rating_stars.enabled && h.app.session.slide_layout.stroke_border); + assert!(has(&h, "template:previous"), "Previous settings appear"); + // save as a template of our own + let r = h.request("ui.clickWidget", json!({"id": "field:slideshowTemplateName"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.text", json!({"text": "Holiday / 2026"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateSave"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(dir.join("Holiday _ 2026.json").exists(), "the separator never makes a folder"); + h.step(); + assert!(has(&h, "template:user:Holiday _ 2026")); + // update it from its menu with changed settings + run(&mut h, "slideshow.layout", json!({"backdropColor": "#123456"})); + let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + for w in ["button:slideshowTemplateUpdate", "button:slideshowTemplateRename", "button:slideshowTemplateExport", "button:slideshowTemplateDelete"] + { + assert!(has(&h, w), "{w}"); + } + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateUpdate"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(h.app.session.slide_template("user:Holiday _ 2026").unwrap().look.layout.backdrop_color, [0x12, 0x34, 0x56]); + // rename it in place + let r = h.request("ui.clickWidget", json!({"id": "template:user:Holiday _ 2026", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateRename"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(has(&h, "field:slideshowTemplateRename")); + let r = h.request("ui.key", json!({"key": "a", "cmd": true}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.text", json!({"text": "Summer"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.key", json!({"key": "enter"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(has(&h, "template:user:Summer"), "{:?}", h.app.session.slide_templates().templates.iter().map(|t| t.id.clone()).collect::<Vec<_>>()); + // delete it (after confirming) + let r = h.request("ui.clickWidget", json!({"id": "template:user:Summer", "button": "right"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateDelete"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); + assert!(dir.join("Summer.json").exists(), "not before it is confirmed"); + let r = h.request("ui.clickWidget", json!({"id": "button:slideshowTemplateDeleteConfirm"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert!(!dir.join("Summer.json").exists()); + // back to the settings before Gallery Wall + let r = h.request("ui.clickWidget", json!({"id": "template:previous"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + assert_eq!(SlideLook::of(&h.app.session).overlays, before.overlays); + h.settle(SETTLE); + let _ = std::fs::remove_dir_all(dir); +} diff --git a/crates/ui-egui/src/tests_solo_sections.rs b/crates/ui-egui/src/tests_solo_sections.rs new file mode 100644 index 000000000..70d1a6aa6 --- /dev/null +++ b/crates/ui-egui/src/tests_solo_sections.rs @@ -0,0 +1,99 @@ +//! Headless tests of Solo Mode: opening one Edit section closes the others (Lightroom Classic's +//! panel Solo Mode), from the Window menu command, the section header's menu and ⌥-click. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +/// Detail view with the Edit panel open and Light (the default) expanded. +fn edit_open() -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 1400.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.app.ui.right = crate::state::RightPanel::Edit; + h.app.ui.open_sections = vec!["light".into()]; + h.step(); + h.step(); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +/// A real click through the control channel: `{id, button?, alt?}` as `ui.clickWidget` takes them. +fn click(h: &mut Headless, params: serde_json::Value) { + let r = h.request("ui.clickWidget", params.clone(), T); + assert_eq!(r["ok"], true, "click {params}: {r}"); + h.step(); +} + +#[test] +fn solo_mode_closes_the_other_sections_when_one_opens() { + let mut h = edit_open(); + click(&mut h, json!({"id": "section:color"})); + assert_eq!(h.app.ui.open_sections, ["light", "color"], "off: sections open side by side"); + + assert_eq!(exec(&mut h, "view.soloSections", json!({})), json!({"soloSections": true})); + assert_eq!(h.app.ui.open_sections, ["color"], "turning it on keeps the section opened last"); + assert_eq!(crate::menubar::checked(&h.app, "view.soloSections"), Some(true)); + h.step(); + + click(&mut h, json!({"id": "section:detail"})); + assert_eq!(h.app.ui.open_sections, ["detail"]); + // the Window > Edit Sections shortcuts follow it too + exec(&mut h, "section.optics", json!({})); + assert_eq!(h.app.ui.open_sections, ["optics"]); + // closing the open one leaves none open + exec(&mut h, "section.optics", json!({})); + assert!(h.app.ui.open_sections.is_empty()); + + assert_eq!(exec(&mut h, "view.soloSections", json!({"on": false})), json!({"soloSections": false})); + exec(&mut h, "section.light", json!({})); + exec(&mut h, "section.color", json!({})); + assert_eq!(h.app.ui.open_sections, ["light", "color"]); +} + +#[test] +fn the_section_header_menu_turns_solo_mode_on() { + let mut h = edit_open(); + click(&mut h, json!({"id": "section:color"})); + click(&mut h, json!({"id": "section:effects", "button": "right"})); + click(&mut h, json!({"id": "sectionMenu:solo"})); + assert!(h.app.ui.single_panel, "the header's Solo Mode item turns it on"); + assert_eq!(h.app.ui.open_sections, ["color"]); +} + +#[test] +fn option_click_opens_just_that_section() { + let mut h = edit_open(); + click(&mut h, json!({"id": "section:color"})); + click(&mut h, json!({"id": "section:detail", "alt": true})); + assert_eq!(h.app.ui.open_sections, ["detail"], "⌥-click closes the others"); + assert!(!h.app.ui.single_panel, "⌥-click doesn't turn Solo Mode on"); + // agents do the same with the section's command (Calibration has one, though not in the menu) + exec(&mut h, "section.calibration", json!({"solo": true})); + assert_eq!(h.app.ui.open_sections, ["calibration"]); +} + +#[test] +fn bad_flags_and_unknown_sections_are_refused() { + let mut h = edit_open(); + for (command, params) in + [("view.soloSections", json!({"on": "yes"})), ("section.color", json!({"solo": 1})), ("section.typo", json!({"solo": true}))] + { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], false, "{command} {params}: {r}"); + } + assert!(!h.app.ui.single_panel); + assert_eq!(h.app.ui.open_sections, ["light"]); +} diff --git a/crates/ui-egui/src/tests_switch_library.rs b/crates/ui-egui/src/tests_switch_library.rs index 8c5208e3d..d1ad85c96 100644 --- a/crates/ui-egui/src/tests_switch_library.rs +++ b/crates/ui-egui/src/tests_switch_library.rs @@ -55,6 +55,26 @@ fn app_in(dir: &std::path::Path) -> LightcraftApp { LightcraftApp::new(session, Services::default()) } +#[test] +fn switching_libraries_dismisses_the_previous_lightroom_result() { + let dir = temp("switch-lightroom-result"); + let mut app = app_in(&dir); + let result = || crate::state::Dialog::LightroomResult { + summary: "Imported into first".into(), + library: Some(dir.join("first").to_string_lossy().into_owned()), + details: Vec::new(), + first: Some(1), + }; + app.ui.dialog = Some(result()); + app.lightroom_result = Some(result()); + app.lightroom_last = Some(json!({"imported": 1})); + app.run("app.newLibrary", json!({"path": dir.to_string_lossy(), "name": "Second"})).unwrap(); + assert!(app.ui.dialog.is_none(), "the old destination and photo ID must not act on the new library"); + assert!(app.lightroom_result.is_none()); + assert!(app.lightroom_last.is_none()); + let _ = std::fs::remove_dir_all(dir); +} + fn recent_submenu(app: &LightcraftApp) -> Vec<crate::menubar::MenuNode> { use crate::menubar::MenuNode; let bar = crate::menubar::menu_bar(app); diff --git a/crates/ui-egui/src/tests_tallies.rs b/crates/ui-egui/src/tests_tallies.rs new file mode 100644 index 000000000..ffc7408c5 --- /dev/null +++ b/crates/ui-egui/src/tests_tallies.rs @@ -0,0 +1,149 @@ +//! The side panels' tallies of the library (folders, keywords, dates, people, counts) are kept through an edit that +//! cannot have changed them, and are never kept through one that did. + +use std::sync::Arc; + +use serde_json::json; + +use crate::{LightcraftApp, Services}; + +/// A library of photos that are files in folders, taken on different days (the demo's photos are in no folder), +/// some of them keyworded. +fn app() -> LightcraftApp { + use lightcraft_catalog::{Op, Photo, Source}; + let mut s = lightcraft_engine::Session::new(); + let mut ids = Vec::new(); + for i in 0..60usize { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{i:03}.jpg"); + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i % 4) }, &name, "JPEG", 60, 40, "2026-01-01T10:00:00"); + p.captured = Some(format!("2025-{:02}-{:02}T10:00:00", 1 + i % 12, 1 + i % 28)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + ids.push(id.0); + } + s.execute("library.select", &json!({"ids": ids.iter().step_by(2).collect::<Vec<_>>()})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap(); + LightcraftApp::new(s, Services { png: None, ..Default::default() }) +} + +/// The tallies as the panels would show them now. +fn shown(a: &mut LightcraftApp) -> String { + let cat = &a.session.catalog; + format!( + "{:?}\n{:?}\n{:?}\n{:?}\n{:?}", + a.caches.folder_tree(cat), + a.caches.keyword_tree(cat), + a.caches.date_groups(cat), + a.caches.person_names(cat), + a.caches.counts(cat) + ) +} + +/// The same tallies made from every photo. +fn fresh(a: &LightcraftApp) -> String { + let cat = &a.session.catalog; + format!( + "{:?}\n{:?}\n{:?}\n{:?}\n{:?}", + cat.folder_tree(), + cat.keyword_tree(), + cat.date_groups(), + cat.people().into_iter().map(|p| p.name).collect::<Vec<_>>(), + crate::Caches::default().counts(cat) + ) +} + +#[test] +fn a_tally_kept_through_an_edit_is_the_tally_made_from_every_photo() { + let mut a = app(); + let ids: Vec<u64> = a.session.visible_cloned().iter().map(|p| p.0).collect(); + let mut dice = 5u64; + let mut roll = |sides: usize| { + dice = lightcraft_catalog::mix64(dice); + (dice % sides as u64) as usize + }; + assert_eq!(shown(&mut a), fresh(&a)); + assert!(!a.caches.folder_tree(&a.session.catalog).is_empty() && !a.caches.date_groups(&a.session.catalog).is_empty()); + for step in 0..600 { + let id = ids[roll(ids.len())]; + // (a photo in Recently Deleted can't be chosen: the edit is then not made, which is an edit too) + let _ = a.session.execute("library.select", &json!({"ids": [id]})); + let keyword = ["travel", "weddings", "pets|dogs"][roll(3)]; + let flag = ["pick", "reject", "none"][roll(3)]; + // where a file is, when a photo was taken, a photo that comes or goes for good + { + use lightcraft_catalog::{Op, Photo, PhotoId, Source}; + let n = roll(1000); + let moved = match roll(8) { + 0 => { + let name = format!("MOVED_{n:03}.jpg"); + let source = Source::File { path: format!("/elsewhere/{}/{name}", n % 3) }; + Some(a.session.commit("Locate", Op::Relink { id: PhotoId(id), file_name: name, source, format: None })) + } + 1 => Some( + a.session + .commit("Capture time", Op::SetCaptured { id: PhotoId(id), captured: Some(format!("2024-{:02}-03T10:00:00", 1 + n % 12)) }), + ), + 2 if n.is_multiple_of(3) => Some(a.session.commit("Remove", Op::RemovePhoto { id: PhotoId(id) })), + 2 if n % 3 == 1 => { + let new = a.session.catalog.alloc_photo_id(); + let name = format!("NEW_{n:03}.jpg"); + let p = Photo::new(new, Source::File { path: format!("/new/{name}") }, &name, "JPEG", 60, 40, "2026-02-01T10:00:00"); + Some(a.session.commit("Add", Op::AddPhoto { photo: Box::new(p) })) + } + _ => None, + }; + if moved.is_some() { + if roll(3) > 0 { + assert_eq!(shown(&mut a), fresh(&a), "after edit {step}"); + } + continue; + } + } + let _ = match roll(11) { + 0 | 1 => a.session.execute("photo.rate", &json!({"rating": roll(6)})), + 2 => a.session.execute("photo.flag", &json!({"flag": flag})), + 3 | 4 => a.session.execute("photo.setMeta", &json!({"addKeywords": [keyword]})), + 5 => a.session.execute("photo.setMeta", &json!({"keywords": []})), + 6 => a.session.execute("photo.setMeta", &json!({"title": format!("Title {}", roll(4))})), + 7 => a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.1 * roll(9) as f64})), + 8 => a.session.execute("photo.delete", &json!({})), + 9 => a.session.execute("photo.label", &json!({"label": "red"})), + _ => a.session.undo_step().map(|_| serde_json::Value::Null), + }; + // (not every frame asks for every tally: sometimes several edits pass between two looks) + if roll(3) > 0 { + assert_eq!(shown(&mut a), fresh(&a), "after edit {step}"); + } + } +} + +#[test] +fn a_rating_or_a_slider_keeps_every_tally_and_a_keyword_keeps_the_folders_and_dates() { + let mut a = app(); + let id = a.session.visible_cloned()[0].0; + a.session.execute("library.select", &json!({"ids": [id]})).unwrap(); + let look = |a: &mut LightcraftApp| { + let cat = &a.session.catalog; + (a.caches.folder_tree(cat), a.caches.keyword_tree(cat), a.caches.date_groups(cat), a.caches.person_names(cat)) + }; + let was = look(&mut a); + // a rating, then a slider: nothing is tallied again + a.session.execute("photo.rate", &json!({"rating": 2})).unwrap(); + a.session.execute("develop.set", &json!({"control": "light.exposure", "value": 0.4})).unwrap(); + let now = look(&mut a); + assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.1, &now.1) && Arc::ptr_eq(&was.2, &now.2) && Arc::ptr_eq(&was.3, &now.3)); + // a keyword: the keyword tree is made again, the folders and the dates are not + a.session.execute("photo.setMeta", &json!({"addKeywords": ["travel"]})).unwrap(); + let now = look(&mut a); + assert!(Arc::ptr_eq(&was.0, &now.0) && Arc::ptr_eq(&was.2, &now.2), "folders and dates kept"); + assert!(!Arc::ptr_eq(&was.1, &now.1), "keywords tallied again"); + // a photo deleted: every tally is made again + a.session.execute("photo.delete", &json!({})).unwrap(); + let gone = look(&mut a); + assert!(!Arc::ptr_eq(&now.0, &gone.0) && !Arc::ptr_eq(&now.1, &gone.1) && !Arc::ptr_eq(&now.2, &gone.2)); + // an album made (more than a photo's own change): nothing is assumed + let before = look(&mut a); + a.session.execute("album.create", &json!({"name": "Trip"})).unwrap(); + let after = look(&mut a); + assert!(!Arc::ptr_eq(&before.0, &after.0)); +} diff --git a/crates/ui-egui/src/tests_titlebar.rs b/crates/ui-egui/src/tests_titlebar.rs index 40eba4b13..68c04f9ec 100644 --- a/crates/ui-egui/src/tests_titlebar.rs +++ b/crates/ui-egui/src/tests_titlebar.rs @@ -104,6 +104,20 @@ fn double_click_in_the_search_field_does_not_zoom() { assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); } +// Scenario: dragging within the search field selects text rather than moving the window (#1646) +#[test] +fn dragging_within_search_does_not_move_the_window() { + let mut h = integrated(); + let r = widget(&h, "field:search"); + let x = r.left() + r.width() * 0.35; + h.request("ui.click", json!({"x": x, "y": r.center().y}), T); + h.request("ui.text", json!({"text": "landscape"}), T); + play(&mut h); + h.request("ui.drag", json!({"x": x, "y": r.center().y, "toX": x + 60.0, "toY": r.center().y}), T); + play(&mut h); + assert!(h.window_commands.is_empty(), "{:?}", h.window_commands); +} + // Scenario: a window with its native title bar (Windows, Linux) leaves the bar alone #[test] fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { @@ -115,3 +129,21 @@ fn without_the_integrated_title_bar_the_bar_is_not_a_window_handle() { let bar = h.app.widgets.iter().find(|(w, _)| w == "region:titlebar"); assert!(bar.is_none()); } + +// Scenario: in full screen the traffic lights are gone, so the bar's buttons move into their space +#[test] +fn full_screen_moves_the_left_buttons_into_the_traffic_lights_space() { + let mut h = integrated(); + let window_right = 1200.0; + let windowed = widget(&h, "icon:sidebar").left(); + assert_eq!(windowed, 78.0, "windowed, the bar leaves room for the traffic lights"); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(h.app.window_is_fullscreen); + let right_padding = window_right - widget(&h, "icon:discord").right(); + assert_eq!(widget(&h, "icon:sidebar").left(), right_padding, "My Photos as far from the left edge as Discord is from the right"); + h.request("ui.key", json!({"key": "f", "cmd": true, "shift": true}), T); + play(&mut h); + assert!(!h.app.window_is_fullscreen); + assert_eq!(widget(&h, "icon:sidebar").left(), windowed, "leaving full screen restores the room for the traffic lights"); +} diff --git a/crates/ui-egui/src/tests_tool_overlay.rs b/crates/ui-egui/src/tests_tool_overlay.rs new file mode 100644 index 000000000..35426ac44 --- /dev/null +++ b/crates/ui-egui/src/tests_tool_overlay.rs @@ -0,0 +1,259 @@ +//! Headless tests of the Tool Overlay (issue #224): when the Remove tool's spot pins and the mask +//! pins show (Auto / Always / Selected / Never), Shift+H and `view.toolOverlay` to change it. + +use std::time::Duration; + +use serde_json::json; + +use crate::headless::Headless; +use crate::menubar::MenuNode; +use crate::state::{RightPanel, ToolOverlay}; +use crate::{LightcraftApp, Services}; + +const T: Duration = Duration::from_secs(20); + +fn detail(panel: &str) -> Headless { + let services = Services { png: None, ..Default::default() }; + let app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + let mut h = Headless::new(app, [1200.0, 800.0], 1.0); + let r = h.request("ui.set", json!({"view": "detail"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("engine.execute", json!({"command": panel}), T); + assert_eq!(r["ok"], true, "{r}"); + h +} + +fn exec(h: &mut Headless, command: &str, params: serde_json::Value) -> serde_json::Value { + let r = h.request("engine.execute", json!({"command": command, "params": params}), T); + assert_eq!(r["ok"], true, "{command}: {r}"); + r["result"].clone() +} + +/// The Remove tool with three spots (the last one selected). +fn three_spots() -> Headless { + let mut h = detail("panel.remove"); + assert_eq!(h.app.ui.right, RightPanel::Remove); + for (x, y) in [(0.25, 0.3), (0.5, 0.6), (0.75, 0.3)] { + exec(&mut h, "spot.add", json!({"mode": "heal", "points": [[x, y]], "size": 0.03})); + } + let id = h.app.session.active().expect("active photo"); + assert_eq!(h.app.session.develop_of(id).unwrap_or_default().spots.len(), 3); + assert_eq!(h.app.session.active_spot, Some(2)); + h +} + +/// Pointer over the photo (normalized image coordinates), or away from it (the top bar). +fn hover(h: &mut Headless, over: bool) { + let r = if over { + h.request("ui.pointer", json!({"events": [{"kind": "move", "x": 0.1, "y": 0.9}]}), T) + } else { + h.request("ui.move", json!({"x": 4.0, "y": 4.0}), T) + }; + assert_eq!(r["ok"], true, "{r}"); + h.step(); +} + +/// The ids of the pins drawn this frame with `prefix` (`spotPin:`, `maskPin:`), sorted. +fn pins(h: &Headless, prefix: &str) -> Vec<String> { + let mut v: Vec<String> = h.app.widgets.iter().filter(|(id, _)| id.starts_with(prefix)).map(|(id, _)| id.clone()).collect(); + v.sort(); + v.dedup(); + v +} + +fn spot_pins(h: &Headless) -> Vec<String> { + pins(h, "spotPin:") +} + +fn all_three() -> Vec<String> { + vec!["spotPin:0".to_string(), "spotPin:1".to_string(), "spotPin:2".to_string()] +} + +#[test] +fn spot_pins_follow_each_tool_overlay_mode() { + let mut h = three_spots(); + // Auto (the default): only while the pointer is over the photo + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Auto); + hover(&mut h, false); + assert_eq!(spot_pins(&h), Vec::<String>::new(), "Auto: hidden with the pointer away"); + assert!(pins(&h, "spotSource:").is_empty()); + hover(&mut h, true); + assert_eq!(spot_pins(&h), all_three(), "Auto: shown with the pointer over the photo"); + assert_eq!(pins(&h, "spotSource:"), vec!["spotSource:2".to_string()], "the selected spot's source"); + // Always: whatever the pointer does + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + hover(&mut h, false); + assert_eq!(spot_pins(&h), all_three(), "Always: shown with the pointer away"); + // Selected: the selected spot only + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + hover(&mut h, true); + assert_eq!(spot_pins(&h), vec!["spotPin:2".to_string()]); + exec(&mut h, "spot.select", json!({"index": 0})); + h.step(); + assert_eq!(spot_pins(&h), vec!["spotPin:0".to_string()]); + // Never: none, even over the photo + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + hover(&mut h, true); + assert_eq!(spot_pins(&h), Vec::<String>::new()); + assert!(pins(&h, "spotSource:").is_empty()); +} + +/// Hidden pins can't be grabbed: with Never a click on a spot paints a new spot (the tool still +/// works); with Selected, clicking a hidden spot does too, while the selected one can be dragged. +#[test] +fn hidden_spot_pins_are_not_grabbed_and_the_tool_still_works() { + let mut h = three_spots(); + let spots = |h: &Headless| h.app.session.develop_of(h.app.session.active().unwrap()).unwrap_or_default().spots.clone(); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.25, "y": 0.3}, {"kind": "up", "x": 0.25, "y": 0.3}]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(spots(&h).len(), 4, "Never: a click adds a spot"); + assert_eq!(h.app.session.active_spot, Some(3)); + exec(&mut h, "edit.undo", json!({})); + assert_eq!(spots(&h).len(), 3); + // Selected: the selected spot's pin still drags it + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + exec(&mut h, "spot.select", json!({"index": 1})); + let r = h.request( + "ui.pointer", + json!({"events": [{"kind": "down", "x": 0.5, "y": 0.6}, {"kind": "drag", "x": 0.52, "y": 0.6}, {"kind": "drag", "x": 0.55, "y": 0.6}, {"kind": "up", "x": 0.55, "y": 0.6}]}), + T, + ); + assert_eq!(r["ok"], true, "{r}"); + let moved = spots(&h)[1].points[0]; + assert!((moved.x - 0.55).abs() < 0.01 && (moved.y - 0.6).abs() < 0.01, "{moved:?}"); + assert_eq!(spots(&h).len(), 3); + // …and a click on a hidden spot adds one rather than selecting it + let r = h.request("ui.pointer", json!({"events": [{"kind": "down", "x": 0.75, "y": 0.3}, {"kind": "up", "x": 0.75, "y": 0.3}]}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(spots(&h).len(), 4); +} + +#[test] +fn shift_h_and_the_command_cycle_the_tool_overlay() { + let mut h = three_spots(); + // Shift+H steps Auto → Always → Selected → Never → Auto + for want in [ToolOverlay::Always, ToolOverlay::Selected, ToolOverlay::Never, ToolOverlay::Auto] { + let r = h.request("ui.key", json!({"key": "h", "shift": true}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, want); + assert_eq!(h.app.ui.right, RightPanel::Remove, "Shift+H leaves the panel alone"); + } + let toast = h.app.ui.toast.as_ref().map(|t| t.0.clone()); + assert_eq!(toast.as_deref(), Some("Tool Overlay: Auto")); + // the pins follow the key: Never hides them over the photo + for _ in 0..3 { + h.request("ui.key", json!({"key": "h", "shift": true}), T); + } + hover(&mut h, true); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Never); + assert!(spot_pins(&h).is_empty()); + // the command: no params steps, `mode` sets, a bad mode is refused and changes nothing + assert_eq!(exec(&mut h, "view.toolOverlay", json!({})), json!({"mode": "auto"})); + assert_eq!(exec(&mut h, "view.toolOverlay", json!({"mode": "Selected"})), json!({"mode": "selected"})); + for bad in [json!({"mode": "sometimes"}), json!({"mode": 3})] { + let r = h.request("engine.execute", json!({"command": "view.toolOverlay", "params": bad}), T); + assert_eq!(r["ok"], false, "{r}"); + } + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); + // plain H still opens Remove (from Edit), and leaves the mode alone + exec(&mut h, "panel.edit", json!({})); + h.request("ui.key", json!({"key": "h"}), T); + assert_eq!(h.app.ui.right, RightPanel::Remove); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); + assert_eq!(crate::shortcuts::shortcut_of(&h.app.ui.settings.keymap, "view.toolOverlay"), Some("Shift+H")); +} + +/// View ▸ Tool Overlay lists the modes (the current one checked) and the cycle item with its key; +/// the Remove panel's dropdown sets the mode. +#[test] +fn view_menu_and_remove_panel_choose_the_tool_overlay() { + let mut h = three_spots(); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + let bar = crate::menubar::menu_bar(&h.app); + let view = &bar.iter().find(|(t, _)| t == "View").unwrap().1; + let Some(MenuNode::Submenu { children, .. }) = view.iter().find(|n| matches!(n, MenuNode::Submenu { label, .. } if label == "Tool Overlay")) + else { + panic!("View ▸ Tool Overlay") + }; + let items: Vec<(String, serde_json::Value, Option<bool>, Option<String>)> = children + .iter() + .filter_map(|n| match n { + MenuNode::Item { id, params, checked, shortcut, .. } if id == "view.toolOverlay" => { + Some((id.clone(), params.clone(), *checked, shortcut.clone())) + } + _ => None, + }) + .collect(); + assert_eq!(items.len(), 5, "{items:?}"); + let checked: Vec<&serde_json::Value> = items.iter().filter(|i| i.2 == Some(true)).map(|i| &i.1).collect(); + assert_eq!(checked, vec![&json!({"mode": "never"})]); + assert_eq!(items[4].1, serde_json::Value::Null, "the last item cycles"); + assert_eq!(items[4].3.as_deref(), Some("Shift+H")); + // the menu items run the command like the shortcut does + let r = h.request("ui.menu.invoke", json!({"id": "view.toolOverlay", "params": {"mode": "always"}}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Always); + // the Remove panel's dropdown + let r = h.request("ui.clickWidget", json!({"id": "dropdown:toolOverlay"}), T); + assert_eq!(r["ok"], true, "{r}"); + let r = h.request("ui.clickWidget", json!({"id": "toolOverlay:selected"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Selected); +} + +/// Mask pins follow the same mode; Show Pins (`view.maskPins`) off still hides them all. +#[test] +fn mask_pins_follow_the_tool_overlay_and_show_pins() { + let mut h = detail("panel.masking"); + exec(&mut h, "mask.add", json!({"kind": "radial", "center": [0.3, 0.4], "rx": 0.1, "ry": 0.1})); + exec(&mut h, "mask.add", json!({"kind": "linear", "start": [0.7, 0.2], "end": [0.7, 0.6]})); + assert_eq!(h.app.session.active_mask, Some(2)); + let both = vec!["maskPin:1:0".to_string(), "maskPin:2:0".to_string()]; + hover(&mut h, false); + assert!(pins(&h, "maskPin:").is_empty(), "Auto: hidden with the pointer away"); + hover(&mut h, true); + assert_eq!(pins(&h, "maskPin:"), both); + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + hover(&mut h, false); + assert_eq!(pins(&h, "maskPin:"), both); + exec(&mut h, "view.toolOverlay", json!({"mode": "selected"})); + h.step(); + assert_eq!(pins(&h, "maskPin:"), vec!["maskPin:2:0".to_string()]); + exec(&mut h, "view.toolOverlay", json!({"mode": "never"})); + hover(&mut h, true); + assert!(pins(&h, "maskPin:").is_empty()); + // Show Pins off hides them in every mode; view.maskPins answers as before + exec(&mut h, "view.toolOverlay", json!({"mode": "always"})); + assert_eq!(exec(&mut h, "view.maskPins", json!({"show": false})), json!({"maskPins": false})); + h.step(); + assert!(pins(&h, "maskPin:").is_empty()); + assert_eq!(exec(&mut h, "view.maskPins", json!({})), json!({"maskPins": true})); + h.step(); + assert_eq!(pins(&h, "maskPin:"), both); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Always, "Show Pins leaves the mode alone"); +} + +/// The mode is saved with the UI state (`ui.json`); settings from before it open in Auto. +#[test] +fn tool_overlay_is_saved_and_old_settings_open_in_auto() { + let ui = crate::UiState { tool_overlay: ToolOverlay::Selected, ..Default::default() }; + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["toolOverlay"], "selected"); + let back: crate::UiState = serde_json::from_value(saved.clone()).unwrap(); + assert_eq!(back.sanitized().tool_overlay, ToolOverlay::Selected); + let mut old = saved; + old.as_object_mut().unwrap().remove("toolOverlay"); + let back: crate::UiState = serde_json::from_value(old).unwrap(); + assert_eq!(back.tool_overlay, ToolOverlay::Auto); + // through the control channel too + let mut h = detail("panel.remove"); + let r = h.request("ui.set", json!({"toolOverlay": "never"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.tool_overlay, ToolOverlay::Never); + for m in ToolOverlay::ALL { + assert_eq!(ToolOverlay::parse(m.name()), Some(m)); + assert_eq!(serde_json::to_value(m).unwrap(), m.name()); + } + assert_eq!(ToolOverlay::parse("nope"), None); +} diff --git a/crates/ui-egui/src/tests_view_cost.rs b/crates/ui-egui/src/tests_view_cost.rs new file mode 100644 index 000000000..064eba6fd --- /dev/null +++ b/crates/ui-egui/src/tests_view_cost.rs @@ -0,0 +1,155 @@ +//! What one edit costs in a large library: a measurement, not a test (run with `--ignored --nocapture`). + +use std::time::{Duration, Instant}; + +use lightcraft_catalog::{Op, Photo, PhotoId, Source}; +use serde_json::json; + +use crate::headless::Headless; +use crate::{LightcraftApp, Services}; + +fn ms(d: Duration) -> f64 { + d.as_secs_f64() * 1000.0 +} + +/// A library of `n` photos, about a third of them keyworded from a pool of 500 keywords, showing a smart album of +/// the photos without keywords at random. +fn library(n: usize) -> lightcraft_engine::Session { + let mut s = lightcraft_engine::Session::new(); + let mut keyworded: Vec<Vec<u64>> = vec![Vec::new(); 500]; + for i in 0..n { + let id = s.catalog.alloc_photo_id(); + let name = format!("IMG_{i:06}.jpg"); + let day = 1 + (i / 400) % 28; + let month = 1 + (i / 11_200) % 12; + let mut p = Photo::new(id, Source::File { path: format!("/pics/{}/{name}", i / 1000) }, &name, "JPEG", 6000, 4000, "2026-01-01T10:00:00"); + p.captured = Some(format!("2025-{month:02}-{day:02}T10:{:02}:{:02}", (i / 60) % 60, i % 60)); + s.commit("Add", Op::AddPhoto { photo: Box::new(p) }).unwrap(); + if i % 3 == 0 { + keyworded[(i / 3) % 500].push(id.0); + } + } + for (k, ids) in keyworded.iter().enumerate() { + s.execute("library.select", &json!({"ids": ids})).unwrap(); + s.execute("photo.setMeta", &json!({"addKeywords": [format!("keyword {k:03}")]})).unwrap(); + } + let rules = json!({"ruleSet": {"match": "all", "rules": [{"field": "keywordCount", "op": "is", "value": 0}]}}); + let album = s.execute("album.createSmart", &json!({"name": "Unkeyworded", "rules": rules})).unwrap()["id"].as_u64().unwrap(); + s.execute("library.source", &json!({"kind": "album", "id": album})).unwrap(); + s.execute("library.sort", &json!({"key": "random"})).unwrap(); + s +} + +#[test] +#[ignore = "a measurement: run with --ignored --nocapture"] +fn what_one_keyword_costs_in_a_library_of_67_000_photos() { + let t = Instant::now(); + let mut s = library(67_000); + eprintln!("built the library in {:.0} ms", ms(t.elapsed())); + // the engine alone + let t = Instant::now(); + let shown = s.visible().len(); + eprintln!("engine: first view of {shown} photos: {:.1} ms", ms(t.elapsed())); + for round in 0..3 { + let id: PhotoId = s.visible()[100 + round]; + s.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + let t = Instant::now(); + s.execute("photo.setMeta", &json!({"addKeywords": ["keyword 001"]})).unwrap(); + let command = t.elapsed(); + let t = Instant::now(); + let n = s.visible().len(); + let view = t.elapsed(); + let t = Instant::now(); + let _ = s.source_total(); + let total = t.elapsed(); + eprintln!("engine: keyword {round}: command {:.1} ms, view of {n} again {:.1} ms, source total {:.1} ms", ms(command), ms(view), ms(total)); + } + // its parts + let ids = s.visible_cloned(); + let t = Instant::now(); + let f = s.source.to_filter(&s.filter, &s.catalog); + let q = s.catalog.query(&f, &s.sort); + let query = t.elapsed(); + let t = Instant::now(); + let _ = s.catalog.arrange_stacks(&q); + let stacks = t.elapsed(); + let t = Instant::now(); + let _ = s.catalog.stack_index(); + let index = t.elapsed(); + eprintln!( + "engine parts: query and sort {:.1} ms, arrange stacks {:.1} ms, stack index {:.1} ms ({} ids)", + ms(query), + ms(stacks), + ms(index), + ids.len() + ); + // what the side panels tally from every photo after each change + let time = |what: &str, f: &dyn Fn()| { + let t = Instant::now(); + f(); + eprintln!("tally: {what} {:.1} ms", ms(t.elapsed())); + }; + time("folder tree", &|| drop(s.catalog.folder_tree())); + time("keyword tree", &|| drop(s.catalog.keyword_tree())); + time("date groups", &|| drop(s.catalog.date_groups())); + time("people", &|| drop(s.catalog.people())); + time("count of a smart album", &|| { + let _ = s.catalog.count(&f); + }); + time("count of all photos", &|| { + let _ = s.catalog.count(&lightcraft_catalog::Filter::default()); + }); + // the app: a frame after an edit against a frame with nothing changed + let app = LightcraftApp::new(s, Services { png: None, ..Default::default() }); + let mut h = Headless::new(app, [1600.0, 1000.0], 1.0); + h.app.ui.left_panel = true; + h.request("engine.execute", json!({"command": "view.squareGrid", "params": {}}), Duration::from_secs(60)); + h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60)); + h.settle(Duration::from_secs(120)); + let frame = |h: &mut Headless| { + let t = Instant::now(); + h.step(); + t.elapsed() + }; + let idle: Vec<f64> = (0..5).map(|_| ms(frame(&mut h))).collect(); + eprintln!("app: frames with nothing changed: {idle:.1?} ms"); + for round in 0..3 { + let id: PhotoId = h.app.session.visible()[300 + round]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + let t = Instant::now(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 002"]})).unwrap(); + let command = t.elapsed(); + let first = frame(&mut h); + let second = frame(&mut h); + eprintln!("app: keyword {round}: command {:.1} ms, the frame after it {:.1} ms, the next {:.1} ms", ms(command), ms(first), ms(second)); + } + // which part of the window the frame after an edit pays for + for (what, left, right) in [("grid alone", false, false), ("grid and left panel", true, false), ("grid and Keywords panel", false, true)] { + h.app.ui.left_panel = left; + h.request("engine.execute", json!({"command": if right { "panel.keywords" } else { "panel.close" }, "params": {}}), Duration::from_secs(60)); + if right && h.app.ui.right != crate::state::RightPanel::Keywords { + h.request("engine.execute", json!({"command": "panel.keywords", "params": {}}), Duration::from_secs(60)); + } + h.settle(Duration::from_secs(120)); + let id: PhotoId = h.app.session.visible()[500]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 004"]})).unwrap(); + let first = frame(&mut h); + eprintln!("app: {what} (right panel {:?}): the frame after a keyword {:.1} ms", h.app.ui.right, ms(first)); + } + h.app.ui.left_panel = true; + let view = "view.photoGrid"; + h.request("engine.execute", json!({"command": view, "params": {}}), Duration::from_secs(60)); + h.settle(Duration::from_secs(120)); + let id: PhotoId = h.app.session.visible()[400]; + h.app.session.execute("library.select", &json!({"ids": [id.0]})).unwrap(); + h.step(); + h.step(); + h.app.run("photo.setMeta", json!({"addKeywords": ["keyword 003"]})).unwrap(); + let first = frame(&mut h); + eprintln!("app: {view}: the frame after a keyword {:.1} ms", ms(first)); +} diff --git a/crates/ui-egui/src/tests_zoom_keys.rs b/crates/ui-egui/src/tests_zoom_keys.rs index 3c1111217..e814230e2 100644 --- a/crates/ui-egui/src/tests_zoom_keys.rs +++ b/crates/ui-egui/src/tests_zoom_keys.rs @@ -1,7 +1,8 @@ //! Headless tests of the View ▸ Zoom shortcuts and modifier-wheel zoom: Cmd+= / Cmd+- / Cmd+0 //! (Ctrl on Windows and Linux) zoom the photo and never egui's interface scale, which would //! otherwise take the same keys at the end of the frame; Ctrl+wheel over the photo in the Edit -//! view zooms it. +//! view zooms it. Also the grid thumbnail-size keys `=` / `-` (Classic's Library Grid keys), which +//! share the physical keys with Cmd+= / Cmd+- but act on the grids only. use std::time::Duration; @@ -85,3 +86,139 @@ fn ctrl_wheel_over_the_photo_zooms_it_in_the_edit_view() { } assert!((h.app.image_rect.unwrap().width() - zoomed.width()).abs() < 0.01, "a plain wheel changed the zoom"); } + +fn grid_view(view: crate::state::ViewMode) -> Headless { + let services = Services { png: None, ..Default::default() }; + let mut app = LightcraftApp::new(lightcraft_engine::Session::with_demo(), services); + app.ui.settings.gpu = false; + app.ui.view = view; + let mut h = Headless::new(app, [1400.0, 900.0], 1.0); + h.step(); + h.step(); + h +} + +fn press(h: &mut Headless, k: &str, shift: bool, cmd: bool) { + let r = h.request("ui.key", json!({"key": k, "shift": shift, "cmd": cmd}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + h.step(); +} + +#[test] +fn equals_and_minus_step_the_grid_thumbnail_size_and_clamp() { + use crate::state::{THUMB_SIZE_MAX, THUMB_SIZE_MIN, THUMB_SIZE_STEP, ViewMode}; + for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid, ViewMode::People] { + let mut h = grid_view(view); + h.app.ui.thumb_size = 220.0; + press(&mut h, "=", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 + THUMB_SIZE_STEP, "{view:?}: ="); + press(&mut h, "-", false, false); + press(&mut h, "-", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 - THUMB_SIZE_STEP, "{view:?}: - -"); + // `+` (Shift+= on a US layout, or the keypad) is the same key for the same job + press(&mut h, "Plus", true, false); + press(&mut h, "Plus", false, false); + assert_eq!(h.app.ui.thumb_size, 220.0 + THUMB_SIZE_STEP, "{view:?}: +"); + // clamped at both ends of the bottom-bar slider's range, however many presses + h.app.ui.thumb_size = THUMB_SIZE_MAX - 5.0; + for _ in 0..3 { + press(&mut h, "=", false, false); + assert!(h.app.ui.thumb_size <= THUMB_SIZE_MAX); + } + assert_eq!(h.app.ui.thumb_size, THUMB_SIZE_MAX, "{view:?}"); + h.app.ui.thumb_size = THUMB_SIZE_MIN + 5.0; + for _ in 0..3 { + press(&mut h, "-", false, false); + assert!(h.app.ui.thumb_size >= THUMB_SIZE_MIN); + } + assert_eq!(h.app.ui.thumb_size, THUMB_SIZE_MIN, "{view:?}"); + // the whole range is walked in whole steps from either end + let mut presses = 0; + while h.app.ui.thumb_size < THUMB_SIZE_MAX { + press(&mut h, "=", false, false); + presses += 1; + assert!(presses <= 20, "never reached the maximum"); + } + assert_eq!(presses, ((THUMB_SIZE_MAX - THUMB_SIZE_MIN) / THUMB_SIZE_STEP).ceil() as usize); + } +} + +#[test] +fn thumbnail_size_keys_do_nothing_outside_the_grids() { + use crate::state::ViewMode; + for view in [ViewMode::Detail, ViewMode::Compare, ViewMode::Survey, ViewMode::Reference] { + let mut h = grid_view(view); + h.app.ui.thumb_size = 220.0; + let zoom = h.app.ui.zoom; + for (k, shift) in [("=", false), ("-", false), ("Plus", true)] { + press(&mut h, k, shift, false); + } + assert_eq!(h.app.ui.thumb_size, 220.0, "{view:?}"); + assert_eq!(h.app.ui.zoom, zoom, "{view:?}: a bare = / - is not a zoom key"); + // the command says why, and the menu shows it unavailable + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], false, "{view:?}: {r}"); + assert_eq!(h.app.ui.thumb_size, 220.0); + let list = h.request("ui.menu.list", json!({}), T); + let entry = list["result"].as_array().and_then(|a| a.iter().find(|e| e["id"] == "view.thumbSizeUp")).cloned().unwrap_or_default(); + assert_eq!(entry["enabled"], false, "{view:?}: {entry}"); + } +} + +#[test] +fn cmd_equals_and_minus_still_zoom_and_leave_the_thumbnail_size_alone() { + // in the loupe: the photo zooms, the grid size is untouched + let mut h = edit_view(); + h.app.ui.thumb_size = 220.0; + key(&mut h, "="); + assert_eq!(h.app.ui.zoom, Zoom::Percent(50.0)); + key(&mut h, "-"); + assert_eq!(h.app.ui.zoom, Zoom::Percent(25.0)); + assert_eq!(h.app.ui.thumb_size, 220.0); + // in a grid Cmd+= / Cmd+- are View ▸ Zoom In / Out, not the size keys + let mut h = grid_view(crate::state::ViewMode::PhotoGrid); + h.app.ui.thumb_size = 220.0; + key(&mut h, "="); + key(&mut h, "-"); + assert_eq!(h.app.ui.thumb_size, 220.0, "Cmd+= / Cmd+- changed the grid size"); +} + +#[test] +fn search_field_keeps_equals_and_minus() { + let mut h = grid_view(crate::state::ViewMode::PhotoGrid); + h.app.ui.thumb_size = 220.0; + let r = h.request("ui.clickWidget", json!({"id": "field:search"}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + for (k, text) in [("-", "-"), ("=", "="), ("Plus", "+")] { + press(&mut h, k, false, false); + let r = h.request("ui.text", json!({"text": text}), T); + assert_eq!(r["ok"], true, "{r}"); + h.step(); + } + assert_eq!(h.app.ui.thumb_size, 220.0, "typing in the search field resized the grid"); + assert_eq!(h.app.ui.search, "-=+", "the search field did not receive the keys"); +} + +#[test] +fn thumbnail_size_commands_run_from_the_control_channel_and_persist() { + let mut h = grid_view(crate::state::ViewMode::SquareGrid); + h.app.ui.thumb_size = 120.0; + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(r["result"]["size"], 150.0); + let r = h.request("engine.execute", json!({"command": "view.thumbSizeDown"}), T); + assert_eq!(r["ok"], true, "{r}"); + assert_eq!(h.app.ui.thumb_size, 120.0); + let r = h.request("ui.menu.invoke", json!({"id": "view.thumbSizeUp"}), T); + assert_eq!(r["ok"], true, "{r}"); + // the size is saved with the rest of the UI state, like the slider's, and restored clamped + let saved = serde_json::to_value(&h.app.ui).unwrap(); + assert_eq!(saved["thumbSize"], 150.0); + let loaded = serde_json::from_value::<crate::state::UiState>(saved).unwrap().sanitized(); + assert_eq!(loaded.thumb_size, 150.0); + let mut wild = serde_json::to_value(&h.app.ui).unwrap(); + wild["thumbSize"] = json!(9000.0); + assert_eq!(serde_json::from_value::<crate::state::UiState>(wild).unwrap().sanitized().thumb_size, crate::state::THUMB_SIZE_MAX); +} diff --git a/crates/ui-egui/src/theme.rs b/crates/ui-egui/src/theme.rs index 282f9c70e..57ee987d1 100644 --- a/crates/ui-egui/src/theme.rs +++ b/crates/ui-egui/src/theme.rs @@ -370,6 +370,10 @@ fn craft_font_name(f: &lightcraft_engine::CraftFont) -> String { /// Points one mouse-wheel notch (a line) scrolls. pub const WHEEL_LINE_POINTS: f32 = 100.0; +/// Share of its visible size one mouse-wheel notch scrolls the photo grid and the filmstrip (Lightroom +/// Classic: three notches move a photo from one edge to the other, at any window size). +pub const WHEEL_STRIP_SHARE: f32 = 1.0 / 3.0; + /// Zoom per wheel point (`exp(speed × points)`): egui's 1/200 at its stock 40-point notch, rescaled so /// one notch still zooms the same amount now that it scrolls [`WHEEL_LINE_POINTS`]. pub const WHEEL_ZOOM_PER_POINT: f32 = 40.0 / 200.0 / WHEEL_LINE_POINTS; @@ -462,14 +466,15 @@ mod tests { } } - /// #736: a Thai folder name drew boxes because only CJK craft-fonts faces were fallbacks. With a - /// craft-fonts build that carries a Thai face, an English UI offers it in every family. + /// #736: the release input must carry Thai, not silently skip the check with an older pin. + /// Exercise the Thai characters of the reported folder name through every egui family. #[test] - fn thai_face_is_a_fallback_in_every_ui_family_when_craft_fonts_carry_one() { - let Some(thai) = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")) else { - eprintln!("no Thai face in craft-fonts: nothing to check"); + fn thai_folder_name_has_glyphs_in_every_ui_family() { + if lightcraft_engine::CRAFT_FONTS.is_empty() { + lightcraft_testkit::skip!(Fonts, "built without CRAFT_FONTS_DIR: no craft-fonts to check"); return; - }; + } + let thai = lightcraft_engine::CRAFT_FONTS.iter().find(|f| f.covers("Thai")).expect("font input includes Thai"); set_language(Locale::En); let definitions = font_definitions(lightcraft_engine::CRAFT_FONTS); let name = craft_font_name(thai); @@ -480,6 +485,20 @@ mod tests { let names = definitions.families.get(&family).expect("UI font family"); assert!(names.contains(&name) || (family == semibold_family() && names.contains(&strong)), "{family:?} falls back on {name}"); } + let ctx = egui::Context::default(); + ctx.set_fonts(definitions); + let mut frame = ctx.run_ui(egui::RawInput::default(), |_| {}); + frame.textures_delta.clear(); + ctx.fonts_mut(|fonts| { + for family in [FontFamily::Proportional, FontFamily::Name(FONT_SEMIBOLD.into()), FontFamily::Monospace] { + let id = FontId::new(13.0, family); + // Check the Thai portion; egui 0.36's has_glyph reports false for characters + // in the replacement glyph's own face (including normal monospace Latin). + for ch in "Studio34 ถ่ายภาพงานแต่งงาน".chars().filter(|ch| !ch.is_ascii()) { + assert!(fonts.has_glyph(&id, ch), "{id:?} has {ch}"); + } + } + }); } #[test] diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs index a6af3dc7a..a00bca4b2 100644 --- a/crates/ui-egui/src/widgets.rs +++ b/crates/ui-egui/src/widgets.rs @@ -8,6 +8,42 @@ use lightcraft_develop::{ControlSpec, Track}; use crate::icons::{Icon, paint}; use crate::theme::Tokens; +/// Standard dropdown chevrons, including settings, import/export and rule editors. +pub fn combo_box(id: impl egui::AsIdSalt) -> egui::ComboBox { + egui::ComboBox::from_id_salt(id).icon(|ui, rect, visuals, _| { + paint(ui.painter(), Rect::from_center_size(rect.center(), vec2(16.0, 16.0)), Icon::ChevronDown, visuals.fg_stroke.color); + }) +} + +/// A native button with a vector arrow beside its text; egui reserves the icon's space. +pub fn arrow_button<'a>(ui: &mut Ui, text: impl egui::IntoAtoms<'a>, icon: Icon, leading: bool) -> Response { + let id = ui.next_auto_id().with("arrow"); + let mut atoms = text.into_atoms(); + let arrow = egui::Atom::custom(id, vec2(16.0, 16.0)); + if leading { + atoms.push_left(arrow); + } else { + atoms.push_right(arrow); + } + let r = egui::Button::new(atoms).atom_ui(ui); + if let Some(rect) = r.rect(id) { + paint(ui.painter(), rect, icon, ui.style().interact(&r.response).fg_stroke.color); + } + r.response +} + +/// Keep egui's submenu interaction and layout, using our vector chevron instead of a font glyph. +pub fn menu_button<'a, R>(ui: &mut Ui, atoms: impl egui::IntoAtoms<'a>, contents: impl FnOnce(&mut Ui) -> R) -> egui::InnerResponse<Option<R>> { + if !egui::containers::menu::is_in_menu(ui) { + return ui.menu_button(atoms, contents); + } + let button = egui::Button::new(atoms.into_atoms()).right_text(" "); + let (response, inner) = egui::containers::menu::SubMenuButton::from_button(button).ui(ui, contents); + let center = pos2(response.rect.right() - ui.spacing().button_padding.x - 8.0, response.rect.center().y); + paint(ui.painter(), Rect::from_center_size(center, vec2(16.0, 16.0)), Icon::ChevronRight, ui.style().interact(&response).fg_stroke.color); + egui::InnerResponse::new(inner.map(|r| r.inner), response) +} + // ------------------------------------------------------------------ automation registry #[derive(Clone, Default)] @@ -58,8 +94,11 @@ pub fn preview_only_explanation(reason: &str) -> String { } /// A panel notice for a raw shown from its embedded preview (Edit, Info): an amber info icon, -/// "Preview only" and the explanation. Registered as `notice:previewOnly:{key}`. -pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { +/// "Preview only", the explanation and a Reload from Disk button (`button:reloadPreviewOnly:{key}`: +/// for a file a newer version can decode that was offline when its library opened). Registered as +/// `notice:previewOnly:{key}`. `true` when the button was clicked. +pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) -> bool { + let mut reload = false; let t = Tokens::get(ui.ctx()); let r = egui::Frame::NONE .fill(t.canvas) @@ -75,11 +114,14 @@ pub fn preview_only_notice(ui: &mut Ui, key: &str, reason: &str) { }); ui.add_space(2.0); ui.label(egui::RichText::new(preview_only_explanation(reason)).size(11.5).color(t.text_label)); + ui.add_space(6.0); + reload = text_button(ui, &format!("reloadPreviewOnly:{key}"), "Reload from Disk", false).clicked(); }) .response; let r = r.on_hover_text(crate::i18n::tr_format!("Decoder: {reason}", reason = reason)); r.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Label, true, crate::i18n::tr("Preview only: editing the camera's embedded JPEG"))); register(ui.ctx(), format!("notice:previewOnly:{key}"), r.rect); + reload } // ------------------------------------------------------------------ colour helpers @@ -176,9 +218,18 @@ pub(crate) fn shown_value(spec: &ControlSpec, v: f64) -> String { if shown == "+0" || shown == "-0" { "0".to_string() } else { shown } } +/// Pointer travel (points, sideways from the press) that turns a press on a slider's value into a +/// scrub rather than a click that types a value. +const SCRUB_START: f32 = 3.0; + +/// The radius of a slider's hollow ring thumb. +const THUMB_RING: f32 = 7.0; + /// A Lightroom slider row (label + value above a track with a hollow ring thumb). -/// Double-click the label or thumb to reset. Shift-drag = fine adjustment. Click the value to -/// type one: Return or clicking away applies it, Esc keeps the old one. +/// Double-click the label or thumb to reset (hovering either says so). Shift-drag = fine adjustment. +/// Scroll sideways over it to nudge it. Click the value to type one: Return or clicking away applies it, +/// Esc keeps the old one. Drag the value sideways to scrub it, as if dragging the thumb (Shift: a tenth +/// as far). pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_override: Option<&str>) -> SliderOut { let t = Tokens::get(ui.ctx()); let w = ui.available_width(); @@ -214,17 +265,48 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ node.add_action(egui::accesskit::Action::Decrement); } }); - let label_resp = ui.interact(label_rect, id.with("label"), Sense::click()); + // the name: double-click it to reset, as the thumb (a disabled slider's name is only text) + let label_resp = ui.interact(label_rect, id.with("label"), if enabled { Sense::click() } else { Sense::hover() }); + // say how to reset (issue #1436: the double-click was hard to discover), as the curve channels do + let reset_hint = crate::i18n::tr_format!("{name} — double-click to reset it", name = label_text); + let label_resp = if enabled { label_resp.on_hover_text(reset_hint.as_str()) } else { label_resp }; + crate::widgets::announce(&label_resp, crate::i18n::tr_format!("Reset {name}", name = label_text.clone())); + register(ui.ctx(), format!("sliderLabel:{}", spec.id), label_rect); register(ui.ctx(), format!("slider:{}", spec.id), track_rect); - // the value: click it to type an exact one (over the label, so it takes the click) + // the value: click it to type an exact one, drag it sideways to scrub (over the label, so it + // takes the click) let value_rect = Rect::from_min_max(pos2(label_rect.right() - 64.0, label_rect.top()), label_rect.max); - let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click() } else { Sense::hover() }); + let value_resp = ui.interact(value_rect, id.with("value"), if enabled { Sense::click_and_drag() } else { Sense::hover() }); + crate::widgets::announce(&value_resp, format!("{label_text}: {}", crate::i18n::tr("Click to type a value, drag to change it"))); register(ui.ctx(), format!("sliderValue:{}", spec.id), value_rect); // say that an exact value can be typed here (issue #534: an exact crop angle was hard to find) - let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value")) } else { value_resp }; + let value_resp = if enabled { value_resp.on_hover_text(crate::i18n::tr("Click to type a value, drag to change it")) } else { value_resp }; let typing_id = id.with("typing"); // Keep the short focus-acquisition guard, but let an explicit Return/Escape end the edit. let mut typing: Option<(String, u8, bool)> = ui.data(|m| m.get_temp(typing_id)); + // A scrub: the unrounded track position (0..1) it has reached, so a slow drag adds up instead + // of rounding back to the same step every frame. + let scrub_id = id.with("scrub-pos"); + let mut scrub: Option<f64> = if value_resp.dragged() || value_resp.drag_stopped() { ui.data(|m| m.get_temp(scrub_id)) } else { None }; + let mut scrub_started = false; + if enabled + && scrub.is_none() + && value_resp.dragged() + && let (Some(origin), Some(p)) = ui.input(|i| (i.pointer.press_origin(), i.pointer.interact_pos())) + && (p.x - origin.x).abs() >= SCRUB_START + { + // a press on the value that moves sideways scrubs: the edit the press opened is dropped + typing = None; + ui.data_mut(|m| m.remove::<(String, u8, bool)>(typing_id)); + ui.memory_mut(|m| m.surrender_focus(crate::text_field::id(&format!("sliderField:{}", spec.id)))); + // the travel so far counts, so the value doesn't lag the pointer by the start distance + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + scrub = Some((spec.position(value) + f64::from((p.x - origin.x) / track_rect.width()) * fine).clamp(0.0, 1.0)); + scrub_started = true; + } + if scrub.is_none() { + ui.data_mut(|m| m.remove::<f64>(scrub_id)); + } let value_pressed = value_resp.is_pointer_button_down_on() && ui.input(|i| i.pointer.button_pressed(egui::PointerButton::Primary)); // Focus on the owned primary press before fast typing; retain the readout's click activation. if enabled && typing.is_none() && (value_resp.clicked() || value_pressed) { @@ -241,8 +323,35 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ // value ↔ track position: linear, except for the Kelvin temperature (`ControlSpec::position`) let to_x = |v: f64| track_rect.left() + spec.position(v) as f32 * track_rect.width(); let from_x = |x: f32| spec.value_at(f64::from((x - track_rect.left()) / track_rect.width())); + // the thumb: a click there doesn't move the value, and resting on it says how to reset (the rest + // of the track, which is clicked and dragged, gets no tooltip) + let on_thumb = |p: egui::Pos2| (p.x - to_x(value)).abs() <= THUMB_RING + 1.0; + if enabled && !resp.dragged() && resp.hover_pos().is_some_and(on_thumb) { + resp.clone().on_hover_text(reset_hint.as_str()); + } let mut v = value; - if let Some((mut text, frames, pressed)) = typing.take() { + if let Some(mut pos) = scrub { + // scrubbing the value moves it as dragging the thumb would: the pointer's sideways travel + // over the track's width (a tenth with Shift), on the control's steps + if !scrub_started && value_resp.dragged() { + let fine = if ui.input(|i| i.modifiers.shift) { 0.1 } else { 1.0 }; + let dx = ui.input(|i| i.pointer.delta().x); + pos = (pos + f64::from(dx / track_rect.width()) * fine).clamp(0.0, 1.0); + } + let step = spec.step.max(1e-9); + let nv = ((spec.value_at(pos) / step).round() * step).clamp(spec.min, spec.max); + if (nv - value).abs() > 1e-12 { + out.value = Some(nv); + v = nv; + } + out.drag_started = scrub_started; + if value_resp.drag_stopped() { + out.drag_stopped = true; + ui.data_mut(|m| m.remove::<f64>(scrub_id)); + } else { + ui.data_mut(|m| m.insert_temp(scrub_id, pos)); + } + } else if let Some((mut text, frames, pressed)) = typing.take() { // Keep the owned opening release focused without restarting its draft or selection. // egui gives that release's click to the readout, so the field under it gives up the focus // whatever frame it lands on (issue #1362): the field takes it back after drawing too. @@ -313,6 +422,9 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } else if resp.clicked() && let Some(p) = resp.interact_pointer_pos() + // a click on the thumb leaves it there, so the first click of a double-click on it isn't a + // history step of its own before the reset + && !on_thumb(p) { let step = spec.step.max(1e-9); let nv = ((from_x(p.x) / step).round() * step).clamp(spec.min, spec.max); @@ -351,6 +463,28 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ } } } + // Sideways scrolling (a trackpad swipe, a tilting wheel, Shift+wheel) adjusts the slider under + // the pointer, selected or not: the panel only scrolls vertically, so nothing else wants it. + // Only a mostly sideways movement counts, so a slightly diagonal swipe that scrolls the panel + // leaves the sliders it passes alone. Right raises the value, as dragging the thumb right does. + let side_id = id.with("side-rem"); + let (dx, dy) = ui.input(|i| (i.smooth_scroll_delta.x, i.smooth_scroll_delta.y)); + if !enabled || typing.is_some() || out.value.is_some() || !ui.rect_contains_pointer(row) || dx.abs() <= dy.abs() { + ui.data_mut(|m| m.remove::<f32>(side_id)); + } else { + // the vertical part would scroll the panel and move the slider out from under the pointer + ui.input_mut(|i| i.smooth_scroll_delta = egui::Vec2::ZERO); + let mut remainder = ui.data(|m| m.get_temp::<f32>(side_id)).unwrap_or(0.0); + let steps = wheel_nudges(&mut remainder, dx); + ui.data_mut(|m| m.insert_temp(side_id, remainder)); + let nv = nudged(spec, value, steps); + if steps != 0.0 && (nv - value).abs() > 1e-12 { + out.value = Some(nv); + out.drag_started = true; + out.drag_stopped = true; + v = nv; + } + } // Wheel nudges require an explicitly selected slider, unlike regular panel scrolling. // Consume only the vertical component so a trackpad's horizontal scroll still works. let wheel_id = id.with("wheel-rem"); @@ -387,11 +521,12 @@ pub fn slider(ui: &mut Ui, spec: &ControlSpec, value: f64, enabled: bool, label_ p.text(label_rect.left_center(), Align2::LEFT_CENTER, crate::i18n::tr(label_override.unwrap_or(spec.label)), t.font(12.5), text_c); if typing.is_none() { p.text(label_rect.right_center(), Align2::RIGHT_CENTER, shown_value(spec, v), t.font(12.5), text_c); - if enabled && value_resp.hovered() { - ui.ctx().set_cursor_icon(egui::CursorIcon::Text); + // the scrub pointer, as over Lightroom's values: a drag here changes the value + if enabled && (value_resp.hovered() || scrub.is_some()) { + ui.ctx().set_cursor_icon(egui::CursorIcon::ResizeHorizontal); } } - let ring = 7.0; + let ring = THUMB_RING; let tx = to_x(v); paint_track(ui, track_rect, &spec.track, &t, tx, ring); let p = ui.painter(); @@ -441,7 +576,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O if ui.rect_contains_pointer(r) { p.rect_filled(r, 0.0, t.chrome.gamma_multiply(1.06)); } - let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(14.0, 14.0)); + let chev = Rect::from_center_size(pos2(r.left() + 30.0, r.center().y), vec2(16.0, 16.0)); paint(p, chev, if open { Icon::ChevronDown } else { Icon::ChevronRight }, t.text_label); p.text(pos2(r.left() + 44.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(14.0), t.text); let mut toggled = None; @@ -453,6 +588,7 @@ pub fn section_header(ui: &mut Ui, id: &str, title: &str, open: bool, enabled: O { let eye = Rect::from_center_size(pos2(r.right() - 34.0, r.center().y), vec2(18.0, 18.0)); let er = ui.interact(eye, ui.id().with(("eye", id)), Sense::click()); + crate::widgets::announce_selected(&er, crate::i18n::tr("Show / hide"), on); register(ui.ctx(), format!("sectionEye:{id}"), eye); paint(ui.painter(), eye, Icon::Eye, if on { t.icon } else { t.text_disabled }); if !on { @@ -491,8 +627,8 @@ pub fn flyout_row(ui: &mut Ui, id: &str, title: &str, icon: Icon, open: bool) -> p.text(pos2(tile.right() + 10.0, r.center().y), Align2::LEFT_CENTER, title, t.semibold(13.0), t.text_label); paint( p, - Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(12.0, 12.0)), - if open { Icon::ChevronDown } else { Icon::TriangleLeft }, + Rect::from_center_size(pos2(r.right() - 14.0, r.center().y), vec2(16.0, 16.0)), + if open { Icon::ChevronDown } else { Icon::ChevronLeft }, t.text_label, ); resp @@ -537,6 +673,7 @@ pub fn segmented(ui: &mut Ui, id: &str, items: &[(&str, &str)], active: Option<u let label = crate::i18n::tr(label); let i = row_i * per_row + j; let (r, resp) = ui.allocate_exact_size(vec2(seg_w, 24.0), Sense::click()); + crate::widgets::announce_selected(&resp, label.to_string(), active == Some(i)); register(ui.ctx(), format!("button:{id}-{key}"), r); let fill = if active == Some(i) { t.pressed @@ -593,6 +730,7 @@ pub fn stars(ui: &mut Ui, id: &str, rating: u8, size: f32) -> Option<u8> { let mut rects = [Rect::NOTHING; 5]; for (i, slot) in (0..5u8).zip(rects.iter_mut()) { let (r, resp) = ui.allocate_exact_size(vec2(size, size), Sense::click()); + crate::widgets::announce_selected(&resp, format!("{} ★", i + 1), i < rating); register(ui.ctx(), format!("star:{id}:{}", i + 1), r); if resp.hovered() { hover_n = Some(i + 1); @@ -621,16 +759,23 @@ fn star_lit(i: u8, rating: u8, hover: Option<u8>) -> bool { /// A borderless dropdown button: `text` followed by a painted chevron (no font glyph needed). pub fn dropdown(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32) -> Response { + dropdown_within(ui, id, text, font, color, f32::INFINITY) +} + +/// [`dropdown`] at most `max` wide, chevron included: a longer `text` is cut short with …, and +/// shown in full on hover. +pub fn dropdown_within(ui: &mut Ui, id: &str, text: &str, font: egui::FontId, color: Color32, max: f32) -> Response { let t = Tokens::get(ui.ctx()); - let galley = ui.painter().layout_no_wrap(text.to_string(), font, color); + let galley = one_line(ui.painter(), text, font, color, max - 20.0); + let elided = galley.elided; let size = vec2(galley.size().x + 20.0, galley.size().y.max(20.0)); let (r, resp) = ui.allocate_exact_size(size, Sense::click()); resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::ComboBox, true, text)); register(ui.ctx(), format!("dropdown:{id}"), r); let c = if resp.hovered() { t.text } else { color }; ui.painter().galley(pos2(r.left(), r.center().y - galley.size().y / 2.0), galley, c); - paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 7.0, r.center().y + 1.0), vec2(12.0, 12.0)), Icon::ChevronDown, c); - resp + paint(ui.painter(), Rect::from_center_size(pos2(r.right() - 10.0, r.center().y), vec2(16.0, 16.0)), Icon::ChevronDown, c); + if elided { resp.on_hover_text(text) } else { resp } } #[cfg(test)] @@ -802,6 +947,18 @@ pub(crate) fn screen_reader_name(ctx: &egui::Context, id: egui::Id, name: &str) ctx.accesskit_node_builder(id, |node| node.set_label(name.to_string())); } +/// A hand-drawn clickable area (an icon, a tile, a row) announced as a button named `name`. +pub(crate) fn announce(resp: &egui::Response, name: impl Into<String>) { + let name = name.into(); + resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, resp.enabled(), name.clone())); +} + +/// The same for one that is on or off (a toggle, a selected row or swatch). +pub(crate) fn announce_selected(resp: &egui::Response, name: impl Into<String>, selected: bool) { + let name = name.into(); + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::Button, resp.enabled(), selected, name.clone())); +} + /// `r`, named `name` for screen readers: a plain egui field or slider whose caption is drawn /// beside it, or whose hint is all it shows. pub(crate) fn named(r: egui::Response, name: &str) -> egui::Response { @@ -848,6 +1005,10 @@ pub(crate) fn elide_head(text: &str, max: f32, width: impl Fn(&str) -> f32) -> S #[path = "widgets/tests_numeric_entry.rs"] mod numeric_entry_tests; +#[cfg(test)] +#[path = "widgets/tests_slider_reset.rs"] +mod slider_reset_tests; + fn focused_slider_key() -> egui::Id { egui::Id::new("lightcraft-focused-slider") } diff --git a/crates/ui-egui/src/widgets/tests_numeric_entry.rs b/crates/ui-egui/src/widgets/tests_numeric_entry.rs index 5e54ff064..278ff3ba8 100644 --- a/crates/ui-egui/src/widgets/tests_numeric_entry.rs +++ b/crates/ui-egui/src/widgets/tests_numeric_entry.rs @@ -11,6 +11,7 @@ struct Harness { enabled: bool, time: f64, rect: Rect, + track: Rect, } impl Harness { fn new() -> Self { @@ -21,6 +22,7 @@ impl Harness { enabled: true, time: 0.0, rect: Rect::NOTHING, + track: Rect::NOTHING, }; for _ in 0..3 { h.frame(vec![]); @@ -33,7 +35,10 @@ impl Harness { self.view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, self.time, events), |ui| { out = slider(ui, &self.spec, self.value, self.enabled, None); }); - self.rect = take_registry(&self.view.ctx).into_iter().find(|(id, _)| id == &format!("sliderValue:{}", self.spec.id)).unwrap().1; + let registry = take_registry(&self.view.ctx); + let find = |name: String| registry.iter().find(|(id, _)| id == &name).map(|(_, r)| *r); + self.rect = find(format!("sliderValue:{}", self.spec.id)).unwrap(); + self.track = find(format!("slider:{}", self.spec.id)).unwrap(); if let Some(v) = out.value { self.value = v; } @@ -64,9 +69,10 @@ fn opening_release_honors_return_and_escape_immediately() { #[test] fn dragging_away_from_readout_does_not_capture_keyboard() { + // straight down: no sideways travel, so the press neither types nor scrubs let mut h = Harness::new(); let inside = h.rect.center(); - let outside = egui::pos2(5.0, 80.0); + let outside = egui::pos2(inside.x, 80.0); let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; for events in [vec![Event::PointerMoved(inside)], vec![button(inside, true)], vec![Event::PointerMoved(outside)], vec![button(outside, false)]] { let out = h.frame(events); @@ -94,3 +100,79 @@ fn typing_after_a_released_click_still_edits() { h.frame(vec![Event::Text("2".into())]); assert_eq!(h.key(Key::Enter).value, Some(2.0)); } + +impl Harness { + /// The track's width: a scrub moves the value as far as dragging the thumb would. + fn track_width(&self) -> f32 { + self.track.width() + } + /// Press on the value, move sideways by `dx` in `steps` frames (Shift held when `shift`) and release; + /// the outputs of every frame. + fn scrub(&mut self, dx: f32, steps: usize, shift: bool) -> Vec<SliderOut> { + let modifiers = if shift { Modifiers::SHIFT } else { Modifiers::NONE }; + let start = self.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers }; + let mut outs = vec![self.frame(vec![Event::ModifiersChanged(modifiers), Event::PointerMoved(start)]), self.frame(vec![button(start, true)])]; + for i in 1..=steps { + let pos = start + vec2(dx * i as f32 / steps as f32, 0.0); + outs.push(self.frame(vec![Event::PointerMoved(pos)])); + } + outs.push(self.frame(vec![button(start + vec2(dx, 0.0), false)])); + outs.push(self.frame(vec![Event::ModifiersChanged(Modifiers::NONE)])); + outs + } +} + +#[test] +fn dragging_the_value_sideways_scrubs_it_like_the_thumb() { + let mut h = Harness::new(); + let width = f64::from(h.track_width()); + assert!(width > 100.0, "the slider has a track ({width})"); + // a fifth of the track: a fifth of the range (Exposure −5..+5 → +2 EV) + let outs = h.scrub((width / 5.0) as f32, 10, false); + assert!((h.value - 2.71).abs() < 0.015, "scrubbed to {}", h.value); + assert_eq!(outs.iter().filter(|o| o.drag_started).count(), 1, "one interaction begins"); + assert_eq!(outs.iter().filter(|o| o.drag_stopped).count(), 1, "and ends, so the scrub is one undo step"); + assert!(outs.iter().filter_map(|o| o.value).count() > 1, "the value follows the pointer while it moves"); + assert!(!h.view.ctx.egui_wants_keyboard_input(), "a scrub opens no value field"); + assert!(h.key(Key::Enter).value.is_none(), "and leaves no draft to commit"); +} + +#[test] +fn scrubbing_back_and_with_shift() { + let mut h = Harness::new(); + let width = h.track_width(); + // left lowers the value; on the control's steps and inside its range + h.scrub(-width, 8, false); + assert_eq!(h.value, -5.0, "clamped at the minimum"); + // Shift moves a tenth as far + let mut h = Harness::new(); + h.scrub(width / 5.0, 10, true); + assert!((h.value - 0.91).abs() < 0.015, "fine scrub to {}", h.value); + assert!(((h.value / 0.01).round() * 0.01 - h.value).abs() < 1e-9, "on the 0.01 steps"); +} + +#[test] +fn a_press_that_barely_moves_still_types() { + // a hand's jitter under the scrub distance is a click: the value can be typed + let mut h = Harness::new(); + let pos = h.rect.center(); + let button = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + h.frame(vec![Event::PointerMoved(pos)]); + h.frame(vec![button(pos, true)]); + h.frame(vec![Event::PointerMoved(pos + vec2(1.5, 0.5))]); + assert_eq!(h.frame(vec![button(pos + vec2(1.5, 0.5), false)]).value, None); + h.frame(vec![]); + assert!(h.view.ctx.egui_wants_keyboard_input(), "the field keeps the keyboard after the click"); + h.frame(vec![Event::Text("2".into())]); + assert_eq!(h.key(Key::Enter).value, Some(2.0)); +} + +#[test] +fn a_disabled_value_does_not_scrub() { + let mut h = Harness::new(); + h.enabled = false; + let width = h.track_width(); + assert!(h.scrub(width / 5.0, 10, false).iter().all(|o| o.value.is_none() && !o.drag_started)); + assert_eq!(h.value, 0.71); +} diff --git a/crates/ui-egui/src/widgets/tests_slider_reset.rs b/crates/ui-egui/src/widgets/tests_slider_reset.rs new file mode 100644 index 000000000..091768ec7 --- /dev/null +++ b/crates/ui-egui/src/widgets/tests_slider_reset.rs @@ -0,0 +1,36 @@ +//! Real egui pointer frames on a disabled slider: its name doesn't reset it (issue #1436). +use super::{SliderOut, slider, take_registry}; +use crate::headless::HeadlessView; +use egui::{Event, Modifiers, PointerButton, Rect, vec2}; +use lightcraft_develop::controls; + +fn frame(view: &mut HeadlessView, time: f64, enabled: bool, events: Vec<Event>) -> (SliderOut, Rect) { + let spec = controls::find("light.exposure").unwrap(); + let mut out = SliderOut::default(); + view.run(HeadlessView::raw_input(vec2(330.0, 100.0), 1.0, time, events), |ui| { + out = slider(ui, spec, 1.25, enabled, None); + }); + let name = take_registry(&view.ctx).into_iter().find(|(id, _)| id == "sliderLabel:light.exposure").unwrap().1; + (out, name) +} + +#[test] +fn a_disabled_sliders_name_ignores_a_double_click() { + for enabled in [true, false] { + let mut view = HeadlessView::new(); + let mut time = 0.0; + let (_, name) = frame(&mut view, time, enabled, vec![]); + let pos = name.left_center() + vec2(8.0, 0.0); + let mut reset = false; + for pressed in [None, Some(true), Some(false), Some(true), Some(false), None] { + time += 0.02; + let events = match pressed { + None => vec![Event::PointerMoved(pos)], + Some(pressed) => vec![Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE }], + }; + let (out, _) = frame(&mut view, time, enabled, events); + reset |= out.reset || out.value.is_some(); + } + assert_eq!(reset, enabled, "enabled: {enabled}"); + } +} diff --git a/crates/ui-egui/src/window_place.rs b/crates/ui-egui/src/window_place.rs new file mode 100644 index 000000000..e88cca9a1 --- /dev/null +++ b/crates/ui-egui/src/window_place.rs @@ -0,0 +1,919 @@ +//! How large the app's window was and whether it was zoomed (macOS) / maximized, so that it opens +//! like that again. Kept with the settings (`ui.json`). +//! +//! The size is the window's as it is when neither zoomed nor full screen: a zoomed window opens +//! zoomed, and goes back to that size when it is un-zoomed. Full screen is not remembered (the +//! app opens as a window), and neither is where on the desktop the window was: the system places +//! it. A place can be on a display that is gone, and only the window system knows which +//! displays there are. +//! +//! Windows and Linux say whether a window is maximized. macOS says so only when the window opens +//! and when the app zooms it itself, so there the window's size is gone by as well (`Unsaid`): a +//! window is zoomed when the system said so, or when it was seen to come to fill its screen, and +//! for as long as it then keeps its size (or is fitted to another screen that it fills). That +//! is a judgement, and it errs to one side on purpose: whatever is in doubt is a window of the +//! size it has, which opens as large as it was left, and only the size it would have gone back +//! to is not kept. So it is with a zoom from the Window menu beside a Dock at the side of the +//! screen, or of a window already as wide as its screen. The other way round there is one case: +//! a window dragged out by hand until it is as wide as its screen and nearly as tall is taken +//! for zoomed, and opens filling the screen. +//! +//! This module only decides (what to remember from what the window reports, what to open with); +//! the desktop app builds its window from it and feeds it each frame. + +use serde::{Deserialize, Serialize}; + +/// The size a window opens with when none is remembered, in points. +pub const DEFAULT_SIZE: [f32; 2] = [1600.0, 1000.0]; +/// The smallest the window gets. +pub const MIN_SIZE: [f32; 2] = [900.0, 560.0]; +/// No window is wider or taller than this: a larger number in the settings is not a size. +const MAX_SIDE: f32 = 16_384.0; +/// How long a size must stay as it is before it is remembered: a window that is being resized +/// or zoomed (which the system animates) passes through sizes it never rests at. +const SETTLED_SECONDS: f64 = 0.5; +/// A size that has stayed this long when the app quits is remembered too (an animation changes +/// it with every frame; a person who resizes and quits at once has let go of it by then). +const SETTLED_AT_EXIT_SECONDS: f64 = 0.15; +/// Two sizes this near each other, across and down, are one size (a size goes through the +/// screen's pixels and back). +const SAME_WITHIN: f32 = 1.5; +/// How much of its screen's height a zoomed window leaves to the system at most: the menu bar +/// (taller beside a camera housing) and a Dock along the bottom at its largest. +const SCREEN_KEPT_DOWN: f32 = 240.0; +/// A little longer than the wait, so that the frame asked for comes after it and not just before. +const LOOK_AGAIN_MARGIN_SECONDS: f64 = 0.05; +/// How long a window that has just left full screen is not looked at: the system animates the +/// way back, and not every way of leaving says that it is still under way. +const AFTER_FULL_SCREEN_SECONDS: f64 = 1.0; + +/// How the window was. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct WindowPlace { + /// Its inner size while neither zoomed nor full screen, in points (of the system, not of + /// the interface's own scale). + pub size: [f32; 2], + /// Zoomed (macOS) / maximized. + #[serde(default)] + pub zoomed: bool, +} + +fn size_is_one(size: [f32; 2]) -> bool { + size.iter().all(|s| s.is_finite() && (1.0..=MAX_SIDE).contains(s)) +} + +/// `size`, or the smallest the window gets where it is smaller (a window manager that does not +/// keep to the minimum, a script): what is remembered is always a size the window opens at. +fn at_least_min(size: [f32; 2]) -> [f32; 2] { + [size[0].max(MIN_SIZE[0]), size[1].max(MIN_SIZE[1])] +} + +fn same(a: [f32; 2], b: [f32; 2]) -> bool { + (a[0] - b[0]).abs() <= SAME_WITHIN && (a[1] - b[1]).abs() <= SAME_WITHIN +} + +/// Whether a window of `size` fills `screen` as a zoomed one does where the Dock is hidden or +/// along the bottom: from side to side, and down to what the system keeps. +fn fills(size: [f32; 2], screen: [f32; 2]) -> bool { + size[0] >= screen[0] - SAME_WITHIN && size[1] >= screen[1] - SCREEN_KEPT_DOWN +} + +/// Whether a window of `size` covers `screen` altogether, as one in full screen does. +fn covers(size: [f32; 2], screen: [f32; 2]) -> bool { + size[0] >= screen[0] - SAME_WITHIN && size[1] >= screen[1] - SAME_WITHIN +} + +impl WindowPlace { + /// What the settings hold (which a person or another version may have written), as + /// something a window can be: a size within bounds. `None` when the size is no size at all. + pub fn checked(self) -> Option<WindowPlace> { + size_is_one(self.size).then(|| WindowPlace { size: at_least_min(self.size), zoomed: self.zoomed }) + } + + /// Reads the window from the settings without letting it cost the rest of them: anything that + /// is not a window as this version writes it (another shape, a hand edit) reads as nothing + /// remembered, where a strict reading would have the whole file taken for damaged. + pub fn lenient<'de, D: serde::Deserializer<'de>>(d: D) -> Result<Option<WindowPlace>, D::Error> { + let value = serde_json::Value::deserialize(d)?; + Ok(serde_json::from_value::<Option<WindowPlace>>(value).ok().flatten().and_then(WindowPlace::checked)) + } +} + +/// What the window reports in one frame, in the system's points. +#[derive(Clone, Copy, Debug, Default, PartialEq)] +pub struct Seen { + /// The size of what it shows. + pub size: Option<[f32; 2]>, + /// The size of the screen it is on. + pub screen: Option<[f32; 2]>, + /// What the window system last said of its being zoomed / maximized. + pub zoomed: bool, + pub full_screen: bool, + pub minimized: bool, +} + +impl Seen { + /// From what egui holds of the window: its `info`, the rectangle it draws in (`content`: the + /// one size every platform reports, Wayland too) and the interface's own scale (`zoom`), by + /// which egui's points differ from the system's. + pub fn of(info: &egui::ViewportInfo, content: egui::Rect, zoom: f32) -> Seen { + let zoom = if zoom.is_finite() && zoom > 0.0 { zoom } else { 1.0 }; + let scaled = |v: egui::Vec2| [v.x * zoom, v.y * zoom]; + Seen { + size: Some(scaled(content.size())).filter(|s| size_is_one(*s)), + screen: info.monitor_size.map(scaled).filter(|s| size_is_one(*s)), + zoomed: info.maximized == Some(true), + full_screen: info.fullscreen == Some(true), + minimized: info.minimized == Some(true), + } + } +} + +/// Follows the window from frame to frame and says what to remember of it. +#[derive(Debug)] +pub struct WindowWatch { + /// A size the window has, and since when (seconds), until it has rested there long enough. + resting: Option<(WindowPlace, f64)>, + /// Whether the window system's word on being zoomed is kept up to date. On macOS it is only + /// what was true when the window opened or the app last zoomed it itself (the top bar + /// double-clicked): Window ▸ Zoom and a drag of the window's edge go unseen. + told_live: bool, + /// What the watch goes by where that word is not live. + unsaid: Unsaid, + /// The window was full screen when it was last seen. + full_screen: bool, + /// Until when (seconds) a window that has left full screen is not looked at. + back_from_full_screen: Option<f64>, +} + +/// Where the window system's word on being zoomed is not live: what the window is taken for. +/// It is zoomed on the system's word, or when it was seen to come to fill its screen, and for as +/// long as it then keeps the size it has zoomed. Anything else is a window: taking a window for +/// zoomed would open it larger than it was left, where taking a zoomed one for a window opens +/// it as large as it was. +#[derive(Debug, Default)] +struct Unsaid { + /// The system's word at the last frame. When it changes, the app has zoomed or un-zoomed the + /// window itself, and what it is taken for starts over from that. + said: bool, + taken: Taken, +} + +#[derive(Clone, Copy, Debug, Default)] +enum Taken { + /// Not seen yet (or let go of): the system's word says what it is. + #[default] + Unseen, + /// A window, of this size when it was last seen. + Window { size: [f32; 2] }, + /// Zoomed: at this size since then (seconds), on this screen; `rested` once it has stayed + /// that size for a moment. Until then it may be on its way there (the system animates a + /// zoom), and `size` is the largest it has been, across and down: on its way it only grows. + Zoomed { size: [f32; 2], since: f64, screen: Option<[f32; 2]>, rested: bool }, +} + +impl Default for WindowWatch { + fn default() -> Self { + WindowWatch::told_live(!cfg!(target_os = "macos")) + } +} + +impl WindowWatch { + /// A watch on a platform where the window system's word on being zoomed is live, or not. + pub fn told_live(told_live: bool) -> WindowWatch { + WindowWatch { resting: None, told_live, unsaid: Unsaid::default(), full_screen: false, back_from_full_screen: None } + } + + /// The window is `seen` at time `now` (seconds), and `remembered` is what the settings hold: + /// returns what they should hold from now on. Whether the window is zoomed is taken at + /// once; a size only once the window has rested at it for a moment. A minimized or + /// full-screen window changes nothing, nor does one that has only just left full screen. + pub fn note(&mut self, remembered: Option<WindowPlace>, seen: &Seen, now: f64) -> Option<WindowPlace> { + if seen.full_screen { + self.full_screen = true; + self.back_from_full_screen = None; + self.let_go(); + return remembered; + } + if std::mem::take(&mut self.full_screen) { + self.back_from_full_screen = Some(now + AFTER_FULL_SCREEN_SECONDS); + } + if seen.minimized { + self.back_from_full_screen = None; + self.let_go(); + return remembered; + } + if self.back_from_full_screen.is_some_and(|until| now < until) { + self.let_go(); + return remembered; + } + self.back_from_full_screen = None; + let zoomed = if self.told_live { seen.zoomed } else { self.taken_for_zoomed(seen, now) }; + if zoomed { + // (its size now is the screen's, not the window's own: the size it goes back to stays) + self.resting = None; + return Some(WindowPlace { size: remembered.map_or(DEFAULT_SIZE, |r| r.size), zoomed: true }); + } + let Some(size) = seen.size else { + self.resting = None; + return remembered; + }; + if seen.screen.is_some_and(|screen| covers(size, screen)) { + // (no window of its own size covers its screen: full screen, though nothing says so) + self.resting = None; + return remembered; + } + let place = WindowPlace { size: at_least_min(size), zoomed: false }; + if Some(place) == remembered { + self.resting = None; + return remembered; + } + match self.resting { + Some((resting, since)) if resting == place => { + if now - since >= SETTLED_SECONDS { + self.resting = None; + return Some(place); + } + } + _ => self.resting = Some((place, now)), + } + // (meanwhile only what is certain: the window is not zoomed) + remembered.map(|r| WindowPlace { zoomed: false, ..r }) + } + + /// Lets go of whatever has not rested yet: nothing waits (and wakes the app) while the + /// window is not one to look at. A window taken for zoomed stays that, at the size it was + /// last seen at. + fn let_go(&mut self) { + self.resting = None; + if let Taken::Zoomed { rested, .. } = &mut self.unsaid.taken { + *rested = true; + } + } + + /// Whether the window is zoomed, where the window system does not keep saying (`Unsaid`). + fn taken_for_zoomed(&mut self, seen: &Seen, now: f64) -> bool { + let Some(size) = seen.size else { + // (nothing to go by in this frame: as it was taken, or by the system's word if it + // was never seen) + self.let_go(); + return match self.unsaid.taken { + Taken::Unseen => seen.zoomed, + taken => matches!(taken, Taken::Zoomed { .. }), + }; + }; + let zoomed_now = Taken::Zoomed { size, since: now, screen: seen.screen, rested: false }; + let fills_its_screen = |size: [f32; 2]| seen.screen.is_some_and(|screen| fills(size, screen)); + self.unsaid.taken = if matches!(self.unsaid.taken, Taken::Unseen) || seen.zoomed != self.unsaid.said { + if seen.zoomed { zoomed_now } else { Taken::Window { size } } + } else { + match self.unsaid.taken { + // it comes to fill its screen: zoomed from the Window menu + Taken::Window { size: was } if fills_its_screen(size) && !fills_its_screen(was) => zoomed_now, + Taken::Window { .. } | Taken::Unseen => Taken::Window { size }, + Taken::Zoomed { size: at, since, screen, rested } => { + // (at one size for a moment: rested, whether or not a frame came to see it) + let rested = rested || now - since >= SETTLED_SECONDS; + let other_screen = seen.screen.is_some() && screen != seen.screen; + if same(at, size) && (!other_screen || fills_its_screen(size)) { + // as it was (not carried, as large as it is, to a screen it does not fill) + Taken::Zoomed { size: at, since, screen, rested } + } else if !rested && !other_screen && size[0] >= at[0] - SAME_WITHIN && size[1] >= at[1] - SAME_WITHIN { + // on its way there: larger than it has been, and never smaller again + Taken::Zoomed { size: [size[0].max(at[0]), size[1].max(at[1])], since: now, screen, rested: false } + } else if rested && other_screen && fills_its_screen(size) { + // its screen changed (a display unplugged, another resolution) and it fills the new one + zoomed_now + } else { + // resized by its edge, or zoomed back from the Window menu + Taken::Window { size } + } + } + } + }; + self.unsaid.said = seen.zoomed; + matches!(self.unsaid.taken, Taken::Zoomed { .. }) + } + + /// How long until a size the window has will have rested long enough, if one is waiting: the + /// app then looks again (nothing else may ask for a frame once the window is let go of). + pub fn waits(&self, now: f64) -> Option<std::time::Duration> { + let (since, wait) = match (self.back_from_full_screen, self.resting, self.unsaid.taken) { + (Some(until), _, _) => (until - AFTER_FULL_SCREEN_SECONDS, AFTER_FULL_SCREEN_SECONDS), + (None, Some((_, since)), _) | (None, None, Taken::Zoomed { since, rested: false, .. }) => (since, SETTLED_SECONDS), + _ => return None, + }; + let left = (wait - (now - since)).clamp(0.0, wait) + LOOK_AGAIN_MARGIN_SECONDS; + Some(std::time::Duration::try_from_secs_f64(left).unwrap_or(std::time::Duration::from_secs(1))) + } + + /// The app quits at time `now`: what the settings should hold. A size the window has rested + /// at, if only briefly, is remembered; one it is passing through is not. + pub fn at_exit(&mut self, remembered: Option<WindowPlace>, now: f64) -> Option<WindowPlace> { + match self.resting.take() { + Some((place, since)) if now - since >= SETTLED_AT_EXIT_SECONDS => Some(place), + _ => remembered, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SCREEN: [f32; 2] = [2560.0, 1440.0]; + + fn window(size: [f32; 2]) -> Seen { + Seen { size: Some(size), screen: Some(SCREEN), ..Default::default() } + } + + fn place(size: [f32; 2], zoomed: bool) -> WindowPlace { + WindowPlace { size, zoomed } + } + + /// Given a window that is resized and comes to rest, when it is watched, then its new size + /// is remembered once it has rested for a moment, and none of the sizes it passed through. + #[test] + fn a_window_is_remembered_at_the_size_it_comes_to_rest_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let mut remembered = None; + for (i, width) in [1000.0, 1100.0, 1200.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), i as f64 * 0.1); + assert_eq!(remembered, None, "passing through {width}"); + assert!(watch.waits(i as f64 * 0.1).is_some(), "and the app looks again"); + } + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.5); + assert_eq!(remembered, None, "not yet at rest"); + // (it has been this size since 0.3: the app looks again just after 0.8) + let wait = watch.waits(0.5).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - 0.35).abs() < 1e-6, "{wait}"); + remembered = watch.note(remembered, &window([1300.0, 800.0]), 0.9); + assert_eq!(remembered, Some(place([1300.0, 800.0], false))); + assert!(watch.waits(0.9).is_none()); + // as it is: nothing to wait for + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 5.0), remembered); + assert!(watch.waits(5.0).is_none()); + } + } + + /// Given a window at rest that is zoomed (the top bar double-clicked), when it is watched + /// where the window system says so, then it is remembered as zoomed at once, with the size + /// it had before, though it grew through other sizes on the way; un-zoomed, it is no longer + /// zoomed at once and keeps that size. + #[test] + fn a_zoomed_window_keeps_the_size_it_goes_back_to() { + let mut watch = WindowWatch::told_live(true); + let rest = place([1300.0, 800.0], false); + let mut remembered = Some(rest); + for (i, width) in [1600.0, 2000.0, 2400.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 1300.0]), 10.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest)); + } + let zoomed = Seen { zoomed: true, ..window([2560.0, 1412.0]) }; + remembered = watch.note(remembered, &zoomed, 10.2); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &zoomed, 30.0), Some(place([1300.0, 800.0], true)), "however long it stays zoomed"); + assert!(watch.waits(30.0).is_none()); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 31.0); + assert_eq!(remembered, Some(rest), "no longer zoomed, and the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 31.2), Some(rest)); + // zoomed before anything was remembered: it opens zoomed, and goes back to the usual size + assert_eq!(WindowWatch::told_live(true).note(None, &zoomed, 0.0), Some(place(DEFAULT_SIZE, true))); + // the system's word is the answer there: a window as large as its screen is a window + assert_eq!(WindowWatch::told_live(true).note(Some(rest), &window([2560.0, 1335.0]), 0.0).map(|p| p.zoomed), Some(false)); + } + + /// Given macOS, where the window system says whether the window is zoomed only as it opens + /// and when the app zooms it itself: when a window that opened zoomed is resized by its edge, + /// then it is a window again, remembered at the size it was given, though the system's word + /// still says zoomed; zoomed once more, it is zoomed. + #[test] + fn a_zoomed_window_resized_by_its_edge_is_a_window_again_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1335.0]) }; + let mut remembered = watch.note(Some(rest), &zoomed, 0.0); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.0).is_some(), "the size it is zoomed at is still to be seen at rest"); + remembered = watch.note(remembered, &zoomed, 0.6); + assert_eq!(remembered, Some(rest)); + assert!(watch.waits(0.6).is_none()); + assert_eq!(watch.note(remembered, &zoomed, 60.0), Some(rest), "however long it stays zoomed"); + // (a size goes through the screen's pixels and back: a point off is the same size) + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([2559.0, 1336.0]) }, 60.5), Some(rest)); + // its right edge dragged in a little, far less than a Dock is wide; the word is unchanged + let dragged = Seen { zoomed: true, ..window([2500.0, 1335.0]) }; + remembered = watch.note(remembered, &dragged, 61.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], false)), "not zoomed any more"); + remembered = watch.note(remembered, &dragged, 61.6); + assert_eq!(remembered, Some(place([2500.0, 1335.0], false)), "and at the size it was given"); + assert!(watch.waits(61.6).is_none()); + // zoomed again from the Window menu: back at the size it is zoomed at + assert_eq!(watch.note(remembered, &zoomed, 70.0), Some(place([2500.0, 1335.0], true))); + } + + /// Given macOS, when the app zooms the window itself (the top bar double-clicked), then the + /// system says so at once and the window is zoomed through all the sizes it grows through, + /// with the size it had, wherever the Dock is; un-zoomed the same way, it is a window again + /// at that size. + #[test] + fn a_window_the_app_zooms_is_zoomed_at_once_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], false); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + // (a Dock at the left: zoomed, the window is narrower than its screen) + for (i, width) in [1300.0, 1700.0, 2100.0, 2410.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 1415.0]) }, 1.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1300.0, 800.0], true)), "growing through {width}"); + } + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2410.0, 1415.0]) }, 2.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert!(watch.waits(2.0).is_none()); + for (i, width) in [2100.0, 1700.0, 1300.0].into_iter().enumerate() { + remembered = watch.note(remembered, &window([width, 800.0]), 3.0 + i as f64 * 0.05); + assert_eq!(remembered, Some(rest), "shrinking through {width}"); + } + assert!(watch.waits(3.1).is_none(), "back at the size it had: nothing to wait for"); + } + + /// Given macOS and a window zoomed from the Window menu (the system's word stays "not + /// zoomed"), when it is seen to come to fill its screen from side to side, then it is zoomed, + /// with the size it had, and zoomed back it is a window at that size again. A window that + /// only grows large is not zoomed, on any screen. + #[test] + fn a_window_that_comes_to_fill_its_screen_is_zoomed_where_the_system_does_not_say() { + let rest = place([1300.0, 800.0], false); + // a window of 1300 × 800 on `screen` that is next seen at `size` + let zoomed_on = |screen: [f32; 2], size: [f32; 2]| { + let mut watch = WindowWatch::told_live(false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + watch.note(Some(rest), &seen([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &seen(size), 0.2).map(|p| p.zoomed) + }; + // under the menu bar, with the Dock hidden, small, or as large as it gets + for (screen, size) in [ + ([2560.0, 1440.0], [2560.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1335.0]), + ([2560.0, 1440.0], [2559.0, 1335.0]), + ([1728.0, 1117.0], [1728.0, 1084.0]), + ([1512.0, 982.0], [1512.0, 794.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(true), "{size:?} on {screen:?}"); + } + // the usual size on a laptop's screen, windows sized by hand to nearly the screen, one + // that is a little narrower than it, and one as wide as it and far from as tall + for (screen, size) in [ + ([1728.0, 1117.0], DEFAULT_SIZE), + ([1710.0, 1107.0], DEFAULT_SIZE), + ([1512.0, 982.0], [1400.0, 820.0]), + ([1470.0, 956.0], [1340.0, 780.0]), + ([2560.0, 1440.0], [2430.0, 1270.0]), + ([2560.0, 1440.0], [2300.0, 1200.0]), + ([2560.0, 1440.0], [2550.0, 1415.0]), + ([2560.0, 1440.0], [2560.0, 1190.0]), + ] { + assert_eq!(zoomed_on(screen, size), Some(false), "{size:?} on {screen:?}"); + } + let mut watch = WindowWatch::told_live(false); + let filling = window([2560.0, 1335.0]); + let mut remembered = watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + remembered = watch.note(remembered, &window([2000.0, 1100.0]), 0.9); + remembered = watch.note(remembered, &filling, 1.0); + assert_eq!(remembered, Some(place([1300.0, 800.0], true))); + assert_eq!(watch.note(remembered, &filling, 9.0), remembered, "and its own size is not written over"); + remembered = watch.note(remembered, &window([1900.0, 1000.0]), 10.0); + assert_eq!(remembered, Some(rest), "zoomed back: a window, at the size it had"); + assert_eq!(watch.note(remembered, &window([1300.0, 800.0]), 10.2), Some(rest)); + assert!(watch.waits(10.2).is_none()); + // not told apart: zoomed from the Window menu beside a Dock at the side, it is remembered + // as a window of that size + let mut watch = WindowWatch::told_live(false); + let beside_a_dock = window([2410.0, 1415.0]); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + watch.note(Some(rest), &beside_a_dock, 0.3); + assert_eq!(watch.note(Some(rest), &beside_a_dock, 0.9), Some(place([2410.0, 1415.0], false))); + // without a screen's size to go by, the system's word is all there is + let no_screen = Seen { size: Some([2560.0, 1335.0]), screen: None, zoomed: true, ..Default::default() }; + assert_eq!(WindowWatch::told_live(false).note(Some(rest), &no_screen, 0.0), Some(place([1300.0, 800.0], true))); + } + + /// Given macOS and a window that was left as wide as its screen and a little shorter (its + /// lower edge dragged up after a zoom), when the app opens it at that size, then it is a + /// window of that size, launch after launch: filling the screen is no sign of a zoom unless + /// the window was seen to come to it. Dragged shorter still, it is remembered at that. + #[test] + fn a_window_that_opens_as_wide_as_its_screen_is_a_window_where_the_system_does_not_say() { + for (screen, size) in [([1728.0, 1117.0], [1728.0, 1054.0]), ([1512.0, 982.0], [1512.0, 760.0]), ([3440.0, 1440.0], [3440.0, 1200.0])] { + let left = place(size, false); + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + for frame in 0..5 { + assert_eq!(watch.note(Some(left), &seen(size), f64::from(frame) * 0.3), Some(left), "{size:?} on {screen:?}"); + } + let shorter = [size[0], size[1] - 20.0]; + watch.note(Some(left), &seen(shorter), 2.0); + assert_eq!(watch.note(Some(left), &seen(shorter), 2.6), Some(place(shorter, false))); + } + // the same after a double-click on the top bar that changed nothing (the system's word was + // stale, and is now "not zoomed"): still the window it is + let mut watch = WindowWatch::told_live(false); + let screen = [1728.0, 1117.0]; + let left = place([1728.0, 1054.0], false); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 0.0); + watch.note(Some(left), &Seen { size: Some(left.size), screen: Some(screen), zoomed: true, ..Default::default() }, 1.0); + // (taken for zoomed on the system's word, until that word is taken back) + let unsaid = Seen { size: Some(left.size), screen: Some(screen), zoomed: false, ..Default::default() }; + assert_eq!(watch.note(Some(left), &unsaid, 2.0), Some(left)); + assert_eq!(watch.note(Some(left), &unsaid, 9.0), Some(left)); + } + + /// Given macOS and a zoomed window, when it is zoomed back from the Window menu (or resized by + /// its edge) before it was ever seen at rest, then it is a window at once, and the size it + /// comes to rest at is the window's, not taken for the size it is zoomed at. + #[test] + fn a_window_zoomed_back_at_once_is_a_window_where_the_system_does_not_say() { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + let mut remembered = watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + assert_eq!(remembered, Some(rest)); + for (i, width) in [2200.0, 1700.0, 1240.0, 1240.0, 1240.0].into_iter().enumerate() { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([width, 820.0]) }, 0.3 + i as f64 * 0.05); + assert_eq!(remembered, Some(place([1240.0, 820.0], false)), "{width}"); + } + assert_eq!(watch.note(remembered, &Seen { zoomed: true, ..window([1240.0, 820.0]) }, 60.0), Some(place([1240.0, 820.0], false))); + assert!(watch.waits(60.0).is_none()); + } + + /// Given macOS and a zoomed window, when its screen changes (the display unplugged, another + /// resolution) and it is fitted to the new one, then it is still zoomed and keeps the size + /// it goes back to. Moved to a larger screen that it does not fill, it is a window. + #[test] + fn a_zoomed_window_is_still_zoomed_on_another_screen_where_the_system_does_not_say() { + for said in [true, false] { + let rest = place([1240.0, 820.0], false); + let on = |screen: [f32; 2], size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), zoomed: said, ..Default::default() }; + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &on([2560.0, 1440.0], [1240.0, 820.0]), 0.0); + if said { + // (the system's word as the window opened: a window; the app zooms it later) + remembered = watch.note(remembered, &Seen { zoomed: false, ..on([2560.0, 1440.0], [1240.0, 820.0]) }, 0.0); + } + for now in [1.0, 2.0] { + remembered = watch.note(remembered, &on([2560.0, 1440.0], [2560.0, 1415.0]), now); + } + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "said: {said}"); + for now in [10.0, 10.6, 60.0] { + remembered = watch.note(remembered, &on([1728.0, 1117.0], [1728.0, 1084.0]), now); + assert_eq!(remembered, Some(place([1240.0, 820.0], true)), "on the smaller screen at {now}, said: {said}"); + } + assert!(watch.waits(60.0).is_none()); + // dragged over to a screen it does not fill + remembered = watch.note(remembered, &on([3440.0, 1440.0], [1728.0, 1084.0]), 70.0); + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "said: {said}"); + } + // seen zoomed in one frame only, and fitted to another screen long after: it had rested + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, 0.0); + let fitted = Seen { size: Some([1728.0, 1084.0]), screen: Some([1728.0, 1117.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &fitted, 5.0), Some(rest)); + } + + /// Given a size the window has not rested at yet, when the window goes full screen or is + /// minimized, or reports no size, then nothing waits (the app is not woken again and again + /// for as long as that lasts), and back as a window its size is seen afresh. + #[test] + fn nothing_waits_while_the_window_is_full_screen_or_minimized() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window(SCREEN) }, + ] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert!(watch.waits(1.0).is_some()); + assert_eq!(watch.note(Some(rest), &away, 1.1), Some(rest)); + assert!(watch.waits(1.1).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &away, 900.0), Some(rest)); + assert!(watch.waits(900.0).is_none(), "{away:?}"); + assert_eq!(watch.note(Some(rest), &window([1500.0, 900.0]), 901.0), Some(rest)); + let mut remembered = Some(rest); + for now in [902.2, 902.8] { + remembered = watch.note(remembered, &window([1500.0, 900.0]), now); + } + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?}"); + assert!(watch.waits(902.8).is_none()); + } + } + // (macOS: the same of a zoomed window's size that was still to be seen at rest) + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, 0.0); + assert!(watch.waits(0.0).is_some()); + watch.note(Some(rest), &away, 0.1); + assert!(watch.waits(0.1).is_none(), "{away:?}"); + } + } + + /// Given a zoomed window that was full screen, when it leaves full screen, then for a moment + /// it is not looked at (the system animates the way back, at sizes that are neither the + /// window's nor the zoomed one), the app looks again once that is over, and the window is + /// zoomed with the size it goes back to, as before. + #[test] + fn a_window_that_has_just_left_full_screen_is_not_looked_at() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for now in [0.0, 1.0] { + assert_eq!(watch.note(Some(rest), &zoomed, now), Some(rest)); + } + assert_eq!(watch.note(Some(rest), &Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, 5.0), Some(rest)); + // on the way back the system no longer says "full screen", nor "zoomed" where its word is live + for now in [9.0, 9.3, 9.6, 9.9] { + assert_eq!(watch.note(Some(rest), &Seen { zoomed: !live, ..window(SCREEN) }, now), Some(rest), "at {now}"); + let wait = watch.waits(now).map(|w| w.as_secs_f64()).unwrap(); + assert!((wait - (10.05 - now)).abs() < 1e-6, "at {now}: looks again after the moment, {wait}"); + } + assert_eq!(watch.note(Some(rest), &zoomed, 10.1), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 11.0), Some(rest)); + assert!(watch.waits(11.0).is_none()); + } + } + + /// Given macOS and a window dragged by its corner, when it reaches the screen's width and is + /// then dragged on, taller and little by little narrower again, and left narrower than the + /// screen, then it is a window at the size it was left at: a zoom only grows on its way, so + /// what gets narrower again is somebody's hand. + #[test] + fn a_window_dragged_wide_and_narrower_again_is_a_window_where_the_system_does_not_say() { + for per_frame in [1.0, 1.4] { + let screen = [1728.0, 1117.0]; + let seen = |size: [f32; 2]| Seen { size: Some(size), screen: Some(screen), ..Default::default() }; + let rest = place([1600.0, 950.0], false); + let mut watch = WindowWatch::told_live(false); + let mut remembered = watch.note(Some(rest), &seen(rest.size), 0.0); + let mut size = [1728.0, 950.0]; + let mut now = 1.0; + for _ in 0..40 { + remembered = watch.note(remembered, &seen(size), now); + size = [size[0] - per_frame, size[1] + 3.0]; + now += 1.0 / 60.0; + } + for _ in 0..3 { + remembered = watch.note(remembered, &seen(size), now); + now += 0.3; + } + assert_eq!(remembered, Some(place(size, false)), "{per_frame} pt narrower a frame"); + } + // a zoomed window whose edge is dragged in slowly: a window once it is no longer the size it was zoomed at + let mut watch = WindowWatch::told_live(false); + let rest = place([1300.0, 800.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let mut remembered = Some(rest); + for frame in 1..=10 { + remembered = watch.note(remembered, &Seen { zoomed: true, ..window([2560.0 - frame as f32, 1335.0]) }, 2.0 + f64::from(frame) / 60.0); + } + assert_eq!(remembered.map(|p| p.zoomed), Some(false), "ten points narrower, a point at a time"); + // and one whose lower edge is dragged up a little: it still fills its screen, and is a window + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let shorter = Seen { zoomed: true, ..window([2560.0, 1385.0]) }; + watch.note(Some(rest), &shorter, 2.0); + assert_eq!(watch.note(Some(rest), &shorter, 2.6), Some(place([2560.0, 1385.0], false))); + } + + /// Given macOS and a window that opened zoomed, when its edge is dragged in before it was + /// seen at rest (one side only gets smaller), or it is carried to a larger screen in that + /// moment, then it is a window; and a frame without the screen's size costs a zoomed window + /// nothing. + #[test] + fn a_zoomed_window_changed_before_it_rested_is_a_window_where_the_system_does_not_say() { + let rest = place([1240.0, 820.0], true); + let zoomed = Seen { zoomed: true, ..window([2560.0, 1415.0]) }; + for at in [0.1, 0.3] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let dragged = Seen { zoomed: true, ..window([2000.0, 1415.0]) }; + assert_eq!(watch.note(Some(rest), &dragged, at), Some(place([1240.0, 820.0], false)), "dragged at {at}"); + assert_eq!(watch.note(Some(rest), &dragged, at + 0.6), Some(place([2000.0, 1415.0], false))); + } + // carried to a larger screen, as large as it is, a moment after it opened: no frame came in between + for at in [0.2, 0.52, 0.9] { + let mut watch = WindowWatch::told_live(false); + watch.note(Some(rest), &zoomed, 0.0); + let carried = Seen { size: Some([2560.0, 1415.0]), screen: Some([5120.0, 2880.0]), zoomed: true, ..Default::default() }; + assert_eq!(watch.note(Some(rest), &carried, at).map(|p| p.zoomed), Some(false), "carried at {at}"); + } + // the screen's size missing for a frame + let mut watch = WindowWatch::told_live(false); + for now in [0.0, 1.0] { + watch.note(Some(rest), &zoomed, now); + } + assert_eq!(watch.note(Some(rest), &Seen { screen: None, ..zoomed }, 2.0), Some(rest)); + assert_eq!(watch.note(Some(rest), &zoomed, 2.1), Some(rest)); + } + + /// Given macOS and a window that opened zoomed and was then resized by hand (the system's + /// word still says zoomed), when it goes full screen and back, or reports no size for a + /// frame, then it is still the window it was: what was made out of it is not let go of for + /// the system's old word. + #[test] + fn a_window_made_out_is_not_taken_for_zoomed_again_on_the_systems_old_word() { + for away in [Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, Seen { size: None, zoomed: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(false); + let rest = place([1240.0, 820.0], true); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1415.0]) }, now); + } + let dragged = Seen { zoomed: true, ..window([1500.0, 900.0]) }; + let mut remembered = watch.note(Some(rest), &dragged, 2.0); + remembered = watch.note(remembered, &dragged, 2.6); + assert_eq!(remembered, Some(place([1500.0, 900.0], false))); + remembered = watch.note(remembered, &away, 5.0); + for now in [8.0, 8.6, 9.2, 30.0] { + remembered = watch.note(remembered, &dragged, now); + assert_eq!(remembered, Some(place([1500.0, 900.0], false)), "{away:?} at {now}"); + } + } + } + + /// Given a platform where the system says whether the window is maximized, when a window + /// grows until it fills its screen, then it is a window all the same: only the system's + /// word counts there. And the watch the app makes goes by that word everywhere but on macOS. + #[test] + fn where_the_system_says_only_its_word_counts() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + let mut remembered = Some(rest); + for (i, size) in [[1300.0, 800.0], [2000.0, 1100.0], [2560.0, 1335.0], [2560.0, 1335.0], [2560.0, 1335.0]].into_iter().enumerate() { + remembered = watch.note(remembered, &window(size), i as f64 * 0.4); + assert_eq!(remembered.map(|p| p.zoomed), Some(false)); + } + assert_eq!(remembered, Some(place([2560.0, 1335.0], false))); + // the app's own watch: opened zoomed and then seen at a hand size, the system's word unchanged + let mut watch = WindowWatch::default(); + for now in [0.0, 1.0] { + watch.note(Some(rest), &Seen { zoomed: true, ..window([2560.0, 1335.0]) }, now); + } + let zoomed = watch.note(Some(rest), &Seen { zoomed: true, ..window([1500.0, 900.0]) }, 2.0).map(|p| p.zoomed); + assert_eq!(zoomed, Some(!cfg!(target_os = "macos")), "macOS goes by the size, the others by the word"); + } + + /// Given a window that left full screen a moment ago, when it is minimized or goes full + /// screen again, then nothing waits; and a window that covers its screen altogether (full + /// screen, though nothing says so) is not remembered as a size, however long it stays. + #[test] + fn nothing_waits_when_a_window_just_back_from_full_screen_goes_away_again() { + let rest = place([1300.0, 800.0], false); + for live in [true, false] { + for away in [Seen { minimized: true, ..window([160.0, 28.0]) }, Seen { full_screen: true, ..window(SCREEN) }] { + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &Seen { full_screen: true, ..window(SCREEN) }, 1.0); + watch.note(Some(rest), &window([1300.0, 800.0]), 2.0); + assert!(watch.waits(2.0).is_some(), "the moment after full screen"); + for now in [2.1, 2.7, 60.0] { + assert_eq!(watch.note(Some(rest), &away, now), Some(rest)); + assert!(watch.waits(now).is_none(), "{away:?} at {now}"); + } + } + let mut watch = WindowWatch::told_live(live); + watch.note(Some(rest), &window([1300.0, 800.0]), 0.0); + for now in [1.0, 1.6, 2.2, 60.0] { + // (where the system does not say, a window that comes to cover its screen is zoomed) + assert_eq!(watch.note(Some(rest), &window(SCREEN), now).map(|p| p.size), Some(rest.size), "covering its screen at {now}"); + } + assert!(watch.waits(60.0).is_none()); + } + } + + /// Given a window smaller than the app's smallest (a window manager that does not keep to + /// it, a script), when it rests, then the smallest size is remembered, once: what is + /// remembered and what is seen agree from then on, and nothing waits. + #[test] + fn a_window_smaller_than_the_smallest_is_remembered_once() { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + let small = window([640.0, 700.0]); + let mut remembered = watch.note(Some(place([1300.0, 800.0], false)), &small, 0.0); + remembered = watch.note(remembered.and_then(WindowPlace::checked), &small, 0.6); + assert_eq!(remembered, Some(place([MIN_SIZE[0], 700.0], false))); + for frame in 0..20 { + let now = 1.0 + f64::from(frame) * 0.1; + assert_eq!(watch.note(remembered.and_then(WindowPlace::checked), &small, now), remembered); + assert!(watch.waits(now).is_none(), "frame {frame}"); + } + } + } + + /// Full screen and a minimized window are not sizes to open at: nothing is remembered of + /// them. Nor of a window that reports no size. + #[test] + fn full_screen_and_minimized_windows_are_not_remembered() { + let rest = place([1300.0, 800.0], false); + for seen in [ + Seen { full_screen: true, ..window(SCREEN) }, + Seen { full_screen: true, zoomed: true, ..window(SCREEN) }, + Seen { minimized: true, ..window([160.0, 28.0]) }, + Seen { size: None, ..window([0.0, 0.0]) }, + ] { + for live in [true, false] { + let mut watch = WindowWatch::told_live(live); + assert_eq!(watch.note(Some(rest), &seen, 0.0), Some(rest), "{seen:?}"); + assert_eq!(watch.note(Some(rest), &seen, 5.0), Some(rest), "{seen:?}"); + assert_eq!(watch.at_exit(Some(rest), 6.0), Some(rest)); + } + } + } + + /// Given a window that was resized, when the app quits before the size has rested its + /// moment, then the size is remembered if the window had been let go of, and not if it was + /// still changing with every frame (a zoom the system was animating). + #[test] + fn a_size_the_window_rested_at_is_remembered_when_the_app_quits() { + let rest = place([1300.0, 800.0], false); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.0); + assert_eq!(watch.at_exit(Some(rest), 1.3), Some(place([1500.0, 900.0], false))); + assert_eq!(watch.at_exit(Some(rest), 9.0), Some(rest), "said once"); + let mut watch = WindowWatch::told_live(true); + watch.note(Some(rest), &window([1500.0, 900.0]), 1.00); + watch.note(Some(rest), &window([1700.0, 1000.0]), 1.02); + assert_eq!(watch.at_exit(Some(rest), 1.04), Some(rest), "a size it was passing through"); + assert_eq!(WindowWatch::told_live(true).at_exit(None, 3.0), None); + } + + /// What egui holds of the window is read in the system's points: a size every platform + /// reports (the rectangle the app draws in), brought back from the interface's own scale. + #[test] + fn the_window_is_seen_in_the_systems_points() { + use egui::{Rect, ViewportInfo, pos2, vec2}; + let info = ViewportInfo { monitor_size: Some(vec2(1280.0, 720.0)), maximized: Some(true), fullscreen: Some(false), ..Default::default() }; + let content = Rect::from_min_size(pos2(0.0, 0.0), vec2(650.0, 400.0)); + // (the interface at twice its size: egui's points are half the system's) + let seen = Seen::of(&info, content, 2.0); + assert_eq!(seen, Seen { size: Some([1300.0, 800.0]), screen: Some([2560.0, 1440.0]), zoomed: true, full_screen: false, minimized: false }); + let away = ViewportInfo { maximized: Some(false), fullscreen: Some(true), minimized: Some(true), ..Default::default() }; + let seen = Seen::of(&away, content, 1.0); + assert!(!seen.zoomed && seen.full_screen && seen.minimized); + // nothing reported but the content (Wayland): the size is there all the same + assert_eq!(Seen::of(&ViewportInfo::default(), content, 1.0).size, Some([650.0, 400.0])); + // a scale or a size that is none + assert_eq!(Seen::of(&info, content, f32::NAN).size, Some([650.0, 400.0])); + assert_eq!(Seen::of(&info, Rect::NOTHING, 1.0).size, None); + assert_eq!(Seen::of(&info, Rect::from_min_size(pos2(0.0, 0.0), vec2(0.0, 0.0)), 1.0).size, None); + } + + /// What the settings hold is not trusted as it is: a size is brought within what a window can + /// be, and anything that is no window reads as nothing remembered, without an error (which + /// would have the whole settings file taken for damaged). + #[test] + fn what_was_remembered_is_checked_and_never_costs_the_other_settings() { + #[derive(Deserialize, Debug, PartialEq)] + struct Settings { + #[serde(default, deserialize_with = "WindowPlace::lenient")] + window: Option<WindowPlace>, + other: u8, + } + let read = |window: &str| serde_json::from_str::<Settings>(&format!(r#"{{"window":{window},"other":7}}"#)).map(|s| s.window); + assert_eq!(read(r#"{"size":[1300.0,800.0],"zoomed":true}"#).unwrap(), Some(place([1300.0, 800.0], true))); + assert_eq!(read(r#"{"size":[1300,800]}"#).unwrap(), Some(place([1300.0, 800.0], false))); + // (a place on the desktop, as a version that remembered one would write it, is passed over) + assert_eq!(read(r#"{"size":[1300,800],"position":[40,60],"zoomed":false}"#).unwrap(), Some(place([1300.0, 800.0], false))); + assert_eq!(read(r#"{"size":[200,100]}"#).unwrap().map(|p| p.size), Some(MIN_SIZE), "too small to be the app's window"); + for odd in [ + "null", + "{}", + r#""x""#, + "5", + "[]", + "true", + r#"{"size":[1,2,3]}"#, + r#"{"size":[1]}"#, + r#"{"size":"big"}"#, + r#"{"size":[null,800]}"#, + r#"{"size":[1300,800],"zoomed":1}"#, + r#"{"size":[0,0]}"#, + r#"{"size":[-5,800]}"#, + r#"{"size":[1e39,800]}"#, + r#"{"size":[99999,800]}"#, + ] { + assert_eq!(read(odd).unwrap(), None, "{odd}"); + } + assert_eq!(serde_json::from_str::<Settings>(r#"{"other":7}"#).unwrap(), Settings { window: None, other: 7 }, "settings from before"); + // and what is written is read back + let written = serde_json::to_string(&place([1300.5, 800.25], true)).unwrap(); + assert_eq!(read(&written).unwrap(), Some(place([1300.5, 800.25], true))); + for size in [[f32::NAN, 800.0], [f32::INFINITY, 800.0], [0.5, 800.0]] { + assert_eq!(place(size, false).checked(), None, "{size:?}"); + } + } +} diff --git a/crates/ui-egui/tests/accessibility.rs b/crates/ui-egui/tests/accessibility.rs index e28d060bb..04d64f3e5 100644 --- a/crates/ui-egui/tests/accessibility.rs +++ b/crates/ui-egui/tests/accessibility.rs @@ -55,7 +55,13 @@ fn audit(h: &Harness<'_, LightcraftApp>, place: &str) -> Vec<String> { for node in h.root().children_recursive() { let n = node.accesskit_node(); let role = n.role(); - if !interactive(role) { + // egui's own scroll bars are announced by their role, as native ones are + if role == Role::ScrollBar { + continue; + } + // a widget that takes the focus or a click is interactive whatever its role + let acts = |a| n.data().supports_action(a); + if !interactive(role) && !acts(egui::accesskit::Action::Focus) && !acts(egui::accesskit::Action::Click) { continue; } count += 1; @@ -187,3 +193,59 @@ fn canvas_tool_sliders_are_reachable_with_tab() { assert_ne!(before, after, "{right:?}: ArrowUp on the Tab-focused {name:?}"); } } + +/// The canvas handles are in the tree by name, with their position as the value. +#[test] +fn canvas_handles_are_named_with_their_position() { + let h = harness(ViewMode::Detail, RightPanel::Crop); + let found: Vec<(String, String)> = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .filter(|n| n.role() == Role::Button) + .map(|n| (n.label().unwrap_or_default(), n.value().unwrap_or_default())) + .collect(); + for name in ["Crop top-left corner", "Crop right edge", "Crop box"] { + assert!(found.iter().any(|(l, v)| l == name && v.contains('%')), "{name}: {found:?}"); + } + let h = harness(ViewMode::Detail, RightPanel::Remove); + assert!(h.root().children_recursive().any(|n| n.accesskit_node().label().is_some_and(|l| l.starts_with("New spot")))); +} + +/// Tab walks each right panel from top to bottom (rows left to right): the order a sighted user +/// reads it in. Each step down may not jump back up by more than a row. +#[test] +fn tab_walks_the_right_panels_top_to_bottom() { + for right in [RightPanel::Edit, RightPanel::Crop, RightPanel::Remove, RightPanel::Masking] { + let mut h = harness(ViewMode::Detail, right); + let panel_left = 1500.0 - h.state().ui.right_width - 10.0; + // the tool strip beside the panel is a column of its own, walked before it + let strip = h + .root() + .children_recursive() + .map(|n| n.accesskit_node()) + .find(|n| n.label().as_deref() == Some("Edit (E)")) + .and_then(|n| n.bounding_box()) + .map(|b| b.x0 as f32) + .unwrap_or(f32::INFINITY); + let mut seen: Vec<(String, f32)> = Vec::new(); + for _ in 0..120 { + h.key_press(egui::Key::Tab); + h.run_steps(1); + let Some(n) = h.root().children_recursive().map(|n| n.accesskit_node()).find(|n| n.is_focused()) else { continue }; + let Some(b) = n.bounding_box() else { continue }; + if (b.x0 as f32) < panel_left || (b.x0 as f32) >= strip - 1.0 { + continue; + } + let name = n.label().unwrap_or_default(); + if seen.iter().any(|(s, y)| *s == name && (*y - b.y0 as f32).abs() < 1.0) && !name.is_empty() { + break; + } + seen.push((name, b.y0 as f32)); + } + assert!(seen.len() > 3, "{right:?}: Tab reaches the panel: {seen:?}"); + for w in seen.windows(2) { + assert!(w[1].1 >= w[0].1 - 30.0, "{right:?}: Tab jumps up from {:?} to {:?}\n{seen:?}", w[0], w[1]); + } + } +} diff --git a/docs/architecture.md b/docs/architecture.md index c50066a10..7d1b7a3ae 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -1,6 +1,6 @@ # Architecture -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (created from the code; the full design notes stay in the local `plan/architecture.md`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (`cargo xtask ci` reports the machine and skipped tests; `testkit`) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft is built today. The longer design document (`plan/architecture.md`) is local-only (`plan/` is gitignored); this page is the committed, current summary. Sizes are Rust lines counted @@ -19,7 +19,7 @@ L5 depends on egui, eframe, winit or rfd, and that each crate only uses lower la | L3 | `catalog` (11.0k), `gpu` (5.1k), `preview` (1.3k), `merge` (3.8k), `segment` (2.3k) | Library model and persistence; wgpu compute twin of the pipeline; thumbnail/preview cache and job pool; HDR and panorama merge; SAM 3 segmentation | | L4 | `engine` (53.2k) | Session, command registry (everything is a command), history, render and export jobs, import, Lightroom catalog and preset import, faces index, view-models | | L5 | `ui-egui` (46.9k), `mcp` (2.3k) | The Lightroom-style egui interface (swappable); the MCP server | -| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (2.7k) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling | +| L6 | `apps/lightcraft` (2.8k), `apps/lightcraft-cli` (1.5k), `apps/lightcraft-web` (3.4k), `xtask` (3.4k), `testkit` (0.2k, dev-dependency only) | Desktop app (eframe + wgpu, native menus, control server), CLI (render, commands, calibrate, snapshot, MCP), WASM build, tooling; test helpers (skips the CI report lists) | ## Data model @@ -63,8 +63,19 @@ dispatch by id, and headless snapshots render the UI without a window. `assets` (every asset attributed, no Adobe formats) and the WASM build. Other workflows build and test FreeBSD and Windows ARM64, lint packaging, and build releases. +`ci` also probes the machine: OS, CPUs, RAM, the GPU adapters of each backend (from the ignored +`gpu_probe` test of `lightcraft-gpu`, run after the workspace tests so it reuses their build; xtask +has no wgpu), the corpus and `CRAFT_FONTS_DIR`. Tests +that need what is missing call `lightcraft_testkit::skip!(need, reason)`, which appends a JSON line to +the file `LIGHTCRAFT_SKIP_LOG` names. The run ends with the machine and the skips by need and reason, +and writes `target/ci/report.json` (commit, dirty flag, machine, capabilities, each step's result and +time, skips) and `target/ci/summary.md` (Markdown for a PR; `--summary-md` prints it). The report holds +no host name, user name or absolute path, and nothing is uploaded. `--hardware` adds a step that runs +the `lightcraft-gpu` tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`). + ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-11 | minor | Quality gates: the CI machine probe, visible skips (`testkit`) and `target/ci/report.json` | | 2026-10-10 | major | Created from the code (crate sizes, layers, data model, rendering, control, gates) | diff --git a/docs/arw6-compression.md b/docs/arw6-compression.md index 3b65c1d37..334b651f1 100644 --- a/docs/arw6-compression.md +++ b/docs/arw6-compression.md @@ -171,4 +171,22 @@ Raw decode only, 4 CPUs, release build, best of 3 wall-clock (2026-10-10). Metho | ILCE-7RM6 APS-C, Compressed (HQ) | 29.7 MB | 6592x4372 | 0.35 s | | ILCE-7RM6 APS-C, Compressed | 30.9 MB | 6592x4372 | 0.34 s | -The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components. +The 1.0 s budget for the full-frame file is met. Parallelised: rayon over the tiles, over the 13 streams of each tile and over its 3 components, and the colour reconstruction over rows. + +2026-10-12 performance pass (output unchanged, bit for bit): the vertical wavelet pass lifts whole rows instead of gathering each column, `0^n 1` codes are counted with one `leading_zeros`, and the colour reconstruction reads rows as slices, in parallel. Same method, best of 5, the previous and the new decoder run alternately on the same machine (4 CPUs, which was busier than on 2026-10-10), best of two rounds: + +| File | Before | After | +|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.56 s | 0.46 s | +| ILCE-7RM6 FF, Compressed | 0.54 s | 0.38 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | +| ILCE-7RM6 APS-C, Compressed | 0.35 s | 0.24 s | + +2026-10-13 (output unchanged, bit for bit): the two halves of each stream, which are independent bit streams, decode in parallel; the vertical wavelet pass runs its rows in parallel; the horizontal pass lifts each row in place (scratch space reused per thread), rows in parallel, instead of allocating two vectors per row. Same method, best of 3, the previous and the new decoder run alternately, on another machine (Ryzen 7 9800X3D, a 4-CPU cgroup quota shared with other builds); CPU is the process CPU time of the decode (all threads): + +| File | Before | After | CPU before | CPU after | +|---|---|---|---|---| +| ILCE-7RM6 FF, Compressed (HQ) | 0.60 s | 0.48 s | 1.14 s | 0.87 s | +| ILCE-7RM6 FF, Compressed | 0.62 s | 0.48 s | 1.14 s | 0.89 s | +| ILCE-7RM6 APS-C, Compressed (HQ) | 0.34 s | 0.23 s | 0.50 s | 0.40 s | +| ILCE-7RM6 APS-C, Compressed | 0.31 s | 0.24 s | 0.51 s | 0.42 s | diff --git a/docs/background-tasks.md b/docs/background-tasks.md index 02336c97c..8ef8789d8 100644 --- a/docs/background-tasks.md +++ b/docs/background-tasks.md @@ -33,7 +33,7 @@ guard.detail(&file_name); dead job never leaves a stuck bar. A worker that only reports progress takes a `TaskHandle` (`guard.handle()`, `Clone + Send`). - **`kind`** is a stable camelCase id that agents filter by (`export`, `import`, `scan`, `sync`, `previews`, - `download`, `faces`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it + `download`, `faces`, `cache`, `focus`…). **`label`** is a short English name in sentence case ("Building previews"). The stack shows it translated, so add it to every catalog in `crates/ui-egui/locales/`. - **Progress:** `progress(done, total)`. A `total` of 0 means the amount of work isn't known yet, and the bar sweeps. `set_unit(Unit::Bytes)` makes the count read "12 of 340 MB"; `Unit::Percent` reads "40 %". The default `Unit::Count` @@ -55,10 +55,12 @@ guard.detail(&file_name); |---|---|---| | Export, contact sheet | `crates/ui-egui/src/export_task.rs` | adopt the job's flag; update the row each frame | | Build Previews | `crates/engine/src/cmd/previews.rs` | move the guard into the worker thread | +| Slideshow export | `crates/engine/src/cmd/slideshow.rs`, `crates/engine/src/slideshow_export.rs` | an engine command that owns its worker and row; progress from the worker through a `TaskHandle`; results read back by command | | Model downloads | `crates/engine/src/face_download.rs` | report progress through a `TaskHandle` in shared state | | Lightroom catalog import | `crates/ui-egui/src/lightroom_import.rs` | stop being cancellable for a phase that must finish | | Face scan, AI Denoise | `crates/engine/src/cmd/face_recognize.rs`, `crates/engine/src/denoise.rs` | a continuous job with no ✕, kept in session state | | Find Missing Photos | `crates/ui-egui/src/tasks.rs` | a generic background task with a row (`kind: Some(..)`) | +| Reading focus points | `crates/engine/src/focus_scan.rs` | a worker paced by the UI's per-frame pump (`focus.pump {pace}`); `Cancel::Flag`; commits in batches without undo steps | ## Testing diff --git a/docs/camera-preview-colour.md b/docs/camera-preview-colour.md index 1210e6b54..463ccaed7 100644 --- a/docs/camera-preview-colour.md +++ b/docs/camera-preview-colour.md @@ -1,6 +1,6 @@ -# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW and Olympus ORF starting look +# Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3/CR2, Pentax PEF, Samsung SRW, Olympus ORF and Epson ERF starting look -ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW and ORF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. +ARW, NEF, RW2, RAF, CR3, CR2, PEF, SRW, ORF and ERF decoding supply sensor data and camera white-balance multipliers, but currently no measured camera colour matrix. Treating camera RGB as linear sRGB produces a dark, muted default rendering. The loader now estimates a file-local starting look from that file's embedded camera JPEG, only when a usable camera matrix is absent. All output pixels still come from the RAW sensor data; there is no JPEG replacement or uniform saturation boost. ## Colour and tone are separate @@ -17,7 +17,9 @@ Matrix-only and matrix + table looks are each completed (tone curve, then chroma The matrix is applied to scene-linear sensor pixels. The curve is carried in `SourceInfo` and applied in the pipeline finish stage, after exposure/WB/local edits. It is never baked into decoded luminance or clipped to JPEG precision. CPU and GPU use the same tone LUT. Contrast, whites and blacks adjust the starting look through the display-domain controls. JPEG neutral rendering and the existing DNG colour model are unchanged. -One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. +One third of the pixels are held out from both fits. The combined model must reduce linear-display squared error by at least 30% and have per-channel RMS error ≤0.10 (0.055 rejected half of the public raw.pixls.us samples tried — ILCE-6400, -6700, -7M4 — whose fits were still 1.5–3× closer to the camera JPEG than the fallback and visibly better). When the fit on all pixels fails these gates, it is tried once more, with the same gates, on the pixels away from edges (where the 3×3 neighbourhood in either proxy spans a luminance ratio above 3). Many camera JPEGs are lens-corrected while the raw is not (Sony "Distortion Comp.: Auto", compacts and kit zooms), and on a detailed scene the misaligned edges alone fail the gate although the colours agree everywhere else (issue #232). Sony's embedded lens tables now correct ILCE-7RM6 renders under Enable Lens Corrections ([Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md)), but this fit still matches its pairs without them (the sensor proxy is uncorrected); aligning the pairs first is a follow-up. The public ILCE-7RM2 samples (raw.pixls.us, one façade shot in seven compression/correction variants, 24–70 mm at 51 mm) are up to 15 px of 1440 off their JPEG; the six with distortion correction on missed the gate at held-out RMS 0.108–0.115 and opened in the grey neutral fallback, 13 L* too dark (ΔE76 16.6–16.9 against the JPEG after registering its geometry). Away from edges they pass at 0.049–0.052 and land at ΔE 3.7–3.8 with lightness within 0.6 L*, like the variant shot with distortion correction off (0.059 on all pixels, ΔE 3.9). The corpus GX80 kit-zoom sample (up to 63 px of 1440 off) passes too: 0.107 → 0.081, ΔE 19.1 → 7.9. Fits accepted on all pixels are unchanged. These are provisional acceptance gates, not a colour-accuracy certification. Singular, unrelated and aspect-mismatched references retain the documented fallback; poor-fitting ones get the partial look below when it is clearly closer to the camera JPEG, and black-and-white ones only their tone curve (below). Both fits use the same fixed proxy at every decode resolution. + +**Lens-corrected camera JPEGs.** Some raws carry the camera's own lens correction (Fujifilm and Panasonic tables, the ILCE-7RM4A, Nikon's NikonNEFInfo) and the camera often applies it to its JPEG. Nikon Z lenses always do, and that JPEG can be zoomed in by up to 9% to fill the frame. The fit used to pair the uncorrected sensor with the corrected JPEG, so near the edges it compared pixels from different parts of the scene. Now the sensor proxy is also warped with the file's correction (`into_lens_geometry`, pixels that land outside the picture are dropped), and the fit uses whichever of the two lines up better with the JPEG, going by the correlation of their high-passed log luminance. Files whose JPEG isn't corrected keep the plain proxy. On the 29 CC0 corpus raws with a warp the alignment goes up by 0.13 on average (Panasonic GX80 0.41 to 0.83, Fujifilm X-T20 0.44 to 0.95). Mean ΔE against the camera JPEG over the 35 corpus raws of those formats goes from 3.73 to 3.61, and on 299 private Z 6II photos with Nikon's correction read it goes from 3.22 to 2.68. **A partial look when the full look misses the gates.** The neutral fallback (camera RGB taken as sRGB, the default tone map) is far from the camera JPEG. Rendered for every photo on this path in the CC0 archive (1,150 files with a camera JPEG), it is 10.7 L\* too dark (median; about one stop), keeps 0.36 of the camera's colourfulness and has hues 7° off on the coloured pixels (median CIEDE2000 13.6). Its white balance is right: on the pixels the camera renders near-neutral the cast is under 1 a\*/b\* (median, every maker). The "blue" fallback on newer Sony bodies (Δb\* −22 over the whole frame on the ILCE-7RM5 sample) is a warm scene losing its colour, not a cast: its near-neutral pixels are within −1.6 b\*. So before falling back, a partial look is fitted (`fit_partial`): the quantile tone curve (registration-free, the largest part of the error), the chroma curve, and, when the scene has enough colour to learn a matrix (the full look's condition), the photo's own colour matrix at half weight, the other half the identity (`PARTIAL_MATRIX_WEIGHT`). No hue/saturation table. Half weight because, on the held-out proxy pixels of the 23 colourful photos without a look, the full matrix left 2 of them more than 2° further from the camera's hues than the neutral fallback and half weight none (median hue error 9.6° with the generic colour, 5.1° at half weight, 4.2° at full weight; on the 1,080 photos with a look 3.9° and 2.7°). Without a matrix (tone and chroma curve alone, or with one global saturation scale) about a third of all photos ended up more than 2° further from the camera's hues than the fallback, as the added colour makes the generic matrix's hue errors visible. The partial look is tried on all pixels, then away from edges, and used only when it cuts the held-out squared error below 0.7 of the neutral fallback's in linear and in gamma-encoded display values, and when the JPEG's luminance ranks the pixels like the scene's (Spearman ≥ 0.5, `PARTIAL_MIN_RANK_CORRELATION`): a curve matched to the brightness of an unrelated picture also beats the dark fallback pixel for pixel. The accepted full looks have 0.55 or more; three old Coolpix files whose raws decode wrongly have 0.07–0.27 and keep the fallback. There is no limit on the partial look's own error. On the archive (1,783 renders): all 1,087 full looks are byte-identical; of the 55 photos that reach the partial fit, 49 get it (40 on all pixels, 9 away from edges; 13 with the damped matrix, 41 with a chroma curve) and 6 keep the fallback (lens-corrected compacts: RX10, TZ80 1:1, D-LUX 4, G10 16:9, GF2 ×2). On the 49, median CIEDE2000 to the camera JPEG 14.0 → 2.1, ΔL\* −11.5 → +0.6, colourfulness 0.36 → 0.87, hue error 10.1° → 7.1°; every one is closer, by at least 1.3. Most of the others without a look never reach a fit: on 48 files (Nikon D4, Df, D300/D300S, D7000, D5100 and others) the camera JPEG proxy comes out a pixel narrower than the sensor proxy and no pairs are collected (the resize in PR #531 fixes this; with it 46 of them pass the full look's gates, CIEDE2000 17.9 → 2.9). @@ -111,7 +113,7 @@ A pooled profile was tried and is not bundled. Pooling the 46 photos (158,549 co One photo can show too little of a colour for its own fit to learn it: in a second shot of the lime shirt only a few dozen proxy pixels show it, next to a hillside of foliage at the same hue that the camera renders differently, and the shirt stayed yellow (hue 68° against the JPEG's 82°; a 192-pixel proxy only reached 74°). `lightcraft-cli calibrate [--max N] [--out DIR] FOLDERS…` therefore pools the colour pairs of many ARWs, NEFs or RAFs per camera model — each against its own embedded JPEG, at a 192-pixel proxy, at most 4000 pairs per photo, files spread evenly over the folders — and fits one matrix (from white-balanced camera RGB, so no single photo's white point is baked in) and one hue/saturation/value table. Models need at least 5 usable photos. -Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos (below); X-H2S, 201 photos; X-T4, 545 photos. They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. +Profiles are JSON files `<model>.json` in `$LIGHTCRAFT_CAMERA_PROFILES`, else `<config>/camera-profiles` (macOS `~/Library/Application Support/LightCraft/camera-profiles`); a local profile replaces a built-in one. Built-in profiles live in `assets/camera-profiles/` (listed in `assets/ATTRIBUTION.md`) and are compiled in, so the app, CLI and web build share them: ILCE-7M4, fitted to 597 photos (2.2 million colour pairs) shot in 2026, mostly with the Standard creative style and DRO Auto; ILCE-7CR, 125 photos, and ILCA-99M2, 144 photos (both below); X-H2S, 201 photos; X-T4, 545 photos; NIKON Z 6_2, 296 photos; NIKON Z 8, 261 photos; NIKON D5300, 264 photos (below). They hold aggregate colour statistics only. A photo of a profiled model takes its colour from the profile and fits only its own tone and chroma curves (picture styles vary per shot; with Sony's DRO on, that tone curve is then lowered as described below); the acceptance gates still apply. A rejected profile fit retries the photo's own colour and tone fit before falling back to neutral rendering. Files are read once per process and validated (version, bounded invertible matrix, table shape and finite data); a damaged file is ignored with a warning. The profiles folder's contents are part of the render cache keys, so thumbnails rendered before a profile existed are redone; smart previews built before keep their colour until rebuilt. ### Sony ILCE-7CR @@ -122,6 +124,39 @@ Without a profile, 123 of the 131 photos accept their own fit; the other 8 open The nine public raw.pixls.us ILCE-7CR files are one scene in every coding the camera writes (uncompressed, lossless compressed L, M and S, compressed, full frame and APS-C crop). They were not used for fitting. All nine accept their own fit at ΔE 1.3–2.1 and stay accepted with the profile, at ΔE 1.5–2.9 (mean 1.59 → 1.99). On this colourful daylight scene the per-file fit is already close. The subsampled M and S codings, which decode with unity white-balance gains, move most (2.0 → 2.8). This is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. +### Sony ILCA-99M2 + +Fitted from 144 usable photos of 150 ILCA-99M2 ARWs (one photographer, seven shooting days, Standard creative style, DRO Auto, ISO 100–1600, compressed ARW, no in-camera distortion correction; Zeiss 24–70 mm F2.8 and 85 mm F1.4, Tamron and Sigma lenses), 501,511 colour pairs; the other six, one dark and nearly colourless indoor series, gave no usable colour pairs. Measured as for the ILCE-7CR. + +Without a profile all 150 photos accept their own fit (ΔE 1.8–13.5). For validation every fifth photo in sorted name order (30) was withheld and a profile fitted to the other 120 (115 usable). On the withheld photos mean ΔE went from 5.11 with per-file fitting to 4.87 with the profile; 25 of 30 improve (by up to 0.8), the largest regression is 0.24. With the final profile all 150 photos stay accepted, mean ΔE 4.62 → 4.45 (in-sample). The one public raw.pixls.us ILCA-99M2 file (Standard, DRO Auto; not used for fitting) is accepted with and without the profile, ΔE 5.57 → 5.73. Seven shooting days are a narrow sample of scenes and light; this is a camera-JPEG look estimate like the others, not measured calibration or Lightroom parity. + +### Canon EOS R7 and EOS R6 Mark II + +With CR3 and CR2 input to `calibrate` (#1073), tried on one photographer's library (CR3, RAW and C-RAW, October 2025 to September 2026) and measured as above, against each file's own full-size embedded JPEG (6960 × 4640 R7, 6000 × 4000 R6 Mark II): + +- Held out: 40 photos per body from different shoots, every fifth withheld (8 per body), profiles fitted on the other 32 (30 R7 photos with 107,286 colour pairs, 28 R6 Mark II with 93,200; both with a hue/saturation table). On the withheld photos the profile changes little: R7 mean ΔE 2.52 → 2.57 (median 2.37 → 2.25; 3 better, 3 worse, 2 unchanged; largest regression +0.51 on an almost colourless photo), R6 Mark II 6.12 → 6.09 (median 5.73 → 5.50; 3 better, 4 worse, 1 unchanged; largest regression +0.48, best gain −0.67). Mean ΔL\* 0.00 (R7) and +0.57 (R6 Mark II) either way. Not bundled. +- The profile only replaces the colour inside an accepted per-photo fit (`fit_preview`), so a photo whose own fit is rejected renders the neutral fallback with or without it: 2 of the 16 withheld photos (one per body), plus one with a black-and-white camera JPEG, rendered identically both ways. Unlike the ILCE-7CR, where the profile rescued every rejected photo, here it cannot. +- Library-wide (`--max 600` over 35,796 CR3s): 410 R7 photos (1.39 million pairs), 119 R6 Mark II (386k) and 38 EOS R100 (129k) gave profiles; 32 photos had no usable camera JPEG or colour (mostly moon, night and other near-colourless scenes) and one did not decode. The R6 Mark II writes its Exif model as "Canon EOS R6m2", the profile's file name too. +- Decoding: 4 of the R6 Mark II C-RAW files tried (3 of 40 sampled, 1 of 119 in the library run) are refused with "unsupported raw variant: Canon CRX adaptive QP value 172" (or 175) and open preview-only. + +### Nikon Z 6II + +Fitted from 296 usable photos of 299 Z 6II NEFs (one photographer, 2024 to 2026, Standard and Auto picture controls, lossless 14-bit, with the NIKKOR Z 20mm f/1.8 S, Z 24-70mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ), 1,099,207 colour pairs. The pairs were collected with Nikon's in-file distortion correction read and the sensor proxy following it (see "Lens-corrected camera JPEGs" above), since the camera corrects every JPEG from these lenses. Measured as above: rendered at 1200 px with `lightcraft-cli render`, mean CIE76 ΔE against each file's embedded JPEG at 200 px. + +For validation every fifth photo in sorted name order (59) was held back and a profile fitted to the other 240 (237 usable). On the held-back photos mean ΔE went from 2.73 with per-file fitting to 2.54 with the profile (median 2.49 to 2.21), 43 closer and 8 farther, the largest by 0.64. With the final profile across all 299 photos it goes from 2.68 to 2.53, but that's in-sample. The few that get noticeably worse are ISO 25600 night shots (up to +2.5), where the camera JPEG's noise reduction changes the colour a lot and a per-photo fit does better. + +The public raw.pixls.us Z 6II file (`nef-nikon-z6ii-lossless.nef`, Vivid picture control with the Z 24-200, not used for fitting) is accepted with the profile and goes from 5.37 to 5.25. + +### Nikon Z 8 + +Fitted from 261 usable photos of 278 Z 8 NEFs (one photographer, 2023 and 2026, almost all Standard picture control, lossless 14-bit, with the NIKKOR Z 24-120mm f/4 S and the AF-S 70-200mm f/2.8G on the FTZ; the High Efficiency files can't be decoded and were skipped), 969,860 colour pairs, collected the same way as the Z 6II's with Nikon's distortion correction read. Holding back every fifth photo (55) and fitting the other four fifths, mean ΔE on the held-back photos went from 2.55 to 2.33 (median 2.31 to 2.16), 39 closer and 10 farther, the largest by 0.46. The public raw.pixls.us Z 8 file (`nef-nikon-z8-lossless.nef`, Z 14-24 f/2.8 S, not used for fitting) is accepted with the profile and stays about the same, 3.22 to 3.27. + +### Nikon D5300 + +Fitted from 264 usable photos of 273 D5300 NEFs (one photographer, 2014 to 2017, mostly Standard picture control with some Portrait and Landscape, lossy 12-bit, nearly all with the AF-S DX 35mm f/1.8G), 964,687 colour pairs. On the 53 held-back photos mean ΔE went from 2.86 to 2.66 (median 2.78 to 2.60), 44 closer and 6 farther, the largest by 0.63. There's no public D5300 sample in the corpus to check it against. + +The same was tried for the photographer's D700, D750 and D80 (about 300 photos each) and it didn't help: on held-back photos the D750 stayed at 3.2 and the D700 and D80 got slightly worse, even fitting only their Standard (or normal saturation) photos. Their per-photo fits are already close and those bodies were shot with a mix of picture controls, so they have no profile. + ## Sony Dynamic Range Optimizer (DRO) Sony bodies apply DRO, a local tone operator that brightens darker regions, to their JPEG (and the preview embedded in the ARW) only; the raw data are unchanged, and the factory default is DRO Auto. The tone curve fitted to that JPEG carried the brightening into the raw's starting look, so DRO photos opened lighter than in Lightroom, darktable, Photomator or Affinity, which develop the raw without it (issue #244). On 24 ILCE-7CR photos (Standard creative style) with Lightroom 9.5.1 default exports, mean L* at 200 px: with DRO off, Lightroom matches the camera JPEG (+0.15 L*, all within ±1.4) and LightCraft matches Lightroom (−0.19 L*, median ΔE76 3.6); with DRO Lv3 or Auto (8 photos), Lightroom is 2.4–10.7 L* darker than the camera JPEG and LightCraft followed the JPEG, +6.4 L* from Lightroom (median ΔE76 7.5). The tone curves fitted to 131 ILCE-7CR photos show where: at scene luminance 0.01, 0.02, 0.05 and 0.1 the median DRO Lv3/Auto curve is 8, 10, 9 and 6 L* above the DRO-off one, and from 0.3 up they agree. @@ -138,7 +173,11 @@ For 52 camera models LightCraft has colour matrices fitted to the camera's measu (`crates/raw/src/spectral.rs`, generated into `spectral_table.rs` by `tools/spectral_camera_table.py` from [rawtoaces-data](https://github.com/AcademySoftwareFoundation/rawtoaces-data) v1.1.0, Apache-2.0): 22 Canon, 11 Nikon, 11 Sony, 3 Fujifilm and 5 others, plus the other names rawtoaces-data lists for them (EOS Rebel T3i and Kiss X5 for -the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100). Per model the generator fits, +the EOS 600D; bodies it lists as sharing a sensor, such as the GFX100S with the GFX 100), and the bodies `SAME_SENSOR` +in `spectral.rs` adds by hand because the table is generated: Fujifilm's 26.1 MP X-Trans CMOS 4 bodies (X-T30, X-T30 II, +X-Pro3, X100V, X-S10, X-E4) take the X-T3's matrices. Without them an X-T30 raw whose camera JPEG was shot in Acros +(black and white) got its tone curve on the muted neutral fallback, with a grey sky where the scene had a deep blue +one. Per model the generator fits, under CIE Standard Illuminant A and under D65, a white-preserving 3×3 from white-balanced camera RGB to XYZ by the least mean CIE76 error over rawtoaces-data's 190 training reflectances, and writes DNG `ColorMatrix1/2` and `ForwardMatrix1/2`. The existing DNG colour model then interpolates them for the file's as-shot white, which the @@ -184,16 +223,40 @@ A merge (HDR, panorama, HDR panorama) writes a linear DNG of the sources' camera tags. A raw without colour matrices of its own has none to give, so its merge used to render with no camera colour: an HDR merge of CR2s came out green and dark (issue #965). Each source now goes through `starting_colour` as when it is opened alone, and `starting_colour_tags` writes the result as DNG tags: one forward matrix (from the look's matrix on -top of the camera transform) with a colour matrix and `AsShotNeutral` that agree with it at the as-shot white, the -look's brightness of white as baseline exposure, its hue/saturation table as `ProfileHueSatMap` and its tone curve as -`ProfileToneCurve`. Raws with their own matrices (DNG) keep their tags. Without a look the tags give the colour the raw -falls back to (spectral matrices or the neutral fallback). +top of the camera transform) with `AsShotNeutral`, the look's brightness of white as baseline exposure, its +hue/saturation table as `ProfileHueSatMap` and its tone curve as `ProfileToneCurve`. Raws with their own matrices (DNG) +keep their tags. Without a look the tags give the colour the raw falls back to (spectral matrices or the neutral +fallback). + +The colour matrices declare the white balance the source shows (`files::WbScaleOf::dng_white`), so the DNG opens at the +source's As Shot temperature and tint and a white-balance edit starts from there: the camera's measured matrices +(`lightcraft_raw::spectral`) when they give the source's scale, which reads every white as the source does; else a +matrix under which the as-shot neutral is the white the source's scale shows (Sony's presets' reading, 6500 K / 0 on +the relative scale, the file's own white otherwise). The forward matrix is used only with the as-shot neutral, so the +render at As Shot doesn't depend on them. A colour matrix derived from the forward matrix, as first written, declared +wherever the look and the generic camera model put the white: merges of real ILCE-7M3 brackets opened at 6113–7307 K / ++115 where the ARWs show 3932–5268 K / +7, and any white-balance edit turned them green (CIEDE2000 11–13 against the +ARW at the same Temp/Tint). With the measured matrices they open at the ARWs' As Shot, and the same Temp/Tint renders +within CIEDE2000 0.8–1.7 of the ARW for most changes, about 5 for the largest (5268 → 3200 K, 3932 → 6500 K): the ARW +adapts its fitted look with Bradford, the DNG rebalances in camera space. The DNG renders close to, not exactly like, the source: a DNG hue/saturation table doesn't keep each pixel's luminance as the fitted table does, and the chroma curve has no DNG tag, so it is left out. On two CC0 CR2s (EOS 550D, 40D), an HDR merge of three copies of the photo against the photo alone (200 px, CIE76): mean ΔE 1.07 and 0.47, 95th percentile 2.65 and 1.15 (26 and 23 before, with no colour tags; 1.47 and 0.66 with the table left out too). +A raw whose white balance is only in its vendor multipliers (CR2, NEF, ARW…) has no `AsShotNeutral` of its own, so a +frame carries the multipliers (`Frame::wb_multipliers`) next to its colour tags. A panorama, and an HDR merge from its +inputs, takes its white balance from the frames that are in it: the median R/G and the median B/G of their multipliers +(the mean of the middle two for an even count; frames without usable multipliers, or outside the panorama, do not +vote), written as `AsShotNeutral`, the reciprocal. Any `AsShotWhiteXY` of the source is dropped with it: a reader takes +it first, and the DNG spec has the two exclude each other. So the result is balanced for what the camera measured for +most of its frames, not for whichever frame came first; an HDR panorama's merged brackets vote with their bracket's +median. With no multipliers anywhere (DNG sources) the reference or first frame's tags are kept as before. For frames +balanced alike this is the neutral `starting_colour_tags` already wrote, so the merged DNG renders as before; it matters +when the camera balanced the frames differently, and for a caller that loads frames without a colour hook +(`load_frame`), whose raw frames carried no white balance at all (issue #1144). + ## Nikon crop and preview colour metadata Nikon maker-note `CropArea` (0x0045) supplies the default `[left, top, width, height]` crop. The decoder validates the rectangle against the active sensor area and falls back to that area for missing or invalid values. The CFA origin is unchanged; cropping follows demosaicing. diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 5ecf41b0d..31ecbf8c2 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -80,10 +80,21 @@ filmstrip is visible. | `ui.resize` | `{width, height}` | Resize the window | | `ui.zoomFactor` | `{factor}` | Set the interface scale (0.5–3; egui's zoom keys are off, Cmd+= / Cmd+- zoom the photo) | | `ui.screenshot` | `{path?, headless?}` | `{path, width, height}` once the frame (with finished renders) is captured. `headless: true` draws the UI on the CPU (no compositor needed); a windowed capture that gets no frame within 2 s falls back to headless automatically. While the loupe still waits for a render, `ui.widgets {filter: "loupe:rendering"}` lists its "Rendering…" pill; `loupe:renderFailed` is the pill saying a render failed behind a stand-in; Compare and Reference panes have their own (`compare:rendering:<pane>`, `compare:renderFailed:<pane>`) | -| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | +| `engine.execute {command: "app.export", params}` | export params (see `docs/mcp.md`), plus `preset`, `dir` / `path`, `ids`, `background`; `destinationMode: "sameAsOriginal"` writes each photo into its original's folder (+ `subfolder`) instead of `dir` | Writes the files and returns `{files}` (one `{path, width, height, bytes, sidecars}` per file, plus `gpuFallback` — why that render fell back from the GPU to the CPU — when it did; see `docs/gpu-pipeline.md`); with `background: true` (what the Export dialog and menus use) it returns `{background: true, total}` at once and the batch runs on a worker thread — poll `ui.inspect` → `export` | | `engine.execute {command: "activity.list"}` | — | The long-running tasks in flight (imports, exports, preview builds, downloads, the face scan…), oldest first: `{tasks: [{id, kind, label, done, total, unit, detail, cancellable, cancelling, ageMs}]}`; `total` 0 = not known yet; `unit` is `count`, `bytes` or `percent`; `label` is English (the app shows it translated) | | `engine.execute {command: "activity.cancel"}` | `{id}` or `{all: true}` | Asks the task (or every cancellable one) to stop at its next file or photo → `{cancelled: n}`; an unknown id (it may have just finished) or a task that can't be cancelled is an error | | `ui.render` | `{id?, size?, path?}` | Render a photo (PNG to `path`), `{width, height}` | +| `engine.execute {command: "slideshow.layout"}` | `{margins?, marginLeft?, …, zoomToFill?, strokeBorder?, castShadow?, backdropColor?, colorWash?, backgroundImage?, …}` (see `engine.commands`) | The slideshow's slide layout (Layout, Options, Backdrop), saved with the library; omitted keys keep their value, `{}` reads it; out-of-range numbers are clamped, a wrong type or unknown key is refused and nothing changes | +| `engine.execute {command: "slideshow.overlays"}` | `{identityPlate?: {enabled?, text?, image?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, behind?}, ratingStars?: {enabled?, color?, size?, anchor?, …}, texts?: [{text?, size?, color?, opacity?, anchor?, offsetX?, offsetY?, relativeTo?, shadow?, shadowOpacity?, shadowOffset?, shadowRadius?, shadowAngle?}], addText?, removeText?, showWatermark?, watermark?}` (see `engine.commands`) | The overlays drawn on every slide, saved with the library: sizes and offsets are fractions of the slide's shorter side, anchors `topLeft`…`bottomRight` of the `slide` or the `photo`; text overlays expand tokens (`{caption}`, `{exposure}`, `{date}`, the file-naming tokens…; unknown tags show as typed); `watermark` takes the export watermark's keys. Replies with the settings and `warnings` | +| `engine.execute {command: "slideshow.titles"}` | `{intro?: screen, ending?: screen}`, a screen being `{enabled?, color?, content?: "none"\|"identityPlate"\|"text", text?, overrideColor?, textColor?, scale?, duration?: seconds\|null}` | The intro and ending screens, saved with the library: a background colour and, centred, the Overlays identity plate or the screen's own text, in the plate's colour or `textColor` when `overrideColor` is on, `scale` = its height as a fraction of the slide's shorter side; `duration` null = the slide duration. The intro plays once before the first photo, the ending after the last when repeat is off. Replies with the settings and `warnings` | +| `engine.execute {command: "slideshow.preview"}` | `{id?, title?, template?, width?, height?, path?}` | One slide composed with the layout and overlays, as the slideshow plays it (with `template: id`, with that template's settings instead, leaving the library's alone: the Template Browser's hover preview) (with `title: "intro"\|"ending"`, that title screen instead → `{title, enabled, width, height, plate}`): a PNG written to `path`, or returned base64 in `png` without one; `{id, width, height, cell, photo, overlays}` (`cell` and `photo` are `[x0, y0, x1, y1]` in slide pixels; `overlays` lists `{kind, box}` for each drawn overlay). Sides 16–4096; one side given takes the other from the aspect preview | +| `engine.execute {command: "slideshow.templates.list"}` | `{}` | The Template Browser: `{templates: [{id, name, builtin, group, description?, path?}], previous, folder, problems}` — built-ins (`builtin:…`, our own designs) then user templates (`user:<file>`, files in `<settings>/slideshow-templates`, at most 1000); `problems` lists files skipped and why | +| `engine.execute {command: "slideshow.templates.apply"}` | `{id}` or `{name}`; `{id: "previous"}` | Replaces the playback (prepare previews kept), layout, overlays and titles settings with the template's, saved with the library. No undo: the replaced settings become `previous` for the session → `{applied, name, warnings, previous, settings}` (`warnings`: a background photo not in this library, an unreadable plate or watermark image…) | +| `engine.execute {command: "slideshow.templates.save"}` / `update` / `rename` / `delete` | `{name, replace?}` / `{id}` / `{id, name}` / `{id}` | Save the current settings as a user template (`<name>.json`; path separators and reserved characters become `_`, a taken or built-in name is refused unless `replace`), update one with the current settings, rename (its file follows), delete. Built-ins can't change | +| `engine.execute {command: "slideshow.templates.import"}` / `export` | `{path}` / `{id, path}` | Import a template `.json` (at most 256 KB; format, version and every setting checked; a taken name gets " (2)") → `{id, name, renamed, warnings}`; export any template as `.json` | +| `engine.execute {command: "slideshow.export"}` | `{format: "pdf"\|"jpeg"\|"video", path, size?, quality?, fps?, container?: "mp4"\|"webm", fullScreen?, speed?, name?, ids?, wait?}` | The slideshow (the photos in view, or `ids`, as composed slides between the intro and ending screens that are on) as a PDF (one page per slide; `size` = long side 64–8192 px, default 1920), a JPEG sequence (`path` = the folder, files `<name>-001.jpg`…) or AV1 video in MP4 / WebM (`size` = frame height 64–2160 or `"720p"` / `"1080p"`, `fps` 24 / 25 / 30 / 60; fades rendered as frames; no audio; at most 2 hours). Runs in the background → `{taskId, background, slides, seconds, total, width, height, warnings}`; its row is in `activity.list` (`kind: "export"`, label "Exporting slideshow"), `activity.cancel {id: taskId}` stops it and leaves no file (outputs are written as `.part` and renamed when complete). `wait: true` runs it in the call and replies with the result. One at a time | +| `engine.execute {command: "slideshow.exportStatus"}` | — | The last slideshow export: `{taskId, format, path, running, done, total, cancelled, result?, error?}`; `result` has `pages` (PDF), `files` (JPEG) or `frames`, `samples`, `keyFrames`, `seconds`, `fps`, `container`, `codec: "av1"`, `audio: false` (video), and `skipped` / `warnings` for photos that could not be rendered | +| `engine.execute {command: "dialog.slideshowExport"}` / `app.slideshowExport` | `{format?}` / `{format, size?, quality?, fps?, container?, fullScreen?, path?}` | View ▸ Export Slideshow…: opens the dialog; `app.slideshowExport` asks for the path with a save dialog when none is given, then starts `slideshow.export` | | `app.quit` | — | Close the app | White balance (`develop.set` with `wb.temp` / `wb.tint`, `develop.wb`, `develop.wbPick`) is in Kelvin and tint on @@ -103,6 +114,14 @@ scrolling pans in both axes and respects the operating system's scrolling direct These gestures work in Detail (including editing tools and full-screen preview), Compare and Reference views, and only apply over their image areas. Panning stops at the image edges. +The camera's focus point ([focus-points.md](focus-points.md)) is `meta.focus` in `photo.inspect`: `{rect: {x0, y0, +x1, y1}, mode?, area?, tracking?}`, normalized to the upright photo (absent when the file records none). +`view.focusPoint {on?}` shows it over the photo (no params toggles; replies `{on}`), and `view.zoomToFocus {on?}` +(on by default) makes `view.zoomToggle` zoom in centred on it, replying `{focus: [x, y]}` (the `pan` it set). +`photo.readFocusPoints {ids?, wait?}` reads the point for photos imported before it was read, from a few KB of each +file, on a background job (activity kind `focus`); `wait: true` returns the run's counts when done. The app calls +`focus.pump {pace}` every frame to drive it. + The interface theme is `view.appearance {mode?: "auto" | "light" | "dark"}` (no mode: the next one, Auto → Light → Dark → Auto, like the top bar's `icon:appearance` button) and `view.theme {theme: "charcoal" | "midnight" | "silver" | "paper"}`, which picks a theme and fixes the mode to its family. diff --git a/docs/cr3.md b/docs/cr3.md index 831d6017d..79babaea4 100644 --- a/docs/cr3.md +++ b/docs/cr3.md @@ -32,10 +32,48 @@ are the public prose descriptions and patents cited in embedded-JPEG fallback and its visible warning when a JPEG is present. So does any other CR3 decode error (a corrupt file, or a body whose CRX stream the decoder misreads): every CR3 opened from its embedded JPEG before this decoder, -and only four bodies are verified, so a CR3 never fails to import or load +and not every body's variants are known, so a CR3 never fails to import or load where a preview exists. HEVC-only preview tracks are recognized but are not decoded. +## Variant status + +The supported and rejected CRX coding variants. Every rejection is an explicit `RawError::Unsupported` whose +text is part of the contract: `crates/raw/tests/cr3_variants.rs` rebuilds each variant as a synthetic CR3 +fixture and asserts the exact string, and that header probing (`probe_info`, the import path) and full +decoding fail with the same error, because the engine shows that reason when it falls back to the embedded +preview. The same tests decode a control fixture at both edges of each accepted range, so a rejection can +only come from the gate named here. + +| Variant | Status | Gate | Exact rejection | +|---|---|---|---| +| Version `0x100`, four Bayer planes, encoding 0, wavelet levels 0 (lossless) or 3 (C-RAW, horizontal tiles) | Supported | `supports()` in `vendor/crx.rs` | none | +| Version `0x200`: single-tile 14-bit three-level C-RAW with adaptive quantization, QP map of any height (an odd height leaves the last QP row without a partner and it is used on its own), QP values 131–175 (169–175: EOS R6 Mark II) | Supported | `supports()`, `qp_map()` | none | +| Version other than `0x100`/`0x200`, or wavelet levels other than 0 and 3 | Rejected | `supports()` | `Canon CRX version {version:#x}, encoding {encoding}, {planes} planes, {levels} wavelet levels` | +| Bit depth outside 8–16 (the `CMP1` parser only accepts 1–16, so 1–7 is what can arrive) | Rejected | `supports()` | `Canon CRX {bits}-bit samples` | +| C-RAW with vertical tiles (tile height below the image height, three levels) | Rejected | `supports()` | `Canon CRX C-RAW with vertical tiles` | +| Version `0x200` that is not a single-tile 14-bit C-RAW | Rejected | `supports()` | `Canon CRX version 0x200 requires a single-tile 14-bit Bayer C-RAW image` | +| Extended `CMP1` header with a median bit depth (used by type-3 burst coding) | Rejected | `supports()` | `Canon CRX extended median-bit-depth header` | +| Rounded-plane coding or unexpected plane flags | Rejected | `validate_planes()` | `Canon CRX plane coding flags` | +| Partial-subband coding | Rejected | `validate_planes()` | `Canon CRX partial subband coding` | +| Adaptive quantization gain on a low-frequency band (bands 0–3) | Rejected | `validate_planes()` | `Canon CRX adaptive quantization subband parameters` | +| Uniform quantization value outside 4–44 | Rejected | `quant_step()`, called from `validate_planes()` | `Canon CRX quantization value {value}` | +| Adaptive QP value outside 131–175 | Rejected | `qp_map()` | `Canon CRX adaptive QP value {value}` | +| `ff11`/`ff12`/`ff13` markers in a `0x100` stream | Rejected | `layout()` | `Canon CRX ff11/ff12/ff13 marker family` | +| Marker header of the wrong length (8 bytes expected for `ff01`, `ff02`, `ff03` and `ff12`, 16 for `ff11` and `ff13`) | Rejected | `layout()` | `Canon CRX marker {marker:#x} header length {size}` | +| `0x200` tile header with a wrong magic, a nonzero reserved field, an empty QP map or QP padding above 7 | Rejected | `layout()` | `Canon CRX adaptive quantization tile header` | +| `0x200` band header with a nonzero reserved field | Rejected | `layout()` | `Canon CRX adaptive subband reserved field` | +| Container adapter: a track that is not four planes with encoding 0 | Rejected | `decode()` in `vendor/cr3.rs` | `Canon CR3 encoding {encoding} with {planes} planes` | + +`supports()` also rejects encoding other than 0, a plane count other than 4 and a CFA pattern above 3, but +through a CR3 container the adapter rejects the first two before the CRX layer is reached, and the `CMP1` +parser rejects the third for four-plane tracks. A descriptor the parser cannot accept at all (a zero +dimension, a tile larger than the image, a bit depth outside 1–16, a plane count outside 1–4 or a zero +header size) is reported as `Corrupt` (`CR3 raw track has an invalid CMP1 descriptor`), not as unsupported. +A gain on a low-frequency band cannot reach `adaptive_step()`, which has its own rejection +(`Canon CRX adaptive low-frequency quantization`): `validate_planes()` refuses it first. That gate is defence +in depth and is not tested separately. + ## Verified sensor decoding CRX version `0x100`, four Bayer planes and `ff01`/`ff02`/`ff03` marker headers @@ -46,7 +84,7 @@ its adaptive quantization map. C-RAW is quantized, so next to clipped highlights its reconstruction can overshoot the sensor range by a few quantization steps (up to 36 codes above 16383 in 17 CC0 files); those samples are clamped to the CMP1 bit depth. A lossless sample outside the bit depth is still corrupt data. -Other unverified coding variants return explicit unsupported errors. Header probing validates the same coding variants and +Other unverified coding variants return explicit unsupported errors (the table above lists them all). Header probing validates the same coding variants and tile/plane layout as full decoding. `crates/raw/tests/cr3_corpus.rs` checks every full-sensor sample against a @@ -92,8 +130,9 @@ files are reported as skipped by the tests. The version `0x200` QP predictor and entropy framing were checked independently. Controlled input mutations establish floor averaging of paired QP rows before the nonlinear integer table, then multiplication, division by eight, addition -of the subband base and a minimum step of one. QP values 131–167 are verified; -unmeasured values are rejected. +of the subband base and a minimum step of one. QP values 131–167 are verified; 168 (quantization +value 44, step 102, measured at about 100 on EOS R50 C-RAW files) continues the sequence and is +accepted. Other values are rejected. The QP map is coded as follows (established on the EOS R5 Mark II). Its first symbol is a 32-bit big-endian literal, mapped like every other symbol and added to a prediction of zero. Every later @@ -132,11 +171,40 @@ and 6400), whose developed images match visually and whose sensor means agree within 0.1% at ISO 6400. Files with even-height maps never reach this rule, so the six verified fixtures are unchanged. Partial-subband and rounded-plane coding, vertical C-RAW tiling and adaptive low-frequency gains -remain unsupported. The decoder bounds image dimensions, sample count, +remain unsupported (see the variant table above; `cr3_variants.rs` pins each rejection). The decoder bounds image dimensions, sample count, tile count, marker headers and temporary allocations. The full sensor buffer is allocated only after the first plane decodes, and plane buffers grow row by row, so a small crafted file fails before reserving memory for the image it claims. +## Further bodies: one CC0 file each + +All 110 CR3 files on raw.pixls.us (2026-10-12, every one CC0, 31 folders) decode from sensor data; none +falls back to the preview, so none of them uses a CRX variant the decoder refuses (vertical C-RAW tiles, +partial-subband or rounded-plane coding, multi-tile 0x200, adaptive low-frequency gains) or a CR3 +sRAW / mRAW. HEIF stills are separate `.HIF` files, not CR3. One file per body is pinned in +`canon_cr3_bodies_decode_like_the_camera_jpeg` (`crates/raw/tests/cr3_corpus.rs`; URLs and SHA-256 in +`RAW_SAMPLES`, `xtask/src/main.rs`): EOS-1D X Mark III, 90D, 250D, 850D, M200, M50 Mark II, M6 Mark II, +R, R3, R5, R6, R6 Mark II, R6 Mark III, R7, R10, RP, R50, R50 V and PowerShot G5 X Mark II, G7 X Mark III, +SX70 HS and V1, C-RAW where the body has a CC0 C-RAW sample. No external decoder was run: the sensor +hashes pin LightCraft's own decode, and the check is black-box against the camera: + +- the green sites of the active area, on a 24 × 16 grid, follow the camera's own embedded JPEG + (gamma-encoded Pearson r 0.93–0.99; 0.83 for the M6 Mark II and 0.71 for the G7 X Mark III, whose + JPEGs are strongly distortion-corrected; a misread stream gives r near 0); +- each render was inspected at 100 % for tile seams, band or quantiser artefacts (none); +- where raw.pixls.us has RAW and C-RAW shots of one scene, their black-subtracted sensor means correlate + on 16 × 16 blocks at 0.99–0.995 (R, R6, 250D, R50, G5 X Mark II, R5 crop mode) and 0.97 (RP); the + two R5 full-frame shots correlate at only 0.83, though each follows its own camera JPEG at r 0.98, so + the shots differ, not the decodes. The shots differ in exposure, so the means are not compared. + +The EOS R6 Mark III (`ColorData` version 66) reads its as-shot white balance and black level as described at +the top of this page; its white level comes from the data. + +Not every C-RAW file of a verified body decodes: the decoder accepts adaptive QP values 131–175 only. The 3 of +40 EOS R6 Mark II C-RAW files with QP 172 or 175 a user reported (they opened as their preview with "Canon CRX +adaptive QP value 172") now decode (#1694). EOS R7 C-RAW (the odd-height QP map above) has only been checked on six paired RAW / +C-RAW captures; the pinned R7 file is lossless, so R7 C-RAW still has no black-box reference. + ## White level The white level is Canon's specular white from `ColorData` (the word after the diff --git a/docs/denoise.md b/docs/denoise.md index 926b1bee7..7c3f5138f 100644 --- a/docs/denoise.md +++ b/docs/denoise.md @@ -77,7 +77,7 @@ pool. A scalar implementation checks the optimized operators on synthetic networ Tract is not a workspace dependency or a product feature. CPU inference and the GPU model module are feature-gated; ordinary engine/web builds do not compile the faer/GEMM backend. The engine depends on the inference crate only through `denoise = ["dep:lightcraft-denoise", …]`; -lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 100 megapixels; at most +lightweight `lightcraft-denoise-core` metadata and cache formats remain available without inference. Pictures are capped at 110 megapixels; at most four tile calls run together, chosen within the process-wide working-memory budget. One idle CPU workspace is retained, and cache costs reserve room for the blended picture. diff --git a/docs/faces.md b/docs/faces.md index 62309f337..07909a18f 100644 --- a/docs/faces.md +++ b/docs/faces.md @@ -143,6 +143,43 @@ SFace's builtin speed metadata now uses the measured 6.9× ratio. User `catalog. Older published tables for MobileFaceNet/R50/R100 came from a different runtime and are not measurements of this runner; architecture names alone do not guarantee the same speed, and those additional model files were not verified in this task. +### On the GPU + +On a machine with a usable GPU, the whole detector and recogniser run on it through wgpu (`crates/gpu/src/faces.rs`). +The interpreter lowers the graph to a flat program for the input's shape (`crates/faces/src/net/program.rs`): each +convolution (`wgsl/faces_conv.wgsl`: an implicit matrix product that gathers the input straight into workgroup memory, +cuts K into slices when a map is small, and a direct kernel for depthwise layers) and every other operator (activations, +batch norm, PRelu, broadcasting add/sub/mul, max pooling, nearest upscaling, transposes, global pooling and the fully +connected layer, `wgsl/faces_ops.wgsl`) is a kernel, every value stays on the device between layers in reused +buffers, and a run is one upload, one submission and one read-back. Shape-only operators make no step. A model that +needs something the program cannot express runs its convolutions one by one on the GPU instead. Several faces of one +photo go together (`Embedder::embed_batch`, up to 16 per submission). + +The CPU interpreter remains the reference. After a model loads, a background check (a row in the activity stack, "Checking +the GPU for faces") runs it once on the CPU and twice through the GPU on a fixed test input: the GPU is used only when +every output is within 1e-3 of the CPU's (relative to the output's largest value) **and** it was faster; until then, +and whenever the GPU is switched off (`LIGHTCRAFT_GPU=0`, `LIGHTCRAFT_GPU_BACKEND=off`, the GPU preference, or **Settings ▸ +Faces ▸ Run face models on the CPU only**), missing, wrong or slower, the model runs on the CPU. The first GPU error, +or a result of the wrong size or with values that are not finite, drops it for that model. Settings ▸ Faces shows +which device each model runs on (`faces.gpu`). The weights stay on a device of the scan's own (separate from the +editor's) while the model is loaded, at most 768 MB per model. + +The background scan's pace also limits the GPU: paused or light, one model run at a time and the GPU busy at most a +quarter of the time (the editor renders on the same GPU); normal, two at once and three quarters; full, as fast as it +goes. + +Measured on Linux with a Radeon (Vulkan) and a 32-thread CPU, one call at a time, machine under very heavy load from +other jobs (load average 100–250, so the absolute numbers are noisy): + +| Model | CPU | GPU, one layer at a time | GPU, whole network | Batch of 16, per face | +| --- | ---: | ---: | ---: | ---: | +| YuNet (640², one photo) | 58–60 ms | 85–89 ms | 21 ms | – | +| SFace 2021dec | 40–42 ms | 47–51 ms | 9.6 ms | 8.1 ms | +| AuraFace v1 | 295–308 ms | 165–172 ms | 70–73 ms | 71 ms | + +`LC_FACE_MODELS=<folder> cargo test --release -p lightcraft-gpu faces::tests -- --include-ignored --nocapture` checks +agreement on the real models and every operator, and prints these timings. + ### Agreement and regression coverage The public-crop embeddings match the reference runtime's outputs: @@ -268,6 +305,13 @@ To name a group: click faces to select them (Shift-click selects a range, **Sele name in the bar that appears (people already named complete as you type), press Enter: all of them are named at once, as one undo step. Selecting names nothing. +Right-click a person's card, or use **Rename Person…** on their page, to rename every face of that person in the library +at once (`faces.renamePerson`). The name is matched the way the cards are grouped (trimmed, ignoring capitalization) and +every matching face is stored with the exact new spelling, so a change of capitalization alone updates them all. The +dialog says how many faces that is. If the new name is already someone else, it refuses until **Combine with the existing +person** is ticked; that makes one person, and Undo separates them again. Cancelling changes nothing. A person filter, +the open person page and the way back to it follow the new name. Nothing is written back to the files. + The thumbnail-size slider in the bottom bar sizes the faces here too, with limits of their own. Every face is shown by the same kind of box: the detector's own box once the scan has looked at it (kept beside its embedding), so a loosely drawn box from another tool does not make one face look farther away than the next; before the scan has looked at a face it is shown @@ -278,6 +322,11 @@ by its own box. In **People**, a click on a person opens their page: **only cropped faces**, never whole photos. First the faces named with their name (a click opens that photo), then **More**: the unnamed faces that look like them, most alike first. Click one to confirm it (that names the face, as one undo step, and it moves up into their faces), or × to hide it for this session. +The `↑` control at the top left of a suggested face confirms that face and every currently shown, non-hidden suggestion +with an equal or higher similarity score. Its tooltip names the person and shows the number of faces before you act. +Hover a suggestion or focus it with the keyboard and press `A` for the same action. The batch is one undo step; +suggestions below that score, previously confirmed faces and hidden suggestions stay as they were. Nothing is named +without this explicit action. **Show photos** puts their photos in the grid; **‹ People** or Escape goes back. "More" only holds faces the scan has already embedded, so it fills in as the scan goes; it shows faces scoring above four fifths of the model's suggestion bar, since you look at each one before anything is named. @@ -327,7 +376,7 @@ reuses the pictures of its step instead of rendering every face again. All of this is reachable from the control channel, the CLI and MCP: `faces.models.list`, `faces.models.inspect {path}`, `faces.models.install {path, acknowledged: true, activate?}`, `faces.models.download {id, acknowledged: true}` (then `faces.models.downloads`, which also installs what has arrived, and `faces.models.downloadCancel {id}`), `faces.models.remove {id}`, `faces.models.select {id}`, -`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. +`faces.enable {enabled?}`, `faces.detect {ids?, apply?}`, `faces.index {budgetMs?, ids?}`, `faces.pump` (what the app calls every frame), `faces.suggest {ids?, threshold?, margin?}`, `faces.person {name, more?}` (a person's faces and the unnamed faces that look like them), `faces.unnamed {limit?}` (every unnamed face, look-alikes together, with suggested names), `faces.setName {id?, index, name}`, `faces.nameFaces {faces: [{photo, index}], name}` (name many at once, one undo step), `faces.renamePerson {from, to, merge?}` (rename every in-library face of one person, or merge two when `merge` is true, one undo step) and `faces.evaluate`. `acknowledged` must be `true`: the caller has shown the user the terms and the user agreed. Installing makes the model the one in use and switches recognition on unless `activate` is `false`. ## Find Blemishes diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index d4bfb8fa9..e8b86a670 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -1,6 +1,6 @@ # File format parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (lossy DNG export; Render to DNG as an export format) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 Every format Lightroom reads or writes, with our support, fidelity and tests. Camera raw formats are deep enough to have their own document: [raw-parity.md](raw-parity.md). @@ -15,7 +15,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | Format | Target reads | Target writes | LightCraft reads | LightCraft writes | Notes / tests | |---|---|---|---|---|---| -| JPEG | ✅ | ✅ | ✅ (parallel decoder, ICC, EXIF orientation) | ✅ quality, size limit, ICC, metadata | `crates/codecs/src/jpeg.rs`, export tests | +| JPEG | ✅ | ✅ | ✅ (parallel decoder, ICC, EXIF orientation) | ✅ quality, size limit, ICC, metadata; Huffman tables optimized per image (issue #1373) | `crates/codecs/src/jpeg.rs`, `crates/codecs/src/jpeg_par.rs`, export tests | | TIFF | ✅ 8/16/32-bit | ✅ 8/16-bit, LZW/ZIP | ✅ 1–64-bit integer, 16–64-bit float, bilevel, CCITT fax, WhiteIsZero | ✅ 8/16-bit, 32-bit float linear, none / LZW / ZIP | `crates/tiff`, `crates/codecs/src/tiff_codec.rs` | | PNG | ✅ | ✅ | ✅ | ✅ | | | PSD / PSB | ✅ (maximized-compatibility composite) | ✅ (Classic: PSD export, edit-in round trip) | ✅ merged composite | ⬜ (external-editor round trip writes 16-bit TIFF) | `crates/codecs/src/psd.rs` | @@ -24,7 +24,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | JPEG XL | ✅ | ✅ | ✅ (`jxl-oxide`) incl. DNG 1.7 JXL tiles | ✅ lossy / lossless, 8/16-bit, PQ HDR (`jixel`, native only; ⬜ web) | `crates/engine/src/tests_export.rs` | | WebP | not in its document types | ⬜ | ✅ | ✅ | beyond target | | GIF, BMP | ⬜ | ⬜ | ✅ | ⬜ | beyond target | -| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed or LJ92; ⬜ lossy DNG, embedded original | `crates/raw/src/dngwrite.rs` | +| DNG (from raw) | ✅ | ✅ incl. lossy, embedded original | ✅ | ✅ uncompressed, LJ92, Deflate or lossy (JPEG tiles, optional long edge); ⬜ embedded original | `crates/raw/src/dngwrite.rs` | | Render to DNG (15.5) | — | ✅ | — | 🟡 export format "Rendered DNG" (linear float DNG, edit baked in); not yet added to the catalog | `crates/raw/src/dngwrite.rs` (`write_rendered_dng`) | | Original + sidecar | — | ✅ | — | ✅ | | | HDR gain map JPEG (ISO 21496-1) | ✅ | ✅ | ✅ (gain map read) | ✅ | `crates/codecs/src/gainmap.rs` | @@ -63,7 +63,7 @@ import and export formats. Ours come from `crates/codecs/src/sniff.rs`, `crates/ | Part | Weight | Ready for real work | Kind | |---|---:|---:|---| -| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, PSD write, lossy DNG) | +| Standard image import / export | 35% | ~85% | estimated (table above; missing: AVIF read, Render to DNG in the catalog) | | Sidecars, metadata, presets | 25% | ~85% | estimated | | Lightroom catalog migration | 25% | ~50% | estimated (one-way, approximate rendering of unsupported settings) | | HDR, print, video output | 15% | ~35% | estimated | @@ -78,3 +78,5 @@ Remaining: **26–47 h** (AVIF decode 8–15 h if a permissive decoder is writte | 2026-10-10 | major | Created from the code and the installed Lightroom's document types | | 2026-10-10 | minor | JPEG XL export (#247): JXL write ✅ (native only), PQ JPEG XL HDR | | 2026-10-12 | minor | Render to DNG: "Rendered DNG" export format (linear 16-bit float DNG of the rendered photo) | +| 2026-10-12 | minor | Lossy DNG export (JPEG tiles, `dngCompression: "lossy"`); PSD write had already landed (#953), so it leaves the remaining hours | +| 2026-10-13 | patch | JPEG write (#1373): Huffman tables made for each image, 12–14 % smaller files at the same pixels; statuses unchanged | diff --git a/docs/focus-points.md b/docs/focus-points.md new file mode 100644 index 000000000..61c562b03 --- /dev/null +++ b/docs/focus-points.md @@ -0,0 +1,119 @@ +# Focus points + +Most cameras record where they focused. LightCraft reads that point from the file, draws it over the photo and +zooms to it, so checking whether the subject is sharp takes one key while culling. Lightroom shows no focus points +(photographers add plug-ins for it); this goes beyond parity (tracker row `LR-VIEW-FOCUSPOINT`). + +## Using it + +- **View ▸ Show Focus Point** (⌥F; `view.focusPoint {on?}`) draws the camera's AF frame as green corner brackets in + Detail, Compare and Survey. It follows Rotate, flips and the crop; a point cropped away is not drawn. Off by + default; the choice is saved with the app's settings. +- **View ▸ Zoom to Focus Point** (`view.zoomToFocus {on?}`, on by default): Toggle Zoom (Z or Space) zooms in + centred on the focus point instead of the last position. A click on the photo still zooms where you click, and a + photo without a focus point zooms as before. On by default because it changes nothing for photos without a point, + and for those with one the focus point is what a 1:1 check is for. In Compare the two panes share one pan, so Z + centres on the active photo's point; per-pane focus zoom is a follow-up. +- **Info panel**: a *Focus* row says how the camera focused, e.g. `AF-C · Wide · Animal Eye Tracking` (focus mode, + the AF area set on the camera, and what it tracked when the camera says). +- **Agents**: `photo.inspect` (and the MCP `lightcraft://photo/active` resource) returns it as `meta.focus`: + `{rect: {x0, y0, x1, y1}, mode?, area?, tracking?}`, the box normalized to the upright (EXIF-oriented) photo, y down. + Photos without one have no `focus` key. `view.zoomToggle` replies `{focus: [x, y]}` (the pan it centred on) when it + zoomed to one. + +The point is read when a photo is imported. Photos imported earlier get it in the background (below). It is +read-only camera data: never written to XMP, and absent from the catalog JSON when unknown, so older catalogs read +unchanged (no catalog format change). + +## Photos imported earlier + +A library imported before focus points were read gets them by itself: when a library opens and some of its photos +from a supported body have no point and haven't been looked at, LightCraft reads them in the background, with a +**Reading focus points** row in the activity stack (n of N; ✕ stops it). **Photo ▸ Read Focus Points** +(`photo.readFocusPoints`) starts the same job by hand. Photo ▸ Reload from Disk still fills a missing point too, but it +re-reads and hashes whole files and runs while you wait, so it is for a few photos, not a library. + +- **What is opened**: only photos whose stored camera is a supported body (`SONY ILCE-1`, `SONY ILCE-7SM3`), whose + file is an ARW or JPEG, that are in the library and have no point yet. Nothing else is opened. +- **What is read**: the few kilobytes that hold the focus data, not the file + (`lightcraft_meta::focus::read_header`). It walks the file's structure as the decoder does — the TIFF header and + IFD0 (Make, Model), the Exif IFD (the MakerNote entry), the maker note's IFD and the values of the focus tags; in a + JPEG, the segments up to the Exif one — asking only for the ranges the bytes read so far point at, and decodes them + with the same code as a whole-file read. On the α1 and the A7S III everything sits in the first 16 KB, so one 32 KB + read per photo does it (instead of 50–120 MB). Rounds (12), bytes (2 MB) and the file size are bounded; a file that + wants more is treated as having no point. On 109 real α1 / A7S III ARWs (83 with a point) the header read gave the + same point as the whole-file read for every file. +- **Pace**: one file at a time on one worker thread (the drive may be a spinning disk), in path order so neighbouring + files are read together. Nothing new is read while you drag, type or scroll, and it rests briefly between files + while you are around, so your own previews come first. Measured on a 5400 rpm USB drive, cold cache, 200 α1 / A7S III + ARWs: 1.9 s (about 100 photos a second, 32 KB each), so a library of 58,796 raws takes about ten minutes at full + pace instead of re-reading 2.25 TB. +- **Saving**: what was read is committed every 200 photos (or every 3 seconds) as LightCraft's own bookkeeping: no + undo step, no sidecar written, and only `meta.focus` changes, on a photo that still has none and still points at the + file that was read (a photo deleted, moved or relinked meanwhile is left for another run). Photos that turned out to + have no point (manual focus, a centre-only Wide report) are listed in `focus-scanned.bin` beside the library, so they + are not read again at every launch. The list is headed by the decoder's tag (the bodies it knows): when a body is + added, the list starts afresh and those photos are looked at again. A file that can't be read (an offline drive, a + file gone or replaced during the read) is not listed, and is tried again the next time the library opens. +- **Resuming**: quitting mid-run keeps what was committed; the next launch reads the rest. +- **Agents**: `photo.readFocusPoints {ids?, wait?}` — without `ids`, every photo not looked at yet; with `ids`, those + photos, looked at again even if listed. `wait: true` returns when done with `{last: {total, done, found, none, + unavailable, bytesRead, seconds, cancelled}}` (`lightcraft-cli run --library DIR photo.readFocusPoints wait=true`). + The app's frame loop calls `focus.pump {pace}`, which also starts the job once per library opening. + +## Cameras + +Sony first, on bodies whose files have been checked: **ILCE-1 (α1)** and **ILCE-7SM3 (A7S III)** +(`lightcraft_meta::focus::SONY_MODELS`). Other Sony bodies store the same tags, but a body is added only after its +files are checked, so an unchecked one shows nothing rather than a misleading box. Fujifilm, Canon, Nikon, OM System +and Panasonic record similar data and are later steps. + +### Sony layout and evidence + +Tag identities and value names come from the public ExifTool tag-name documentation (Sony MakerNotes table). No +third-party code was read or ported. Every field was confirmed on real files by comparing LightCraft's decoder with +ExifTool's output, run as a black box: 333 ARWs sampled across four years of a photographer's library (180 α1, +153 A7S III; 12 portrait or upside-down, 54 in APS-C crop or reduced-size modes), with no mismatch in the point, frame or names. + +| MakerNote tag | Layout | Notes | +|---|---|---| +| `0x2027` FocusLocation | 4 × int16u: frame width, frame height, focus x, focus y | in the sensor's landscape frame (a portrait shot still says 8640 × 5760); the frame is the image size (5616 × 3744 in α1 APS-C mode) | +| `0x2037` FocusFrameSize | 6 bytes: int16u width, int16u height, then 2 bytes that are zero when no frame was shown | zero in manual focus; one AF-C α1 file had no frame (a small box is drawn: 3 % of the short side) | +| `0x201b` FocusMode | int8u: 0 Manual, 2 AF-S, 3 AF-C, 4 AF-A, 6 DMF, 7 AF-D | | +| `0x201c` AFAreaModeSetting | int8u: 0 Wide, 1 Center, 3 Flexible Spot, 11 Zone, 12 Expanded Flexible Spot, 13 Custom AF Area | | +| `0x2021` AFTracking | int8u: 0 Off, 1 Face tracking, 2 Lock On AF | | +| `0x9402` bytes 0x16 / 0x17 | enciphered: focus mode (low 7 bits) and the AF area mode used (0 Multi, 2 Spot, 3 Flexible Spot, 11 Zone, 14 Tracking, 15 Face Tracking, 20 Animal Eye Tracking, 21 Human Eye Tracking, 255 Manual…) | read only when its focus mode equals `0x201b`'s | + +The documentation says the `0x94xx` blocks use "a simple substitution cipher". On the files each byte `p < 249` is +stored as `p³ mod 249` (bytes 249–255 unchanged): ExifTool's decoded AFAreaMode 20 is stored as `0x20`, a decoded +`0x83` as `0x77`. Cubing is a bijection modulo 249 (= 3 × 83), so the inverse is a table lookup. + +On the α1 files the box sits on the subject: drawn on the embedded previews, it lands on the bird's eye in eye-AF +shots and follows spot, zone, wide and tracking shots, in portrait and landscape. + +### When there is no real point + +Nothing is stored (so nothing is drawn and Z zooms as before) when: + +- the body is not one of the checked ones, the tags are missing or malformed, the frame size is zero, or the centre + lies outside its frame; +- **manual focus** (`0x201b` = 0, or the block's area mode is Manual). The camera still records where the AF frame was + parked: 83 of the 91 sampled manual-focus files carry an off-centre location that says nothing about focus; +- the location is the **exact frame centre while the area used was Multi** (Wide with nothing tracked; without the + block, Wide with tracking off). The wide area reports the centre when it picked no particular spot; 7 of the 34 + sampled autofocus Multi files did. A centred Spot, Zone or Flexible Spot frame is a choice the photographer made, and + is kept (49 of the 111 sampled spot and zone files were centred, the camera's default position). + +DMF (AF, then manual touch-up) keeps its point. + +## Code + +- `crates/meta/src/focus.rs`: the decoder (bounded; hostile values give no point, never a panic) and its tests, + including an opt-in test over real files (`LIGHTCRAFT_FOCUS_SAMPLES=<photo folder>`). +- `crates/meta/src/focus_read.rs`: the header-only read; its tests check it against the whole-file read on intact, + truncated and corrupted files and count what it reads. +- `crates/engine/src/focus_scan.rs`: the background job (`photo.readFocusPoints`, `focus.pump`) and + `crates/engine/src/tests_focus_scan.rs`, with an opt-in comparison over real files + (`LIGHTCRAFT_FOCUS_FILES=<a list of paths>`); `crates/ui-egui/src/focus_scan.rs` drives it from the frame loop. +- `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `focus_pan`, `draw_focus_point`): mapping through the user's + rotation and crop, the overlay, and the zoom target; `crates/ui-egui/src/tests_focus_point.rs`. diff --git a/docs/gaps.md b/docs/gaps.md index c54a7baee..b108fb094 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Gaps: where LightCraft falls short of Lightroom -> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-14 · **Change:** minor (gap 4 follow-up: diagnostic overlays get zoom windows); previously minor (gap 4 closed: soft proofing gets zoom windows; gap 8: Nikon Z 6II / Z 8 / Z f in-file distortion; gaps 10, 22 and 23 listed once again: merges had left two copies of each, and `cargo xtask parity` now fails on a gap listed twice) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-11 · **Last updated:** 2026-10-15 · **Change:** minor (gap 15: Print Page Options — page numbers, page info, crop marks; gap 4 follow-up: diagnostic overlays get zoom windows, soft proofing on the GPU) Every known shortfall, one entry each, ranked by user impact (how many Lightroom users hit it, and how hard it blocks them), then by effort. **Agents pick work from the top** (the first entry nobody is @@ -15,41 +15,42 @@ Hours are Opus 5.5 agent wall-clock hours (see [target-app-parity.md](target-app | # | Gap | Evidence | User impact | Hours | Doc | |---:|---|---|---|---:|---| -| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 4 bundled profiles or 52 spectral matrices; others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | -| 2 B | **CR3 beyond four verified bodies** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview; CR3 sRAW / mRAW and HEIF CR3 too. Verified: M50 / R100 / R8 / R5 Mark II. EOS R7 C-RAW (odd-height QP map) decodes since a45c2b66 and R6 Mark II C-RAW is reported working on 0.6.0, but neither is verified yet (no corpus test or recorded check) | measured: Canon is the largest bucket (33% of photographers) and its share whose camera fully works is the lowest of the big four, 73%; 18 of 22 popular Canon bodies write CR3, 4 verified (raw-parity.md); R7 rule checked only on paired RAW / C-RAW captures ([cr3.md](cr3.md)) | +6.2 points of photographers whose camera works, the biggest single raw gain | 25–45 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | +| 1 B | **Camera colour is estimated, not measured** (LR-PROF-CAMERACOLOR). Raws start from a per-file fit to the camera JPEG, 5 bundled profiles or 52 spectral matrices (plus same-sensor bodies, e.g. the X-T30 through the X-T3); others use a neutral matrix (a black-and-white camera JPEG gives its tone curve only, #577; dark JPEG tones also guide the toe, #1307) | ΔE vs camera JPEG 2.9–10 after fitting (raw-parity.md); 0 chart-calibrated models vs 1,500 Adobe Standard profiles | every raw shooter sees colour that is close but not Lightroom's; some get neutral-matrix colour | 50–90 | [raw-parity](raw-parity.md#colour) | +| 2 B | **CR3 variants no public file uses** (LR-IMP-FORMATS): `crx.rs` refuses vertical-tile C-RAW, partial-subband and other plane coding, multi-tile 0x200 and adaptive low-frequency gains, and those files fall back to the preview. All 110 CC0 CR3s on raw.pixls.us (31 folders) decode from sensor data, so none of these variants has a public sample; 26 CR3 bodies are verified (M50 / R100 / R8 exact, R5 Mark II pinned, 22 more pinned on one CC0 file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)). HEIF stills are `.HIF` files, not CR3; no CR3 sRAW / mRAW sample exists | measured: Canon's share whose camera fully works 73% → 94% (raw-parity.md); 18 of 22 popular Canon bodies write CR3, all 18 now verified | +0.3 points of photographers; the variants surface only as user files that open as a preview | 10–25 | [raw-parity](raw-parity.md#remaining-work-ranked-by-photographers-unlocked-per-hour) | | 3 B | **Render fidelity unmeasured** (LR-BEHAV-RENDER-FIDELITY): tone, highlights, texture / clarity / dehaze, NR and sharpening are tuned by eye | no side-by-side suite; a retune needs a new process version (only V1 exists) | edits imported from Lightroom look different; the look "isn't Lightroom's" | 30–60 | [raw-parity](raw-parity.md), [process-versions](process-versions.md) | | 4 | ~~Original-pixel detail at 100%~~ (LR-VIEW-ZOOM): closed 2026-10-12, see Closed recently | checklist row ✅ | — | 0 | [ui-parity](ui-parity.md) | | 5 B | **AI masks: Subject, Sky, People, Background, Landscape** are heuristics; Object / Describe run SAM 3 but its download has no mirror configured | `crates/segment`; ai-masks.md; MASK rows 67% | masking is how most Lightroom users edit today | 40–70 + model decision | [target-app-parity](target-app-parity.md#ai) | | 6 B | **Compressed ORF** (every Olympus / OM body since ~2008) | measured: 66 of 78 Olympus / OM models preview-only; `orf.rs` refuses; OM bucket 3% of photographers, 3% of them fully work | +2.6 points; best gain per hour of any raw gap (~0.2) | 8–15 | [raw-parity](raw-parity.md#compressed-orf-a-clean-room-starting-point) (the two Olympus patents that describe the method) | | 7 | **Nikon High Efficiency NEF** (HE / HE★ on Z 8, Z 9, Z 6III, Z f, Z50II, Z5II when set to HE) | named and preview only (#193); needs a pure-Rust JPEG XS decoder | +1.1 points (only shots taken in HE; lossless works on the same bodies); low gain per hour (~0.04), so not a beta blocker | 20–40 | [raw-parity](raw-parity.md) | -| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A distortion and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | +| 8 B | **No lens profile database** (LR-EDIT-OPTICS-PROFILE): only DNG opcodes, RW2 in-file distortion, Sony ILCE-7RM4A and ILCE-7RM6 distortion (`docs/sony-ilce7rm6-lens-corrections.md`) and Nikon Z 6II / Z 8 / Z f distortion (`docs/nikon-lens-corrections.md`) | Adobe ships 3,635 lens profiles; we never use them | distortion / vignetting correction is a default step for many | 40–80 + calibration shots | [raw-parity](raw-parity.md#lens-profiles) | | 9 B | **Tests don't run in CI on macOS, Windows or Linux x64**: only FreeBSD runs `cargo test`; the others build packages | `.github/workflows/` (freebsd, packaging-lint, release, windows-arm64); the linux-riscv64 release job added in 0.6.0 (#844) runs only `lightcraft-cli --version` under QEMU | regressions reach users on the main platforms; `cargo xtask ci` runs only locally | 6–12 | [target-app-parity](target-app-parity.md#by-dimension) | -| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 236 of 1,446 Adobe models verified (16%), ~42% of photographers on a verified body; Nikon 5 bodies verified, Canon 12, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | +| 10 B | **Per-model verification thin, plus cheap per-body decode fixes**: 545 of 1,446 Adobe models verified (38%), ~85% of photographers on a verified body; Nikon 5 bodies verified, Canon 34, Leica 14 (the Q3 by a recorded check of 38 private DNGs). Cheap fixes: Z 6II packed 14-bit NEF (#769, PR #671), Sony A100 / F828 regression (#702), DSLR-A200…A390 / A850 12-bit ARW (#535) | measured: [raw/coverage.tsv](raw/coverage.tsv) | per-body bugs (levels, crops, WB) surface only from users | 25–45 | [raw-parity](raw-parity.md#by-maker-measured) | | 11 | **Enhance: Raw Details, Super Resolution; AI denoise without shippable weights or X-Trans** | ENH rows 0%; denoise Bayer only, RawNIND weights GPL and opt-in | Fujifilm users get no AI denoise; nobody gets it out of the box | 30–60 + model decision | [raw-parity](raw-parity.md#demosaic-levels-crops-and-lens-metadata) | | 12 | **Content-aware / generative remove, detect distractions, reflections removal** | REM rows 75%; patch synthesis missing | cleanup work goes back to Photoshop | 25–45 (+ generative model) | [target-app-parity](target-app-parity.md#ai) | -| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | +| 13 | **Lightroom catalog migration is one-way and approximate**: unsupported Adobe profiles / AI settings archived, history kept as data; offline record counts and destination are now shown in a lasting import result | lightroom-catalog-import.md | switching users lose some looks | 8–12 | [file-format-parity](file-format-parity.md) | | 14 | **HDR display** (EDR / Windows HDR); the headroom limit and SDR rendition from `crs` XMP (the HDR switch, `HDREditMode`, is read). HLG AVIF export done; the display's SDR/HDR decision is tested, blocked on eframe's 8-bit surface | HDR rows 70%; LR-VIEW-HDR-DISPLAY ⬜ | HDR editing works blind on HDR monitors | 12–25 | [hardware-parity](hardware-parity.md) | -| 15 | **Print module**: custom packages, printer colour management (contact-sheet PDF only) | LRC-PRINT rows | Classic users who print | 25–40 | [target-app-parity](target-app-parity.md#features) | +| 15 | **Print module**: identity plate / watermark / photo info overlays, Cut Guides for the packages, Custom Package Auto Layout, printer-driver Page Setup and direct printing, JPEG file output (the Print dialog writes a colour-managed PDF) | LRC-PRINT rows | Classic users who print | 15–25 | [target-app-parity](target-app-parity.md#features) | | 16 | **Map view, Book, Slideshow module, publish services** | LRC-MAP / BOOK / SS rows ⬜ | Classic users; publish services matter for Flickr / SmugMug workflows | 60–100 | [target-app-parity](target-app-parity.md#features) | | 17 | **Tethered capture** | ⬜ | studio photographers | 25–45 + cameras | [hardware-parity](hardware-parity.md) | | 18 | **Video**: playback, trim, global edits, export | VID rows 0% | users who mix stills and clips | 25–45 | [target-app-parity](target-app-parity.md#features) | | 19 | **Plug-in ecosystem**: no Lightroom SDK equivalent (export / publish plug-ins, metadata plug-ins) | ⬜ | pro workflows built on plug-ins (LR/Enfuse, Jeffrey's exporters, Negative Lab Pro) | 40–80 + owner decision | [target-app-parity](target-app-parity.md#by-dimension) | | 20 | **Long-tail raw formats**: compressed SRW, ARQ pixel shift, IIQ, Hasselblad compression, X3F, CRW, MRW, ERF, KDC, DCR, MEF, GPR, old FinePix | raw-parity.md | small user share each | 45–85 | [raw-parity](raw-parity.md) | -| 21 | **Localization**: 9 of 15 shipped languages partial (74–99% of catalog messages), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 55–90 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | -| 23 | **Accessibility**: AccessKit tree audited by a test (every control named, sliders with range); left: canvas handles, spot and mask placement by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 4–8 | [ui-parity](ui-parity.md) | -| 22 | **Pen pressure**, OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | +| 21 | **Localization**: 9 of 15 shipped languages partial (de, es, fr, ja, uk now 97–99% of catalog messages, awaiting native review; pt-BR, ru, zh-hans, zh-hant 74–92%), lower-layer errors English; Hindi, Arabic, Indonesian, Korean, Vietnamese absent; no RTL or complex shaping | measured from catalogs | non-English users see mixed English | 40–70 (shipped to full); 145–255 (five new) | [localization-parity](localization-parity.md) | +| 23 | **Accessibility**: AccessKit tree audited by a test; canvas handles, spots and mask pins by keyboard; left: brush and red-eye by keyboard, a session with a real screen reader | LR-BEHAV-ACCESS 🟡 | blind and low-vision users | 2–4 | [ui-parity](ui-parity.md) | +| 22 | **Pen pressure on macOS / Linux tablets** (brush size and flow follow pressure where winit reports a pen force), OS monitor profiles on Windows (Linux reads colord's, macOS the screen's), follow-window colour on Linux / Windows | hardware-parity.md | brush users with tablets; multi-monitor setups | 11–22 | [hardware-parity](hardware-parity.md) | | 24 | **Brush live preview and masking feel** (#517, #518); Feather / Edge mask refinement (15.5) | issues | masking feels slower than Lightroom | 8–12 | [ui-parity](ui-parity.md) | -| 25 | **File writes**: lossy DNG; AVIF import; Render to DNG as a catalog command (the export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | -| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | +| 25 | **File writes**: AVIF import; Render to DNG as a catalog command (lossy DNG and the Render to DNG export format landed) (PSD export landed with Edit in PhotoCraft, #953) | file-format-parity.md | interchange edge cases | 19–34 | [file-format-parity](file-format-parity.md) | +| 26 | **Performance at scale**: 85k-photo library opens in 1.7–4.7 s; 100k grid test pending; colour NR at half resolution; at 100k photos stack and album lookups are indexed (an "In a Stack" or "Album" smart album counts in ~14 ms, was 0.6–2.7 s) | ROADMAP log, gpu-pipeline.md | large-catalog users | 15–30 | [target-app-parity](target-app-parity.md#by-dimension) | | 27 | **Windows / Linux runtime coverage**: Windows installer UI unverified on Windows (#79); GPU path proven on Apple and on one AMD GPU on Linux (Radeon 8060S, Vulkan); NVIDIA, Intel and DX12 unverified; the Linux riscv64 tarball (new in 0.6.0, #844) is cross-compiled and never run beyond a QEMU smoke test | ROADMAP, issues, `release.yml` | non-Mac users find bugs first | 13–27 | [hardware-parity](hardware-parity.md) | -| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5–F8) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | +| 29 | **HDR merge leftovers** (LR-MERGE-HDR): AI Denoise doesn't run on merged (linear) DNGs; JPEG brackets are treated as linear (no camera response curve); every frame is held as 32-bit float, ~0.86 GB per 67 MP frame (half-float frames would halve it) | tested on Sony A7R VI brackets 2026-10-14 ([merge.md](merge.md)); Lightroom's Denoise supports HDR DNGs | HDR shooters who denoise their merges, merge JPEGs, or merge 7+ frames of high-resolution raws on 16 GB machines | 10–20 (+ denoise linear-RGB contract, gap 11) | [merge](merge.md) | +| 28 | **Small Classic shortcuts and keys**: rating `[` `]`, flag cycling, filter-bar keys, F1 help, panel keys (Tab, F5, F8; F6 and F7 toggle the filmstrip and the left panel) | [ui-parity](ui-parity.md#shortcuts) | Classic muscle memory | 4–8 | [ui-parity](ui-parity.md) | Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform work they need around them, **~340–600 h** to beta (incl. 40–80 h for the ~92 open core-path user issues) (see [target-app-parity.md](target-app-parity.md#stage)). ## Gaps by kind -- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19 +- **Feature:** 5, 8, 11, 12, 13, 15, 16, 18, 19, 29 - **UI / UX:** 4, 23, 24, 28 - **File format:** 2, 6, 7, 10, 13, 20, 25 (raw ones in [raw-parity.md](raw-parity.md)) - **Hardware:** 14, 17, 22, 27 @@ -61,6 +62,8 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w - 2026-10-12: original-pixel detail at 100% (gap 4, LR-VIEW-ZOOM, #323): soft proofing gets zoom windows like Detail, Before/After, Compare, Reference and mask overlays; 1:1 tested equal to the full-size export. +- 2026-10-11: Sony ILCA-99M2 profile. +- 2026-10-11: Nikon edge bars on bodies without CropArea (#976, #914): 36 model / size / depth groups measured. - 2026-10-10: Nikon lossy-after-split NEF (#633), ORF 12.8 bpp (#651), HDR gain map / PQ AVIF / float TIFF export (#646), activity stack (#345), JPEG XL export (#247). - 2026-10-09: EOS R7 C-RAW with an odd-height QP map (a45c2b66; part of gap 2), HEIC in release builds (#609), Samsung uncompressed SRW (#459), contact-sheet PDF (#563), @@ -74,17 +77,40 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | Date | Change | Summary | |---|---|---| +| 2026-10-18 | patch | Gap 1: six X-Trans CMOS 4 Fujifilm bodies (X-T30 …) take the X-T3's spectral matrices instead of the neutral matrix | | 2026-10-14 | minor | Gap 4 follow-up: the diagnostic overlays (Visualize Spots, Point Color range, HDR range) render zoom windows too; soft proofing runs on the GPU | +| 2026-10-15 | minor | Gap 15 shrinks: the Page panel's Page Options (Page Numbers, Page Info, Crop Marks) print on Single Image and Contact Sheet pages; identity plate, watermark, photo info, Cut Guides, direct printing and JPEG output remain | +| 2026-10-14 | minor | Gap 26: at 100k photos stack and album lookups are indexed, so an "In a Stack" or "Album" smart album counts in ~14 ms (was 0.6–2.7 s); open time unchanged | +| 2026-10-14 | minor | Gap 10: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277) | +| 2026-10-14 | minor | Gap 21: de, es, fr, ja and uk catalogs filled (97–99%); 55–90 h → 40–70 h | +| 2026-10-14 | minor | Gap 10: 17 uncompressed Olympus and Samsung models verified on one CC0 file each (254 models) | +| 2026-10-14 | minor | Gap 29 added: the HDR merge, tested on real 67 MP brackets, keeps moving clipped highlights and merges in batches now; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain | | 2026-10-14 | minor | Gap 4 closed: soft proofing at 1:1 renders a window (mask overlays already did); 1:1 pixels tested equal to export; only diagnostic overlays (Visualize Spots, Point Color range, HDR range) stay whole-frame | | 2026-10-13 | patch | Gaps 10, 22 and 23 each had two rows (two branches edited them; the merges kept both): the older copies are removed, and `cargo xtask parity` (in `ci`) now fails when a gap number is listed twice | +| 2026-10-13 | minor | Closed recently: Sony ILCA-99M2 profile | +| 2026-10-14 | minor | Gap 10: 21 Sony and Leica models verified on one CC0 file each (258 models) | +| 2026-10-12 | minor | Gap 20 shrinks: Mamiya MEF decodes (`vendor/mef.rs`) and is imported; gap 10 count 240 → 241 | +| 2026-10-12 | minor | Gap 20 shrinks: Epson ERF decodes (`vendor/erf.rs`), 3 bodies verified; gap 10 count 237 → 240 | +| 2026-10-13 | minor | Gap 28: F6 (filmstrip) and F7 (left panel) are bound, so the entry no longer lists them as missing; hours unchanged | | 2026-10-11 | minor | Gap 10 shrinks: Leica Q3 verified by a recorded check of 38 private DNGs (236 models verified; Leica 14); gap not closed | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | +| 2026-10-12 | minor | Gap 23 (accessibility): crop handles, spots, gradient handles and mask pins by keyboard; 4–8 h → 2–4 h; gap not closed | | 2026-10-12 | minor | Gap 23 (accessibility): the AccessKit tree audit and its fixes landed; what is left is canvas placement by keyboard and a real screen-reader session; 10–20 h → 4–8 h; gap not closed | | 2026-10-12 | minor | Gap 22: Linux previews use the display profile from colord | | 2026-10-12 | minor | Gap 27: the CPU ↔ GPU equivalence suite passes on an AMD Radeon 8060S (Vulkan, Linux), the first non-Apple GPU verified; 15–30 h → 13–27 h; gap not closed | | 2026-10-12 | minor | Gap 25: Render to DNG lands as an export format; lossy DNG and AVIF import remain | | 2026-10-12 | minor | Gap 10 shrinks: the Hasselblad Lunar (a Sony NEX-7 ARW under its own Make) decodes and is verified, 236 models (#1275) | | 2026-10-12 | minor | Gap 14: HLG AVIF export (`hdrTransfer`) and a tested SDR/HDR display decision | +| 2026-10-12 | minor | Gap 15: printer colour management lands (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening, 16-bit ICC-tagged PDF with an output intent); page overlays and direct printing remain | +| 2026-10-12 | minor | Gap 15 shrinks: File → Print… is a Print dialog with the four layout styles, cell editing, image settings, guides, page setup and templates; printer colour management, page overlays and direct printing remain | +| 2026-10-12 | minor | Gap 15: the Print module's layout styles, cells, image settings, templates and PDF are `print.*` commands (CLI, control, MCP); the Print view, printer colour management and page overlays remain | +| 2026-10-12 | minor | Gap 15: print pages compose in the engine (fit / Zoom to Fill, Rotate to Fit, border, stroke, background) with an interim PDF writer; commands and UI still to land | +| 2026-10-12 | minor | Gap 15: the Print layout model (paper sizes, margins, grid, package cells, Auto Layout packer, snapping) is in the engine; commands, composition and UI still to land | +| 2026-10-12 | minor | Gap 25: lossy DNG export (DNG compression 34892) lands; AVIF import and Render to DNG as a catalog command remain | +| 2026-10-12 | minor | Gap 22: pen pressure scales brush size and flow (Brush ▸ Pen Pressure) where winit reports a pen force | +| 2026-10-12 | minor | Gaps 2 and 10 shrink: 22 more CR3 bodies verified on one CC0 file each (every CC0 CR3 on raw.pixls.us decodes), EOS R6 Mark III white balance read; Canon 34 verified, 259 models, ~59% on a verified body; CR3 gain +6.2 → +0.3 points | +| 2026-10-12 | minor | Gap 10 shrinks: 117 Nikon and Fujifilm bodies verified on one CC0 file each (354 models, ~58% on a verified body); COOLPIX 5700 / 8400 / 8800 decode wrongly; #702 is fixed | +| 2026-10-11 | minor | Nikon edge bars on bodies without CropArea fixed for 36 model / size / depth groups (#976, #914; survey in #1053); gap 10 notes the survey and the pending bodies; gap not closed | | 2026-10-11 | minor | Reviewed against main after 0.6.0: CR3 entry names the CRX variants `crx.rs` still refuses and the R7 / R6 Mark II C-RAW state (decoding, not verified, so the measured numbers don't move); CI and Windows / Linux entries note the riscv64 release build (#844); R7 fix added to Closed recently. Panel docking (UI.9, #869) closes no listed gap | | 2026-10-11 | minor | Camera colour entry: raws with a black-and-white camera JPEG take its tone curve (#577); gap not closed | | 2026-10-11 | minor | Gap 4: Compare and Reference views get zoom windows per pane (1:1 was a 2,560 px render magnified); overlays and soft proofing remain, 8–15 h → 4–8 h | @@ -95,3 +121,9 @@ Beta-blocking gaps (B) total **~230–430 h**; with the stability and platform w | 2026-10-10 | minor | Beta total raised to 340–600 h to include the ~92 open core-path user issues | | 2026-10-10 | major | Created from the checklist's Top gaps (whose items 1–2 had been duplicated by a merge) and ROADMAP's Where we stand; re-ranked; added evidence, impact, hours and beta flags; added platform-CI, lens-database, HE NEF and localization gaps | | 2026-10-13 | minor | Gap 8 narrows: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (`docs/nikon-lens-corrections.md`) | +| 2026-10-12 | minor | Catalog migration reports offline records as additions, keeps the destination/result readable, skips missing-original probes and stages only masters needed for virtual copies. Approximate rendering and database/table limits remain. | +| 2026-10-13 | minor | Catalog import review: offline Local records join All Photos, redundant operation/settings copies removed, and result invalidation across library switches covered. Approximate rendering and database/table limits remain. | +| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | +| 2026-10-10 | minor | Gap 8: Sony ILCE-7RM6 in-file distortion added next to the ILCE-7RM4A's, its lateral CA parsed but off by default (docs/sony-ilce7rm6-lens-corrections.md); the gap stays open (no lens database), hours unchanged | +| 2026-10-14 | minor | Gap 10 shrinks: 97 Canon CR2, Pentax and Ricoh models verified on one CC0 file each (334 models, ~50% on a verified body) | +| 2026-10-14 | minor | Gap 10: 515 of 1,446 models verified (36%), ~84% of photographers on a verified body (integration batch 36) | diff --git a/docs/gpu-pipeline.md b/docs/gpu-pipeline.md index fd8f41715..92fdf46f2 100644 --- a/docs/gpu-pipeline.md +++ b/docs/gpu-pipeline.md @@ -105,6 +105,14 @@ rendering; `set VK_LOADER_DRIVERS_DISABLE=*igvk64*` (Vulkan loader) hides a spec from every program started with it. Help ▸ System Info and `app.gpu` show the adapter and backend in use (e.g. `Intel(R) UHD Graphics 630 (Dx12)`). +**LightCraft stops during an export or a render; the log says "device lost" (Windows, issue #1337).** +When the graphics driver resets the GPU (the Windows watchdog, a driver hang: DXGI `0x887A0005`, "GPU +device instance has been suspended"), renders fall back to the CPU, but the window's renderer (egui-wgpu) +panics on the lost device and LightCraft stops. It says so ("LightCraft stopped unexpectedly", not "could +not open its window") and records it in `gpu-lost.marker` in the settings folder: the next start turns +*Use the GPU for rendering* off and says why once. Turn it back on in Settings ▸ Performance after a +driver update. + **Window never appears, "not responding" (Windows, NVIDIA; issue #374).** With *Shader Cache Size* set to **Disabled** in NVIDIA Control Panel (Manage 3D settings → Global Settings), NVIDIA's driver walks the whole system drive while the window's GPU device is created (`D3D12CreateDevice` → diff --git a/docs/hardware-parity.md b/docs/hardware-parity.md index 8e3a96b8f..a6c63b816 100644 --- a/docs/hardware-parity.md +++ b/docs/hardware-parity.md @@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (pen pressure for brush masks; monitor profile from colord on Linux); previously major (created from the code, gpu-pipeline.md and the parity checklist) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 What Lightroom does with the machine's hardware, per platform, and what LightCraft does. Sources: [gpu-pipeline.md](gpu-pipeline.md), [display-profiles.md](display-profiles.md), [denoise.md](denoise.md), @@ -14,7 +14,7 @@ What Lightroom does with the machine's hardware, per platform, and what LightCra | HDR (EDR) display | ✅ (macOS EDR, Windows HDR) | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ (LR-VIEW-HDR-DISPLAY) | | Monitor profile | automatic per display, follows the window | ✅ the screen's profile, followed across screens | 🟡 picked by hand (WCS needs FFI) | 🟡 colord's profile (one display, or all sharing one); no follow across monitors | — | 🟡 Windows OS profile not read; Linux per-monitor follow missing | | Second window / second monitor | ✅ (Classic) | ✅ (⌘F11) | ✅ | ✅ | — | ✅ (Classic secondary-window keys missing) | -| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ | ⬜ | ⬜ | ⬜ | ⬜ | +| Pen pressure / tilt (brush masks) | ✅ pressure for size / flow | ⬜ winit reports no tablet pressure on macOS | 🟡 pen force from winit touch events (untested on a tablet) | ⬜ winit has no X11 / Wayland tablet input | 🟡 pointer-event force where winit passes it | 🟡 brush size and flow follow pen pressure wherever egui gets a touch force (Brush ▸ Pen Pressure); no tilt; macOS / Linux tablets wait on winit | | Trackpad gestures | pinch zoom, pan | ✅ pinch, two-finger pan | 🟡 (winit) | 🟡 | 🟡 | ✅ on macOS | | Card readers / cameras as import sources | ✅ (devices panel, eject after import) | ✅ mounted volumes with DCIM | ✅ | ✅ | — | 🟡 no eject-after-import check, no MTP / PTP phones | | Tethered capture | ✅ Classic (Canon, Nikon, Sony, Leica) | ⬜ | ⬜ | ⬜ | — | ⬜ | @@ -38,7 +38,7 @@ NVIDIA, Intel and Windows DX12 still rely on user reports. |---|---:|---| | HDR display (EDR on macOS, Windows HDR, Linux where possible) | 10–20 | an HDR display to verify (human) | | OS monitor profile: Windows WCS (an FFI helper crate, like `crates/sysmem`), follow the window across displays on Linux | 4–8 | multi-monitor setup | -| Pen pressure for brush masks (winit tablet events or a pure-Rust tablet layer) | 6–12 | a tablet | +| Pen pressure for brush masks: done where winit reports a touch force; macOS and Linux tablets need winit tablet events (or a pure-Rust tablet layer) | 3–8 | a tablet | | Remaining stages on GPU (defringe, spot removal, masks) | 10–20 | | | Tethered capture (PTP over USB in pure Rust, Canon / Nikon / Sony first) | 25–45 | cameras (human) | | Printing with colour management | 15–25 | printers (human) | @@ -51,4 +51,5 @@ NVIDIA, Intel and Windows DX12 still rely on user reports. |---|---|---| | 2026-10-12 | minor | Monitor profile: Linux reads the display profile from colord; macOS row reflects #787 | | 2026-10-12 | minor | CPU ↔ GPU equivalence suite run on an AMD Radeon 8060S (Vulkan, RADV, Linux): 16 tests / 135 cases pass, max 2 LSB, mean ≤ 0.0003; first GPU verified outside Apple | +| 2026-10-12 | minor | Pen pressure: strokes record per-point pressure from egui touch force, scaling dab size and flow; per-platform status | | 2026-10-10 | major | Created | diff --git a/docs/library-shortcuts.md b/docs/library-shortcuts.md index ca3100fc8..73322b2f0 100644 --- a/docs/library-shortcuts.md +++ b/docs/library-shortcuts.md @@ -4,6 +4,7 @@ Select photos in Photo Grid or Square Grid, then use: | Key | Action | |---|---| +| `Enter` | Open the active photo in Detail from Photo Grid or Square Grid | | `Cmd+Shift+D` (macOS) / `Ctrl+Shift+D` (Windows/Linux) | Select only the active photo | | `0` | Clear the star rating | | `1`–`5` | Set the star rating | @@ -12,8 +13,12 @@ Select photos in Photo Grid or Square Grid, then use: | `P` | Flag as a pick | | `X` | Flag as rejected | | `U` | Clear the flag | +| `` ` `` (backtick) | Cycle the flag: unflagged → pick → reject → unflagged | +| `Cmd`/`Ctrl` + `↑` / `↓` | Raise / lower flag status (reject < unflagged < pick; stops at either end) | | `Shift` + `0`–`9`, `P`, `X` or `U` | Apply the action and select the next photo | +Enter opens the active photo without changing the selection. Text fields, dialogs and open popups keep the key; a custom Enter binding takes precedence. Enter has no additional action in Detail. + Select Only Active Photo (Edit menu) narrows the selection to the active photo. It does not change the photo, its edits or the last edit Undo step, and it works in any view that has an active photo. It can be reassigned or disabled in Help → Keyboard Shortcuts; text fields and dialogs keep the key. @@ -34,6 +39,16 @@ step. AutoWrite uses the normal rating metadata path. In Detail, including Maski remapping and disabling the default brush bindings take precedence over their grid bracket actions. The separate Decrease Rating / Increase Rating commands can also be assigned in Help → Keyboard Shortcuts. +Flag cycling and raise/lower use each photo's own flag, so a mixed selection steps together without being made uniform. Adobe's shortcut list names the backtick "Cycle flag settings" but does not state the order; unflagged → pick → reject is LightCraft's choice. The key is the logical grave key, so it follows the keyboard layout (on layouts where it is a dead accent key, assign another key in Help → Keyboard Shortcuts to `photo.cycleFlag`). + +`Home` and `End` make the first or last photo of the view the active, selected one. `Shift` + `←` / +`→` add the previous or next photo of the view to the selection and make the added photo active; the +key never removes a photo from the selection, and at either end of the view it does nothing. These +four keys work in Photo Grid and Square Grid only. They wait in Detail, Compare and Survey, in the +full-screen preview (which covers the grid), while a text field or a dialog has the keyboard, and +when you gave the same key to a command in Help → Keyboard Shortcuts, where they are listed under +Fixed keys. + Grid actions apply to all selected photos and support Undo / Redo. In Compare and Survey, culling actions apply to the active photo. Auto Advance moves forward after an action; holding Shift with Auto Advance on still advances only once. The last photo stays selected. Text fields keep their @@ -51,7 +66,17 @@ commit `4e012a59a6d2442ba749f109ea27aadb5c0b11a5` by Gardy, and addresses the ca [issue #283](https://github.com/storytold/lightcraft/issues/283). That PR was open when checked on 2026-10-08; this work extends its fix with library culling bindings and tests. -Public behaviour references, checked 2026-10-08: +## Grid thumbnail size + +`=` makes the grid thumbnails larger and `-` smaller (`+` also enlarges), as in Lightroom Classic. Each +press moves the bottom-bar slider by 30 pt within its 90–480 pt range and stops at either end. The keys +work in Photo Grid, Square Grid and People (the views with the slider), do nothing in Detail, Compare, +Survey and Reference, and leave text fields alone, so the search box still types `-` and `=`. `⌘=` / +`⌘−` remain Zoom In / Out. The same steps are the View ▸ Increase / Decrease Grid Thumbnail Size +commands (`view.thumbSizeUp`, `view.thumbSizeDown`, reachable from the control channel and MCP, which +return `{"size"}`); the size is saved with the other interface settings. + +Public behaviour references, checked 2026-10-08 (thumbnail size: 2026-10-13): - [Lightroom Classic keyboard shortcuts](https://helpx.adobe.com/lightroom-classic/help/keyboard-shortcuts.html) - [Lightroom Classic flagging, labelling and rating](https://helpx.adobe.com/lightroom-classic/help/flag-label-rate-photos.html) diff --git a/docs/lightroom-catalog-import.md b/docs/lightroom-catalog-import.md index 1f9deeea4..b6c0014f0 100644 --- a/docs/lightroom-catalog-import.md +++ b/docs/lightroom-catalog-import.md @@ -15,9 +15,21 @@ lightcraft-cli run --library "LightCraft Library" library.inspectLightroom path= lightcraft-cli run --library "LightCraft Library" library.importLightroom path="Lightroom Catalog.lrcat" ``` +Import adds records to the currently open LightCraft library; it does not create a separate +folder named after the Lightroom catalog. The desktop result stays open until dismissed and +shows the destination library, the number of records added, missing originals and warnings. +**Show in Library** opens All Photos, clears filters and selects an imported record. Errors stay +readable in the same result window. If another dialog is open, the result waits until that dialog closes. The result +is also available through `library.importLightroom {"status":true}` during that session. + Original files are referenced in place. Ratings, picks/rejects, color labels, XMP metadata, hierarchical keywords, regular collections/collection sets, virtual copies and mapped develop -settings are imported. Missing originals remain catalogued for relinking. Existing LightCraft +settings are imported. Missing originals remain catalogued for relinking, count as added records and are reported +separately from failures reading files that are present. Known-missing originals are not probed +again. Previously browsed Local records join All Photos even when their originals are offline; +their promotion is counted once and undone together with the catalog import. +Virtual copies count as added records too; reimport reports zero additions when all +records already exist. Existing LightCraft edits are preserved by default; `updateExisting=true` explicitly replaces them. Persistent import identity prevents reimport from duplicating virtual copies and collections. One undo step reverses catalog changes; any saved recovery archives remain available. @@ -32,8 +44,10 @@ retained. Oldest managed archives are retired; legacy `.json` archives remain un `Interop/lightroom-index.json` records imported identities. Source catalogs and originals are never overwritten. -Native import is bounded to a 1 GiB database/WAL, 16 MiB XMP packets and one million rows per -table. The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format +Each native `.lrcat` database and WAL file is independently limited to 1 GiB +(1,073,741,824 bytes). This bounds the current whole-file reader; referenced photo sizes and +photo counts are not part of that limit. XMP packets are capped at 16 MiB and each table at one million rows. +The in-tree, read-only Rust SQLite B-tree reader follows SQLite’s public file-format specification; it introduces no dependency or C runtime. Table payloads are capped at 256 MiB. Optional history, keyword and collection tables that exceed limits are skipped with warnings; required photo tables still report errors. Unrelated unsupported schemas are ignored. A needed @@ -61,6 +75,12 @@ Lightroom's `-999999` deferred-adjustment sentinel is omitted from both catalog it is reported, included in any saved archive, and never clamped into a real slider value. Deferred Adobe Auto Tone is not evaluated by the importer; LightCraft's Auto control remains available after migration. +New photo records are moved into staged operations; only masters needed for virtual copies +retain a second owned record while the import commits. +Applying catalog operations collects affected source-cache IDs before consuming the operations, +avoiding another full operation copy. Full-strength develop patches reuse their JSON without +serializing an unused copy of the current settings. + Native desktop inspection/import runs on a cancellable background worker with progress. Only prepared catalog operations commit on the owner thread; archive index writing stays on a worker. Late results from a closed or switched library are rejected, including reopening the same path. @@ -77,3 +97,12 @@ the path relative to the catalog that Lightroom records for root folders on the (`relativePathFromCatalog`), when that is a folder, and reports the remapping as a warning. Copying a project folder with its catalog inside it therefore keeps its originals linked. Other moved originals stay missing; use LightCraft's missing-photo relinking tools to update their paths. + +Original and root folder paths are resolved with the catalog as the document and are looked at +only when they are on this computer or on the network share the catalog itself is on +(`crates/engine/src/doc_path.rs`). An original on another computer (`\\server\share\…`, +`//server/…`, `file://server/…`), a device path, or one whose folder or file name in the catalog is +an absolute path is not looked at: the import lists it as missing with a warning and leaves the +photo and its virtual copies out of the library. To import originals on a share, open the catalog +from that share. A path that is not a path on this computer (a Windows drive path read on macOS or +Linux) is not looked at either and stays catalogued as missing. diff --git a/docs/localization-cs.md b/docs/localization-cs.md new file mode 100644 index 000000000..49a4d9859 --- /dev/null +++ b/docs/localization-cs.md @@ -0,0 +1,57 @@ +# Čeština + +Češtinu (`cs`, English name **Czech**, native name **Čeština**) zapnete přes **Úpravy → Jazyk → +Čeština** nebo **Nastavení → Obecné → Jazyk**. Změna platí ihned a ukládá se pro další spuštění. +Příkaz je `app.language.czech`; řídicí kanál přijímá `ui.set {"language":"cs"}` i značky +`cs-CZ` a `cs_CZ.UTF-8`. Pro běh bez okna lze nastavit `LIGHTCRAFT_LANGUAGE=cs`. + +Katalogy `crates/ui-egui/locales/cs.json` a `cs-formats.json` vznikly s pomocí AI pro @Losminek, +po celých frázích podle významu anglických zdrojových řetězců. Výchozí seznam klíčů vycházel +z největšího, ukrajinského katalogu; doplnilo se sjednocení ostatních katalogů a položky +používané kódem. Překlad byl porovnán s konvencemi rozhraní aplikace a prošel nezávislou +revizí rozsahu a terminologie. Rodilý mluvčí ani profesionální překladatel jej zatím nekorigoval. + +## Konvence a rozsah + +- Nabídky: Soubor, Úpravy, Zobrazení, Fotografie, Okno, Nápověda. +- Library = Knihovna, Develop = Vyvolat, Collection = Kolekce, Preset = Přednastavení. +- Flag = Příznak, Pick Flag = Příznak Vybráno, Soft Proofing = Měkký nátisk. +- Clarity = Zřetelnost, Dehaze = Odstranit opar, Vibrance = Živost, Saturation = Sytost. +- Highlights = Světla, Shadows = Stíny, Whites = Bílé, Blacks = Černé. +- Filmstrip = Filmový pás, Virtual Copy = Virtuální kopie, Snapshot = Snímek stavu. +- Datum má podobu `20. 9. 2026`, čas je 24hodinový (`16:04:05`). +- Čísla používají společné formátování aplikace; tento jazyk nepřidává vlastní číselný formatter. +- Počty používají neutrální formulace jako „Importováno fotografií: 22“ nebo + „Čekající fotografie: 2“. Anglická přípona se podle společných pravidel spotřebuje pomocí + `{:.0}`. Stávající mechanismus rozlišuje pouze jednotné a množné číslo; samostatné české + plurálové pravidlo se nepřidává. +- Písmo Inter dodávané s aplikací obsahuje české znaky i uvozovky „…“. Další font není potřeba. + +Katalog obsahuje 2 056 běžných položek a 246 formátových šablon. Každý běžný klíč existuje +v jiném katalogu nebo je používán kódem. Oproti německému katalogu má 108 dalších klíčů, +všechny s doloženým použitím: popisky příkazů, menu, ovládacích prvků a nastavení, +texty HDR/exportu a obličejů a kontextové hodnoty Ano/Ne. Z toho 41 klíčů již existuje +v dalších katalozích a 67 doplňuje současné překladové lookupy. + +Řádkové fragmenty poznámek k vydáním byly odstraněny, včetně 163 fragmentů převzatých +ze sdílených katalogů; text poznámek v dialogu Co je nového používá anglický fallback. +Společné nadpisy používané také v UI (například Library nebo Masking) zůstávají přeložené. + +Úplné pokrytí katalogů neznamená úplnou jazykovou paritu: technické chyby nižších vrstev +a některá hlášení mimo překladové rozhraní zůstávají anglicky. Překlad nemění data knihovny +ani anglické zdrojové řetězce. Rozložení panelů a seznamu jazyků je beze změny. + +## Ověření a revize + +Testy `i18n::tests` ověřují registraci příkazu, přepínání přes nabídku a řídicí kanál, +uložení jazyka, pokrytí katalogů, placeholdery, neutrální formulace počtů, datum, čas +a české glyfy. Překladové šablony kontroluje také Rust při sestavení. + +Při jazykové korektuře doporučujeme zkontrolovat „asistované třídění“, „sada“ pro Stack, +„bodová barva“, „měkký nátisk“ a názvy kreativních přednastavení. +Postup přidávání jazyků je v [localization.md](localization.md). + +V této změně se doplnilo volání překladu u jednoduchých popisků v panelech osob a obličejů, +volbě souboru modelu a podmínkách jeho instalace, výběru nepojmenovaných obličejů, +pojmenování obličeje v detailu, instrukce tisku PDF a upozornění na PNG PQ, +včetně textů barevného prostoru HDR. Anglické zdrojové texty zůstaly stejné. diff --git a/docs/localization-ka.md b/docs/localization-ka.md new file mode 100644 index 000000000..234ab5ee9 --- /dev/null +++ b/docs/localization-ka.md @@ -0,0 +1,77 @@ +# LightCraft in Georgian + +Choose **Edit → Language → ქართული** or **Settings → General → Language**. The choice applies at once +and is saved for the next launch (`language: "ka"` in `ui.json`); the control command is +`app.language.georgian`. `LIGHTCRAFT_LANGUAGE` selects it for headless runs and accepts `ka`, `ka-GE` +and `ka_GE.UTF-8`; the saved value is always `ka`. LightCraft does not follow the system language, +for Georgian as for every other language. + +The Georgian catalogs cover every key of the other catalogs plus all command and slider labels: +menus, shortcuts, library, editing, crop, masks, people, import/export, settings, built-in presets and +profiles, history, versions, date headings, status messages, the smart-album rule editor and the +unsaved-changes dialog. The embedded release notes are translated too. Technical errors from lower +layers stay in English, as in the other languages. They were written from the meaning of the English +labels; no proprietary translation resources were consulted. + +File names, command ids, template placeholders and albums, presets or metadata you named yourself are +not translated. Descriptions for SAM 3 go to the model unchanged. + +## Georgian script + +Mkhedruli has no upper and lower case: `i18n::upper_first` leaves Georgian letters alone, and the +interface never uses Mtavruli (a test checks the whole catalog). Commands are verbal nouns. Counts +are count-neutral, noun first (`იმპორტირებულია ფოტო: 3`); English plural endings are dropped with +`{:.0}`. Dates read `კვირა, 20.09.2026` (`{weekday}, {day:02}.{month:02}.{year}`), months `09.2026`. + +Inter has no Georgian letters and craft-fonts has no Georgian face yet, so Georgian text shows boxes +until a Georgian face is available to the UI. + +## Terminology + +| Source | Georgian | +|---|---| +| Library / Develop | ბიბლიოთეკა / დამუშავება | +| File / Edit / View | ფაილი / რედაქტირება / ხედი | +| Settings / General / Language | პარამეტრები / ზოგადი / ენა | +| Import / Export | იმპორტი / ექსპორტი | +| Photos / Albums / Folders | ფოტოები / ალბომები / საქაღალდეები | +| Smart Album | სმარტ-ალბომი | +| Crop | ჩამოჭრა | +| Masks / Masking | ნიღბები / ნიღბვა | +| Heal / Remove | აღდგენა / მოშორება | +| Presets / Profile | პრესეტები / პროფილი | +| History / Versions | ისტორია / ვერსიები | +| Light / Color / Effects / Detail | სინათლე / ფერი / ეფექტები / დეტალი | +| Exposure / Contrast | ექსპოზიცია / კონტრასტი | +| Highlights / Shadows | ღია ტონები / ჩრდილები | +| Whites / Blacks | თეთრები / შავები | +| Texture / Clarity / Dehaze | ტექსტურა / სიცხადე / ბურუსის მოცილება | +| Vibrance / Saturation | სიხალასე / გაჯერება | +| Hue / Luminance | ფერის ტონი / სიკაშკაშე | +| Tint | ტონირება | +| Tone Curve / Color Grading | ტონალური მრუდი / ფერის გრადაცია | +| Sharpening / Noise Reduction | გამკვეთრება / ხმაურის შემცირება | +| Lens Corrections / Geometry | ობიექტივის კორექციები / გეომეტრია | +| Vignette / Grain | ვინიეტი / მარცვლოვნება | +| Feather / Threshold | კიდეების შერბილება / ზღვარი | +| Rating / Flag / Keywords | შეფასება / დროშა / საკვანძო სიტყვები | +| Reset / Revert | ჩამოყრა / წინა ვერსიაზე დაბრუნება | +| Undo / Redo | დაბრუნება / გამეორება | + +Please follow this table and the shared ArtCraft Georgian glossary when you add strings. + +## Maintenance and checks + +Fixed texts: `crates/ui-egui/locales/ka.json` (2,719 entries). Messages with values: +`crates/ui-egui/locales/ka-formats.json` (249 entries). The English keys stay unchanged; Rust checks +the placeholders at build time. Strings not yet in the plain catalog show in English. + +```sh +cargo test -p lightcraft-ui-egui i18n::tests -- --nocapture +LIGHTCRAFT_LANGUAGE=ka lightcraft-cli snapshot --demo -o georgian.png --size 1600x1000 +``` + +The tests check catalog coverage, placeholders, switching language by menu and control channel, +saving, the `ka` locale tags, dates, count wording, the absence of Mtavruli and that sentence case +keeps Georgian lowercase. More notes are in +[localization.md](localization.md). diff --git a/docs/localization-parity.md b/docs/localization-parity.md index c90f3a270..b7900d350 100644 --- a/docs/localization-parity.md +++ b/docs/localization-parity.md @@ -1,12 +1,12 @@ # Localization parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-11 · **Change:** minor (the interface no longer decides on error text, a prerequisite for translating lower-layer errors) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (de, es, fr, ja, uk catalogs filled; re-measured; Italian and Georgian interface catalogs added) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How far each interface language is from `full`. How to add or maintain a language is in [localization.md](localization.md); per-language notes are in `localization-<code>.md` ([zh-hans](localization-zh-hans.md), [zh-hant](localization-zh-hant.md), [ja](localization-ja.md), [pt-br](localization-pt-br.md), [es](localization-es.md), [de](localization-de.md), -[ru](localization-ru.md)). +[ru](localization-ru.md), [ka](localization-ka.md)). ## The target @@ -47,26 +47,28 @@ that is promising but is not recorded as review, so the column says `no (communi |---|---|---|---:|---|---|---|---|---:| | 1 | English | `en` | 2,304 / 2,304 (100%, source) | all; Help ▸ What's New and the shortcut sheet; no full user manual | Latin (Inter) | n/a | **full** | 0 h | | 2 | Simplified Chinese | `zh-hans` | 1,994 / 2,304 (87%) | menus, panels, dialogs, presets, profiles, dates; lower-layer errors English | Hans via craft-fonts (Noto Sans CJK SC); IME through winit; no vertical text in UI (not in target either) | no (community edits) | partial | 6–10 h | -| 3 | Spanish | `es` | 1,972 / 2,304 (86%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | +| 3 | Spanish | `es` | 2,362 / 2,398 (98%; 2026-10-12) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 4 | Hindi | `hi` | 0 (0%) | none | **Devanagari shaping missing** (egui has no complex shaping) | no | none (target: not shipped) | 50–90 h (shaping 25–50 h, shared with Arabic) | | 5 | Arabic | `ar` | 0 (0%) | none | **RTL layout and Arabic shaping missing** | no | none (target: not shipped) | 60–110 h (RTL mirroring 30–50 h + shaping, shared with Hindi) | -| 6 | French | `fr` | 2,021 / 2,304 (88%) | as zh-hans | Latin | no (community edits) | partial | 5–9 h | +| 6 | French | `fr` | 2,315 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 7 | Portuguese (Brazil) | `pt-br` | 1,966 / 2,304 (85%) | as zh-hans | Latin | no (community edits) | partial | 6–10 h | | 8 | Indonesian | `id` | 0 (0%) | none | Latin (Inter covers it) | no | none (target: not shipped) | 10–16 h | -| 9 | Japanese | `ja` | 1,995 / 2,304 (87%) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 6–10 h | -| 10 | German | `de` | 2,104 / 2,304 (91%) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 4–8 h | +| 9 | Japanese | `ja` | 2,383 / 2,398 (99%; 2026-10-12) | as zh-hans; vertical export watermarks | Jpan via craft-fonts (BIZ UDPGothic / UDMincho); IME through winit | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | +| 10 | German | `de` | 2,338 / 2,398 (97%; 2026-10-12, the rest are words spelled the same) | as zh-hans; What's New translated line by line | Latin | no (community edits) | partial | 2–4 h (native review, lower-layer errors) | | 11 | Korean | `ko` | 0 (0%) | none | **no Korean (Hangul) face**: needs a non-Adobe OFL face in craft-fonts (Noto CJK is allowed only for Chinese) | no | none | 14–22 h | | 12 | Vietnamese | `vi` | 0 (0%) | none | Latin with stacked diacritics (Inter covers it; check combining marks) | no | none (target: not shipped) | 10–16 h | -## Other languages LightCraft ships (3) +## Other languages LightCraft ships (4) | Language | Code | UI strings translated | Script | Native review | Status | To `full` | |---|---|---:|---|---|---|---:| | Traditional Chinese (Taiwan) | `zh-hant` | 1,993 / 2,304 (87%) | Hant; uses the Simplified Chinese face until craft-fonts has a TC face | no (community edits) | partial | 8–14 h (incl. the font) | | Russian | `ru` | 1,704 / 2,304 (74%) | Cyrillic (Inter) | no (community edits) | partial | 8–12 h | -| Ukrainian | `uk` | 2,287 / 2,304 (99%) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Ukrainian | `uk` | 2,383 / 2,398 (99%; 2026-10-12) | Cyrillic (Inter) | no (community edits) | partial (lower-layer errors English) | 2–4 h | +| Italian | `it` | 2,201 / 2,398 (92%, measured 2026-10-12; the rest is What's New) | Latin (Inter) | no (written with an agent; native-speaker pass pending) | partial (lower-layer errors English) | 3–5 h | +| Georgian | `ka` | 2,398 / 2,398 (100%: every plain and formats message any catalog has on 2026-10-12, plus the 27 display labels and 397 What's New lines no other catalog translates) | Geor: no craft-fonts face yet (boxes until one is available) | yes (native speaker) | partial (lower-layer errors English) | 2–4 h (+ a Georgian face) | -Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai +Target languages LightCraft lacks: Korean, Dutch, Polish, Swedish, Norwegian Bokmål, Thai (Thai needs shaping and line breaking without spaces). Each Latin-script one is ~10–16 h; Thai ~20–35 h on top of the shaping work. @@ -83,13 +85,16 @@ Target languages LightCraft lacks: Italian, Korean, Dutch, Polish, Swedish, Norw ## Totals -- Bring the 9 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). +- Bring the 10 shipped non-English catalogs to `full`: ~55–90 h (incl. the shared work). - Add the 5 missing key languages: ~145–255 h, most of it RTL and complex shaping in egui. -- Reach the target's 16 languages: + ~60–100 h (Italian, Dutch, Polish, Swedish, Norwegian, Thai). +- Reach the target's 16 languages: + ~50–85 h (Dutch, Polish, Swedish, Norwegian, Thai). ## Revision history | Date | Change | Summary | |---|---|---| +| 2026-10-12 | minor | Italian interface catalog (`it`): 2,201 of 2,398 messages, What's New left in English | | 2026-10-11 | minor | Shared work: the interface decides on error kinds, not English error text (#1046) | +| 2026-10-12 | minor | Georgian (`ka`) row: every catalog message | | 2026-10-10 | major | Created: per-language counts measured from the catalogs, the twelve key languages, script support, estimates | +| 2026-10-12 | minor | de, es, fr, ja and uk catalogs filled to every message another catalog knows (agent translations, native review welcome); those rows re-measured against the current 2,398 messages. Other rows keep their 2026-10-10 counts (open PRs for pt-BR, ru, zh) | diff --git a/docs/localization.md b/docs/localization.md index 715b871af..84feb4d8d 100644 --- a/docs/localization.md +++ b/docs/localization.md @@ -2,10 +2,11 @@ The interface ships in the language it is written in (English) plus every language in the table in `crates/ui-egui/src/i18n.rs`. Today that is English, Simplified Chinese (`zh-hans`), Traditional -Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`) and French (`fr`). This file is the reference for **adding or maintaining a language**; the per-language notes +Chinese (`zh-hant`, Taiwan), Japanese (`ja`), Brazilian Portuguese (`pt-br`), Spanish (`es`), German (`de`), Russian (`ru`), French (`fr`), Ukrainian (`uk`), Czech (`cs`) and Georgian (`ka`). This file is the reference for **adding or maintaining a language**; the per-language notes are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-hant.md`](localization-zh-hant.md), [`localization-ja.md`](localization-ja.md), [`localization-pt-br.md`](localization-pt-br.md), [`localization-es.md`](localization-es.md), -[`localization-de.md`](localization-de.md) and [`localization-ru.md`](localization-ru.md). +[`localization-de.md`](localization-de.md), [`localization-ru.md`](localization-ru.md), +[`localization-cs.md`](localization-cs.md) and [`localization-ka.md`](localization-ka.md). ## Adding a language @@ -30,6 +31,9 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h De, "de", "Deutsch", "Latn", include_str!("../locales/de.json"); Ru, "ru", "Русский", "Cyrl", include_str!("../locales/ru.json"); Uk, "uk", "Українська", "Cyrl", include_str!("../locales/uk.json"); + Cs, "cs", "Čeština", "Latn", include_str!("../locales/cs.json"); + It, "it", "Italiano", "Latn", include_str!("../locales/it.json"); + Ka, "ka", "ქართული", "Geor", include_str!("../locales/ka.json"); } ``` @@ -43,7 +47,9 @@ are in [`localization-zh-hans.md`](localization-zh-hans.md), [`localization-zh-h 3. **Make sure the glyphs exist.** The script drives which craft-fonts faces the UI installs: a language whose script has no face in the build shows boxes (the test says so instead of failing). Fonts live in [storytold/craft-fonts](https://github.com/storytold/craft-fonts), never here; a - missing face is added there, not worked around in this repository. + missing face is added there, not worked around in this repository. Sentence-cased messages go + through `i18n::upper_first`, which leaves Georgian lowercase (upper-casing Mkhedruli would make + Mtavruli). 4. Run `cargo xtask ci` (it includes `fmt`, `clippy`, the tests, `parity` and the wasm check). ## How a message is found diff --git a/docs/mcp.md b/docs/mcp.md index f0cc9a1d1..00841bec8 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -24,7 +24,12 @@ it open, `--library` on the same folder fails with "This library is already open the app is not running yet and connects on the first call (and reconnects if the app restarts). A request is sent again only when it never reached the app. If the connection drops after a request was sent but before its reply, the tool fails with "the outcome is unknown" and nothing is resent, so a -command such as undo can't run twice: check the app's state before retrying. +command such as undo can't run twice: check the app's state before retrying. Replies must be +newline-terminated, at most 64 MiB before the newline (including encoded previews/screenshots), +and carry the request's integer `id`, a boolean `ok`, and either a `result` or a string `error`. +A malformed, incomplete, oversized or mismatched reply also closes the connection and reports +an unknown outcome without resending. An idless `ok: false` with a string `error` is preserved +as a pre-dispatch refusal (for example, too many control connections); the next call reconnects. Logs go to stderr; stdout carries only protocol messages. @@ -203,6 +208,37 @@ JSON params (documented in each tool's description and by `list_commands`). Pass leave these per-command tools out when a client struggles with a large tool list; `run_command` still reaches every command. +Slideshow slides, for example: `cmd_slideshow_layout` reads or changes the slide layout (margins, +zoom to fill, stroke border, cast shadow, backdrop colour, colour wash, background image; saved with +the library) and `cmd_slideshow_preview {id?, width?, height?, path?}` composes one slide with it — +written as a PNG to `path`, or returned as base64 PNG in `png` — so an agent can look at a layout +before playing it. `cmd_slideshow_playback` sets the timing and fades. `cmd_slideshow_overlays` sets the +overlays drawn on every slide (identity plate text or image, rating stars, text overlays with tokens such as +`{caption}` or `{exposure}`, the export watermark); its reply lists `warnings` (an unreadable plate image, tags that +aren't tokens), and `cmd_slideshow_preview` returns each overlay's box. `cmd_slideshow_titles` sets the intro and ending +screens (colour, the identity plate or a line of text, scale, duration), and `cmd_slideshow_preview {title: "intro"}` +renders one. Templates bundle all four: `cmd_slideshow_templates_list` lists the built-in and user templates (and files +it skipped), `cmd_slideshow_templates_apply {id}` replaces the settings with one (the replaced ones stay available as +`{id: "previous"}` for the session), `cmd_slideshow_preview {template: id}` previews one without applying it, and +`cmd_slideshow_templates_save` / `update` / `rename` / `delete` / `import` / `export` manage the user's own (JSON files +in `<settings>/slideshow-templates`). +`cmd_slideshow_export {format: "pdf"|"jpeg"|"video", path, size?, quality?, fps?, container?: "mp4"|"webm", +fullScreen?, name?, ids?, wait?}` writes the slideshow (titles included) as a PDF (a page per slide), a numbered JPEG +sequence in a folder, or an AV1 video (no audio). It runs in the background and returns `{taskId, total, …}`: +`cmd_activity_list` shows its progress, `cmd_activity_cancel {id: taskId}` stops it (nothing is left on disk) and +`cmd_slideshow_exportStatus` gives the result (`pages`, `files`, or `frames` / `samples` / `seconds` for video) or the +error. With `wait: true` the call blocks and replies with the result. + +### Preview-only raws after an update + +A raw LightCraft couldn't decode when it was imported is shown from its embedded preview +(`previewOnly` in `catalog.query`). The desktop app rechecks such photos in the background when a +library opens in a newer (or older) LightCraft version, so a raw the new decoder handles develops +from its raw data without a manual Reload. Headless sessions don't do this on their own: call +`cmd_library_recheckPreviewOnly` (`{all: true}` ignores the version stamps, e.g. to test a decoder +build) → `{checked, updated, cleared}`. Like the app's recheck, it isn't an undo step and keeps +develop settings and history. + ## Resources `resources/list` / `resources/read` serve JSON snapshots: @@ -211,7 +247,7 @@ still reaches every command. |---|---| | `lightcraft://library` | Source, filter, sort, selection, undo/redo labels (`library.state`) | | `lightcraft://photos` | Photos in the current view (`catalog.query`) | -| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`) | +| `lightcraft://photo/active` | Everything about the active photo (`photo.inspect`), including the camera's focus point as `meta.focus` when the file records one ([focus-points.md](focus-points.md); `run_command photo.readFocusPoints {wait: true}` reads it for photos imported before it was read, a few KB per file) | | `lightcraft://develop/active` | The active photo's develop settings (`develop.get`) | | `lightcraft://controls` | Every develop control with its current value (`develop.controls`) | @@ -314,5 +350,15 @@ being processed finishes first: completed photos remain, each written with the e file writer, and there are no partial files to delete. Unrelated outputs and earlier exports are untouched. A failed export is an `isError` tool result. Transport mutexes recover poisoning. +If a protocol write or flush fails, the CLI reports the output error and shuts down without +waiting for stdin to close. Output failures are observable only when writing. An active direct +headless export stops at the next photo boundary; a synchronous command and normal backend +cleanup must finish first. Completed exports and library cleanup retain their usual guarantees. + +Library callers serving process stdin use `Server::serve_stdio(stdout())` once per process and +exit after it returns. Its single stdin worker may remain blocked until process exit. The generic +`Server::serve(input, output)` keeps borrowed input scoped: its caller must arrange input EOF or +an input error, even when output fails. + Connect mode and exports inside `command_batch` remain synchronous and do not report MCP progress or cancellation. Use a direct headless export call for this behavior. diff --git a/docs/merge.md b/docs/merge.md index 7c298cb10..e3f3e71ad 100644 --- a/docs/merge.md +++ b/docs/merge.md @@ -10,12 +10,17 @@ All algorithms are our own implementations from published papers (cited in the m *HDR Panorama…* from the photo context menu (or the `dialog.merge*` commands). The dialog previews the merge at ≤ 1024 px and re-runs the preview when an option changes; *Merge* runs the full merge in the background (a row in the activity stack shows its progress, with ✕ to stop it). The result is written next to the first photo as - `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. + `<name>-HDR.dng`, `-Pano.dng` or `-HDR-Pano.dng`, imported and selected. *Photo Merge ▸ HDR for Each Bracket Set* + merges many brackets in one go with the last HDR options: one HDR per stack among the selected photos, then per + bracket (photos taken less than a second apart, after the shutter time, at exposures at least ½ EV apart; a burst at + one exposure is skipped), one after the other in the activity stack. - **Commands** (CLI, MCP, control channel): - `merge.hdr {ids?, align=true, deghost=none|low|medium|high, autoSettings=true, stack=false, preview=false, showOverlay=false, previewPath?}` - - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels=40, preview, previewPath?}` + - `merge.panorama {ids?, projection=auto|spherical|cylindrical|perspective, boundaryWarp=0..100, autoCrop=false, fillEdges=false, autoSettings=true, maxMegapixels (default: the photos' own resolution), preview, previewPath?}` - `merge.hdrPanorama {ids?, bracket=0 (from EXIF), …both sets of options}` -- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama [options] FILES…`; + - `merge.hdrBatch {ids?, align, deghost, autoSettings, stack}` → `{merged: [{id, path, …}], failed: [{photos, error}]}` +- **CLI:** `lightcraft-cli merge hdr|panorama|hdr-panorama|hdr-batch [options] FILES…` (`hdr-batch` finds the brackets + among the files, e.g. a whole folder); `lightcraft-cli synth-merge hdr|panorama -o DIR` writes synthetic inputs (procedural scene). ## What it does @@ -25,8 +30,14 @@ reference. Auto Align: features on exposure-normalised luminance, RANSAC transla homography (the simplest model that explains ≥ 97 % of the matches). Exposure ratios are measured (median log ratio of well-exposed pixels between brightness neighbours), EXIF is the fallback. Radiance is a weighted mean in linear light, weight `k · (1 − smoothstep(0.8·clip, 0.96·clip, max -channel))`. Deghost: a per-pixel consistency test against the reference radiance (with a noise -floor), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. +channel within 1 px))`: a pixel next to clipped photosites was demosaiced from them and reads low in the clipped +channel, so it goes with them (on real ARW brackets the brightest frame's green was 3–5 % low there, and bright +windows took a magenta tinge). The frames are taken in brightness order (ties by content), so the result doesn't +depend on the order they come in. Where the reference is at or near its clip the scene is at least that bright, so a frame that reads far +darker there (an object moved: a star, the moon, a passing light) is left out and the clipped highlight stays (always +on: a moving star otherwise left a black hole). Deghost: a per-pixel consistency test on ≤ 2048 px versions against the +reference radiance (with a noise floor; the reference predicts down to 0.1 % of its clip, so a brighter frame's ghost in +a night sky is caught), dilated and feathered masks; the union is the deghost overlay. Output: 16-bit float DNG. Lens corrections: frames are merged uncorrected, and the DNG's `OpcodeList3` carries the reference frame's (`WarpRectilinear`, `WarpRectilinear2`, `FixVignetteRadial`: a DNG's own, or a camera's correction a raw reader converts, e.g. Sony, Panasonic), re-expressed for its default crop (`Opcode::cropped`). So Optics ▸ Enable Lens @@ -52,7 +63,10 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) - HDR, 3 brackets ±2 EV with 14-bit noise and handheld shifts: alignment error < 0.3 px, exposure error < 0.02 EV, radiance median log error < 0.03 (noise-limited, bias < 0.01), recovered - highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High. + highlights < 0.05; moving object: ghost error 0.2–2.0 without deghosting, < 0.06 with High; a star that moves + across a night sky keeps its clipped core and, with High, leaves no ghost of the brightest frame. +- Real brackets (Sony A7R VI, 67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % from 3 to 90 % of + their clip in all three channels; measured exposure steps match EXIF within 0.03 EV. - Panorama, 5 views over 182° with a 1.3× exposure drift: relative rotations < 0.1°, focal < 1 %, BA RMS ≈ 0.35 px; median log error vs ground truth < 0.02; no seam steps. @@ -63,9 +77,13 @@ scale in `BaselineExposure`; raw sources keep their DNG colour tags (camera RGB) panoramas carry no lens corrections, nor does an HDR whose frames don't share an orientation (they are turned before merging). - No camera response calibration for JPEG brackets (sRGB decoding is taken as linear light). +- AI Denoise doesn't run on merged DNGs (they are linear RGB; the denoiser takes Bayer raws), unlike Lightroom's. - Boundary Warp is a separable stretch, not a content-preserving mesh warp; Fill Edges is diffusion, not patch synthesis. - *Create Stack*: the result goes on top of a collapsed stack of its sources (`Catalog::stack_with_ops`); its caption also lists them. - Panorama output is limited to `maxMegapixels` (default 40) — blending memory is ~25 bytes/pixel. +- HDR memory: every frame is held as 32-bit float RGB (~0.86 GB per 67 MP frame at the peak, plus ~1.2 GB); full-size + photos are decoded one at a time. Measured on 67 MP raws: 3 frames 3.8 GB, 4 frames 4.7 GB, 5 frames 5.5 GB (was + 4.8 / 6.3 GB for 3 / 4). Half-float frames would halve the per-frame share. - The browser build has no file access for merges yet. diff --git a/docs/parity-checklist.md b/docs/parity-checklist.md index 940d34010..342c33f0a 100644 --- a/docs/parity-checklist.md +++ b/docs/parity-checklist.md @@ -1,6 +1,6 @@ # Lightroom parity checklist -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (LR-VIEW-ZOOM: diagnostic overlays get zoom windows; LRC-DEV-SOFTPROOF on the GPU); previously minor (LR-VIEW-ZOOM ✅: mask overlays and soft proofing get zoom windows; 1:1 equals export; integration batch 35: KEYC-SELECTONLYACTIVE, Select Only Active Photo ⌘⇧D; LR-BEHAV-AUTOSAVE, damaged snapshots repaired on open; LR-EDIT-OPTICS-PROFILE, Nikon Z 6II / Z 8 / Z f in-file distortion; LRC-LIB-CATALOG-IMPORT, WAL-only catalogs open; LR-EXP-DIM, enlargements stop at 512 MP; LR-IMP-CAMERA-COVERAGE, Canon EOS RP CR3 verified; model folder retry) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (LRC-PRINT-PAGE: Print Page Options; LR-VIEW-ZOOM: diagnostic overlays get zoom windows; LRC-DEV-SOFTPROOF on the GPU) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 The measured, row-by-row checklist: one row per Lightroom feature, menu item and shortcut, what we have and what is missing. It answers *does the feature exist?* (feature breadth). Whether it is good @@ -28,19 +28,19 @@ cited here still exists. Formerly docs/parity.md. |---|---:|---:|---:|---:|---:|---:| | A. Import (IMP) | 12 | 3 | 1 | 1 | 4/6 (67%) | 5/5 (100%) | | B. Library management (LIB) | 22 | 2 | 1 | 2 | 9/9 (100%) | 9/9 (100%) | -| C. Views & navigation (VIEW) | 16 | 0 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | +| C. Views & navigation (VIEW) | 16 | 1 | 1 | 0 | 9/9 (100%) | 4/4 (100%) | | D. Search & filter (FILT) | 11 | 1 | 1 | 0 | 4/4 (100%) | 4/4 (100%) | | E. Metadata (META) | 5 | 1 | 0 | 0 | 2/2 (100%) | 2/2 (100%) | -| F. Edit panel — global adjustments (EDIT) | 43 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | +| F. Edit panel — global adjustments (EDIT) | 44 | 2 | 3 | 1 | 28/28 (100%) | 13/14 (93%) | | G. Profiles (PROF) | 7 | 1 | 3 | 0 | 3/4 (75%) | 3/3 (100%) | | H. Crop & rotate (CROP) | 9 | 0 | 0 | 1 | 6/6 (100%) | 3/3 (100%) | | I. Remove / healing (REM) | 8 | 1 | 2 | 2 | 4/4 (100%) | 2/3 (67%) | | J. Red eye (EYE) | 2 | 0 | 0 | 0 | — | 1/1 (100%) | -| K. Masking (MASK) | 14 | 5 | 5 | 0 | 7/8 (88%) | 6/6 (100%) | +| K. Masking (MASK) | 15 | 5 | 5 | 0 | 7/8 (88%) | 7/7 (100%) | | L. Presets (PRE) | 6 | 0 | 1 | 1 | 2/2 (100%) | 2/2 (100%) | | M. Versions & history (VER) | 5 | 0 | 0 | 0 | 1/1 (100%) | 3/3 (100%) | | N. Copy / paste / sync (SYNC) | 5 | 0 | 0 | 0 | 3/3 (100%) | 1/1 (100%) | -| O. Merge (MERGE) | 4 | 0 | 0 | 0 | — | — | +| O. Merge (MERGE) | 3 | 1 | 0 | 0 | — | — | | P. Enhance (ENH) | 0 | 0 | 2 | 0 | — | — | | Q. HDR (HDR) | 2 | 3 | 0 | 0 | — | — | | R. Video (VID) | 0 | 0 | 5 | 1 | — | 0/2 (0%) | @@ -51,11 +51,11 @@ cited here still exists. Formerly docs/parity.md. | W. Cloud & AI infrastructure (CLOUD / AI) | 1 | 0 | 1 | 2 | — | — | | X. Cross-cutting behaviours (BEHAV) | 23 | 6 | 1 | 1 | 8/8 (100%) | 7/10 (70%) | | Y. Menus | 84 | 0 | 4 | 8 | 47/47 (100%) | 23/23 (100%) | -| Z. Keyboard shortcuts (desktop) | 76 | 4 | 3 | 1 | 49/52 (94%) | 23/25 (92%) | -| Lightroom Classic extras | 34 | 16 | 35 | 8 | — | 24/26 (92%) | -| **Total** | 410 | 52 | 72 | 35 | 194/201 (97%) | 146/159 (92%) | +| Z. Keyboard shortcuts (desktop) | 78 | 4 | 2 | 1 | 49/52 (94%) | 24/26 (92%) | +| Lightroom Classic extras | 40 | 23 | 23 | 8 | — | 24/26 (92%) | +| **Total** | 419 | 61 | 59 | 35 | 194/201 (97%) | 148/161 (92%) | -Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **81.6%** of 534 in-scope rows — P0 98.3% of 201 · P1 95.0% of 159 · P2 50.0% of 173. +Weighted completion (✅ = 1, 🟡 = ½, 🚫 left out): **83.4%** of 539 in-scope rows — P0 98.3% of 201 · P1 95.0% of 161 · P2 55.7% of 176. <!-- /parity:summary --> ## Top gaps and shortcut notes @@ -69,19 +69,21 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| +| 2026-10-12 | minor | LR-IMP-FORMATS: Mamiya MEF decodes from sensor data and is imported (`vendor/mef.rs`) | +| 2026-10-12 | minor | LR-IMP-FORMATS: Epson ERF decodes from sensor data (`vendor/erf.rs`), no longer preview-only | | LR-IMP-ADD-DIALOG | Add photos/folders | P0 | ✅ | `cmd:file.addPhotos`, `cmd:library.importPreview`, `cmd:library.import` (`mode`, `album`, `albumName`, `preset`, `keywords`), `crates/ui-egui/src/import.rs` | review dialog (Import Photos): the scanned source (a scanned folder is not added to Local), candidate grid with thumbnails and checkboxes (duplicates by path/content marked and unchecked; with several file types, a toggle per type checks or unchecks all of its files, e.g. the raws without the JPEGs beside them, issue #344) filling a resizable window (issue #337), transfer mode (add in place / copy / move, each explained), album (existing/new), preset, keywords; the folder scan runs on a background thread, shown in the activity stack with ✕ to cancel (a NAS folder no longer freezes the window); the import itself (probing, sidecars, copying / moving files) runs on a worker thread and its batches join the library between frames, shown in the activity stack with ✕ to cancel; one undo step. Drag-and-drop goes through the same background import. File dialogs list the importable extensions (`lightcraft_engine::import::EXTENSIONS`) in any letter case on Linux, where GTK and the desktop portal match globs case-sensitively (`apps/lightcraft/src/dialog_filter.rs`, issue #342; 0.4.0 hid `.JPG` / `.NEF`, issue #705). The folder scan, Browse Folder, drag-and-drop and the sidecar lookup match extensions in any case (`crates/engine/src/tests_import.rs`) | | LR-IMP-DRAGDROP | Drop files/folders to import | P0 | ✅ | `crates/ui-egui/src/lib.rs` (dropped files → `cmd:library.import`) | dropping onto a specific album not supported | | LR-IMP-DUPES | Skip duplicates by content | P1 | ✅ | `crates/engine/src/import.rs`, `crates/engine/src/tests_import.rs` | | -| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk) | +| LR-IMP-DEVICE | Import from camera/card | P1 | ✅ | `cmd:library.devices`, `cmd:file.addFromDevice`, `crates/engine/src/devices.rs` | mounted volumes with a DCIM folder (macOS /Volumes, Linux /media and /run/media, Windows drive letters); File → Import from Device → the import review, copying into the library by default; menus serve the last scan and rescan in the background (never blocking a frame; hot-plugs repaint); copies are verified (a new file, synced, checked against the content hash the scan computed — byte compare with the source when there is none, issue #134; a bad copy is removed and reported as failed, never counted as imported; taken names get -1, -2…, never replaced — issue #96, `crates/engine/src/import_move.rs`); no PTP/MTP (cameras that don't mount as a disk); Settings → Import → Cameras and Cards can open the import review when a camera or card is connected (off by default; a card that was already in doesn't ask, and an open dialog or running import is finished first; `crates/ui-egui/src/import.rs`) | | LR-IMP-AUTO | Watched-folder auto import | P2 | ✅ | `cmd:library.autoImport`, `cmd:library.autoImportScan`, `crates/ui-egui/src/panels/settings.rs` | Settings ▸ Import ▸ Auto Import: a watched folder whose new photos are added (in place or copied) once complete, into an optional album; scanned every 3 s | | LR-IMP-PRESET | Preset on import | P2 | ✅ | `cmd:library.import` (`preset`) | chosen in the import review; one History entry | | LR-IMP-RAWDEFAULT | Raw defaults | P1 | ✅ | `cmd:library.preferences`, `crates/engine/src/import.rs`, `crates/ui-egui/src/panels/settings.rs` | LightCraft default / a preset / per camera (make + model); non-raw default too; the preset look counts as unedited and Reset returns to it | | LR-IMP-MIGRATE | Migrate other catalogs | OOS | 🚫 | | | -| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate, photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); Adobe profile formats deliberately unsupported | -| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change) | +| LR-IMP-PROFILES | Import profiles & presets | P1 | ✅ | `cmd:file.importPresets`, `cmd:preset.import`, `cmd:profile.import`, `crates/engine/src/preset_import.rs`, `crates/engine/src/preset_luminar.rs` | presets: .lcpreset, XMP, classic .lrtemplate (local adjustment templates become mask effects, LR-MASK-EFFECTS), photos carrying edits ("DNG presets"), Luminar looks (.lmp, .mplumpack collections; sliders with a counterpart), .zip bundles, folders (folder → group), drag & drop (an `.xmp` dropped together with its photo is that photo's sidecar and is not imported as a preset, issue #936); masks carried over; unmapped settings reported (fields that only describe the preset or photo, e.g. `Cluster`, `SortName`, `SupportsAmount2`, the as-shot white, empty Point Color slots, are not); profiles: .cube 3D LUTs (one toast reports both halves; a LUT that fails is named with its reason); a preset that names a profile (`crs:Look`) applies the imported profile of that name, imported before or after it, and Lightroom's basic profiles map to ours (`crates/engine/src/profile_refs.rs`, issue #1001); Adobe profile formats deliberately unsupported (so `.xmp` creative profiles' colour tables aren't read) | +| LR-IMP-LOCAL | Work on files in place | P0 | ✅ | `cmd:library.browse`, `cmd:photo.addToLibrary`, `cmd:library.import` (mode add), `cmd:local.addRoot`, `cmd:local.hide`, `cmd:local.restoreHidden`, `crates/engine/src/cmd/browse.rs`, `crates/ui-egui/src/panels/left.rs` (`local_section`), `crates/ui-egui/src/panels/grid.rs` (`folder_header`) | Local: browse Pictures / Desktop / Downloads / Home or any folder without adding it (breadcrumb, Include subfolders, Add N to My Photos); browsed photos stay out of All Photos, albums and counts (album commands skip them and the album menus say to Add to My Photos first, issue #684); folders picked with Browse Folder… (or Keep in Local / Add to Local on a folder row) stay listed across restarts, and browsing a folder inside a listed one highlights it inside that tree (opened down to it, siblings reachable) instead of adding a root; edits go to XMP sidecars; importing promotes them; right-click a top-level location → Remove from Local hides the shortcut only (nothing on disk or in the catalog changes), saved with the UI state, and “Show N hidden locations” (or Browse Folder… on it) brings it back; a folder is one location however its path is spelled (`/` or `\`, trailing separator, `.`/`..`, drive-letter case; `folder_key` in `crates/catalog/src/query.rs`); browsed photos nobody changed are forgotten once their folder has not been browsed for 30 days (Settings → Performance → Local folders, 0 = never; checked when the library opens, or `cmd:library.forgetLocal` with `dryRun`; files and sidecars stay, browsing again brings them back; see `crates/catalog/src/local.rs` for what counts as a change); browsing a folder again forgets at once the untouched records of its files that another app deleted or moved, so they no longer fail to load on every visit (issue #1392; changed ones stay, shown as missing); browsing a folder keeps the active photo when the folder shows it, else makes the folder's first photo active as it arrives, the same rule as albums and the other sources (`Session::keep_active_in_view`; issue #1447: before, a library photo stayed active, so Photo Grid marked nothing and Detail, the histogram and the info showed a photo the folder doesn't hold) | | LR-IMP-SIDECAR-SPLIT | Separate XMP sidecar variants | P2 | ⬜ | `cmd:library.xmpPreferences` | sidecar naming option exists (stem/full), no split sidecars | -| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | -| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). | +| LR-IMP-FORMATS | Supported formats | P0 | 🟡 | `crates/codecs/src/lib.rs`, `crates/raw/src/lib.rs`, `crates/raw/src/jxl.rs` | JPEG, PNG, TIFF (1- to 64-bit integer, 16- to 64-bit float, incl. bilevel and CCITT fax), WebP, JXL, PSD, GIF, BMP (lossy wide-gamut JXL decodes to the right colours: jxl-oxide desaturated XYB rendered to P3/Rec. 2020, worked around in `crates/codecs/src/jxl.rs`); DNG (incl. lossy / Smart Preview DNG and DNG 1.7 JPEG XL tiles/strips: lossless ones decode bit-exactly on synthetic CFA and LinearRaw files, lossy ones are restored to the declared linear primaries without clipping or gamut mapping, checked on one real file with 108 lossy tiles, and any tile that fails to decode makes the file preview-only; DNG 1.7 row/column-interleaved rasters (`RowInterleaveFactor` / `ColumnInterleaveFactor`) are undone before the mosaic is read, so a file from Adobe's converter with a 2×2 interleave no longer decodes as four scaled-down copies in the wrong colours; a dimension the factor does not divide decodes with short trailing fields; verified on 4 real ILCE-6400 JPEG XL DNGs rendering bit-identical to their non-interleaved twins, factors above 2 rest on the plain reading of the tag; a single-chunk JPEG XL preview IFD is offered as the embedded preview; a DNG whose samples pile up at one value well below its stated `WhiteLevel`, as a camera app's 12-bit Bayer DNG that states 4095 and clips at 3567, has those samples raised to the white level on decode, so highlight reconstruction sees the clipped areas instead of white balance tinting them magenta, issue #548, `crates/raw/src/saturation.rs`), CR2 (colour-filter layout from the file's `CR2CFAPattern` tag, issue #85), ARW (pre-2017 bodies: white balance and black level from the encrypted `SR2SubIFD`, issue #148, its `WB_RGGBLevels` being the white balance the camera applied, issue #535; the DSLR-A900's packed 12-bit strip, two pixels per three bytes, read only when the strip is exactly 1.5 bytes per pixel, with its camera-JPEG window centred, checked on one CC0 file (A850 unmeasured); downsized Sony lossless YCbCr 4:2:0 / 4:2:2 tiles decoded to linear RGB, verified on private ILCE-7M4 3:2 & 4:3 examples including 16-bit full-resolution export; Sony DSC-R1 .SR2 decodes, its big-endian 16-bit words read in the order that fits the sample range, checked on the CC0 3221 sample; its crop and white balance live in the SR2SubIFD and are not read yet; ARW6 Compressed / Compressed (HQ) of the ILCE-7RM6 (`crates/raw/src/vendor/arw6.rs`, `crates/raw/src/llvc.rs`; 12-bit codes exact against a black-box oracle on 8 CC0 files, FF over the full frame, APS-C in the interior (LibRaw's own edge rows differ); output through our own companding curve, measured from CC0 lossless / ARW6 pairs; geometry checked against the camera JPEG, `docs/arw6-compression.md`; the ILCE-7M5 writes the same codec, unverified)), NEF (uncompressed, lossless and lossy compressed 12/14-bit, `crates/raw/src/vendor/nefc.rs`), RAF (uncompressed, lossless/lossy compressed Bayer and X-Trans; `crates/raw/src/vendor/rafc.rs`, full sensor verification on 13 bodies, `docs/raf-compression.md`), RW2 / Leica RWL / Panasonic RAW in every raw format (compressed 4 and 6, the prefix-coded strips of format 8 from the GH6 on, packed 2/5/7, the 16-bit words of the 2005–2007 bodies; black levels, in-camera aspect crops and mapped-out defect pixels; 178 CC0 files from 118 bodies checked, `crates/raw/src/vendor/rw2.rs`), PEF, ORF (uncompressed; colour-filter layout from the file's Exif `CFAPattern`; 12.8 bit/px 16-byte blocks of ten 12-bit samples + a zero pad byte, E-300 / E-330 / E-500 verified on CC0 files, E-M5 Mark II / PEN-F high-res files decode smooth but CFA and black unverified), Kodak DCS520C TIFF (a two-component 12-bit lossless JPEG strip, the two components the even and odd columns; the file's `GrayResponseCurve` table linearises the companded samples to 12 bits, black measured per CFA position, white balance from the `KodakIFD`; `RawFormat::KodakDcs`, `crates/raw/src/vendor/kodak.rs`, checked on the one CC0 sample against its IFD0 thumbnail), Hasselblad 3FR and FFF (a TIFF without a DNG version tag that describes its CFA IFD, levels, crop and white balance with DNG's tags; `RawFormat::CfaTiff`, read by `dng::decode_as`, RGGB when the file states no `CFAPattern`, checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; an FFF has no colour matrix of its own and takes the camera's from the original 3FR header it embeds in tag `0xC51B`; neither carries a JPEG, so their uncompressed 8-bit RGB preview (1217 × 913 in the H4D-40 FFF, 218 × 218 in the CFV 3FR) is the embedded preview, handed on as a baseline TIFF, `crates/raw/src/vendor/hasselblad.rs`): uncompressed (X1D, X1D II, X2D, CFV 100C) and Hasselblad's lossless JPEG with scan selection value 8 (every FFF; H3D, H4D and CFV-50c 3FR), `ljpeg::decode_hasselblad`, its rule measured on 14 CC0 files with the ljpeg-explorer tool, Samsung SRW without compression (NX5, NX10, NX11 and the NX20-generation bodies NX20, NX200, NX210, NX1000 and NX1100 as 12-bit packed samples in either bit order, EX1 and WB2000 as 16-bit words; layout, camera-JPEG framing, levels and white balance from the maker note, `crates/raw/src/vendor/srw.rs`; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (EOS R6 Mark III `ColorData` version 66: as-shot white balance and black level from the file, white level from the data, `docs/cr3.md`) (the shot's aspect ratio from the maker note's `AspectInfo`); full sensor samples match the reference on M50/R100/R8/RP, incl. RP 1.6× crop mode (`crates/raw/tests/cr3_corpus.rs`, `docs/cr3.md`). EOS R5 Mark II C-RAW and RAW decode too (the QP map's first-symbol literal and 23-zero escape, odd-height Bayer planes): 4 CC0 files pinned, every QP map of 33,665 private C-RAW files decodes exactly, and full-frame images match the camera JPEG. Unsupported coding variants retain metadata and use the embedded JPEG when available; HEVC preview decoding remains unsupported. Missing: CR3 unverified CRX variants, compressed ORF, compressed Samsung SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini: preview only with the reason), NEFs labelled compressed (34713) but stored uncompressed without a `0x0096` table (Z 6 packed 14-bit, D850 12-bit uncompressed), Nikon High Efficiency NEF (HE / HE★ from the Z 8, Z 9, Z 6III and Z f: compression 34713 with a JPEG XS codestream and no `0x0096` table; no public specification, so preview only with the reason "Nikon High Efficiency NEF", issue #193; Lossless compressed NEFs from the same bodies decode), NEF "lossy after split" (preview only: imported with `preview_only` = the decoder's reason, rendered as a rendered JPEG, flagged by a grid badge and loupe/Edit/Info notices, `previewOnly` in `catalog.query`; Reload clears it once the file decodes, and the notices carry a Reload from Disk button; each flag is stamped with the app version that set it, and a library opened in another version rechecks its preview-only raws in the background, so a raw a newer release decodes is cleared without a manual Reload, issue #662, `library.recheckPreviewOnly`), TIFF-based raws whose raw data sits in a private block (Phase One / Leaf IIQ, Canon EOS-1D / 1Ds and the Kodak DCS Pro / DCS760C TIFFs (compression 65000): IFD0 is only a reduced RGB image next to a much larger declared picture) are recognised from that structure and import as `preview_only` with the reason, instead of opening as a thumbnail-sized ordinary image (`thumbnail_shell` in `crates/raw/src/lib.rs`), Recognised raws LightCraft cannot decode open as `preview_only` with any usable embedded JPEG, also Canon CRW, Minolta MRW and Sigma X3F (containers recognised by magic, preview found by a structural JPEG scan), Kodak KDC (IFD0 without `ImageWidth`), Leaf MOS and Epson ERF (scan; ones with no JPEG still fail with "without an embedded preview", AVIF decode (HEIC decodes only behind the non-default `heif` feature, which the release packages enable: `crates/heif`, heic-rs for the container and HEVC (bit-exact against libde265 on iPhone tiles) plus our own colour conversion, matching libheif within 1 code value on iPhone-style grids (VUI full range, Display P3 ICC, nclx, 10-bit, irot/imir/clap, gain map/depth ignored); 4:2:0/monochrome only, 4:2:2/4:4:4 Canon/Sony HIF refused; same policy as PhotoCraft), palette (indexed-colour) TIFF (refused by the `tiff` crate); transparency is flattened onto white, in linear light, where the working image is built (`to_working` in `crates/codecs/src/lib.rs`): renders, previews, exports and contact sheets no longer turn transparent areas black, issue #1212; median ΔE vs the camera JPEG 2.2–7.6 on 11 of the 12 files from 10 bodies checked, 10.7 (5.7 registered) on the backlit WB2000 sample). 12-bit NEF black level: maker note `0x003d` is in 14-bit units (D750, D780, D850, D7500, Z 50 checked). Lossy type 2 NEF curves of version `0x44 0x40` (D780, D6, Z 5, Z 6, Z 7, Z 7_2) span a quarter of the sample range, read from the table's own point spacing. Import probes (`probe_info`) of compressed NEFs decode only the first 128 rows, the ones the masked-trailing-column test reads in both the probe and the full decode (the D5100 and D7000 write 8 masked columns at 0 with no maker-note tag for them, compressed and uncompressed alike), instead of the whole Huffman stream: ~3 ms instead of the full decode time per file (386 ms on a 24 MP D750 file), issue #708. CR3 lossless CRX Bayer and version 0x100/0x200 C-RAW decodes to sensor pixels with timed white balance, black/white levels and file crop (the shot's aspect ratio from the maker note's `AspectInfo`); the Hasselblad Lunar, a Sony NEX-7 under Make HASSELBLAD, decodes as an ARW: a HASSELBLAD file with Sony's raw coding (compression 32767 or tag `0x7010`) goes to the ARW reader, and the body for its `Tag2010` layout comes from the maker note's `SonyModelID`, checked on the CC0 4051 file, issue #1275; an uncompressed MOS, whose IFD0 is the 16-bit mosaic with no Make tag, is recognised by Leaf's data block `0x8606` instead of opening as a greyscale image of its mosaic, issue #1275), and any decode failure of a recognised raw, not only an unsupported variant | +| LR-IMP-CAMERA-COVERAGE | Camera coverage, verified per model | P0 | 🟡 | `crates/raw/tests/corpus.rs`, `crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/evidence.tsv`, `docs/raw/coverage-sweep.tsv`, `xtask/src/main.rs` | a supported container is not the same as every camera that writes it decoding correctly: ~55 CC0 corpus files plus 6 exact-sensor CR3 cases and 4 pinned EOS R5 Mark II files (plus ~60 NEFs checked by hand and 178 Panasonic / Leica RW2, RWL and RAW files from 118 bodies, checked against each file's own JPEG) against the >1,000 models Lightroom lists; per-model differences still surface (e.g. the CR2 colour-filter layout differed by model until it was read from the file's own tag, issue #85, checked on 35 CR2 bodies; CR2 black levels were read from border columns light already reaches, left of `SensorInfo`'s border, so CR2s rendered dark with crushed shadows (EOS 6D: 2591 instead of 2047), now measured from the masked columns only, checked on 8 corpus bodies; pre-2017 Sony ARWs opened green until their scrambled white balance / black level were read, issue #148, checked on 16 ARWs from 15 Sony bodies; their white balance now comes from the encrypted `SR2SubIFD`'s `WB_RGGBLevels`, the gains the camera applied, which 14 bodies (DSLR-A450 … A700, NEX-3/5/C3, SLT-A33/A35/A55, ILCE-6001, UMC-R10C) had no other source for and which the maker-note estimate read before missed by up to 100 % on preset shots, issue #535, checked against ExifTool on 100 decoded ARWs from 87 Sony bodies; the encrypted SR2 black level is now read from its directory entry (the DSLR-A450/A500/A550/A700 read 354–975 instead of 512), checked on 126 ARWs from 97 Sony bodies, and downsized lossless M/S ARWs use their measured black level 1024 instead of 512, checked on 12 files from 4 bodies, issue #535, and on a fifth, the ILCE-7CM2 (issue #697: 0.5.0 rendered its lossless M file with that 512 pedestal, the darkest half of a dusk scene +11 L* and blue, the lamplight white; its darkest 0.1 % per channel sits at 1017/1020/1024, and with 1024 the render is within +3 L* of the camera JPEG; the body's compressed and uncompressed "M-size" files are Bayer at 4736×3132 with black 512, two public ones in the corpus); pre-2017 Sony bodies shot in the camera's 16:9 mode (ILCE-7SM2, DSLR-A580) now get the 16:9 crop of their camera JPEG instead of the whole 3:2 frame with padding columns, issue #535; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked; private Z 8 crop verified; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone); the D7100, whose data holds picture beyond the camera's frame, develops at Lightroom's frame (the camera JPEG's size) in full frame and 1.3x crop, so Lightroom crops line up (#1248).; CR2 white levels came from masked rows holding samples at 16383, so on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1) clipped highlights sat at 81–98% of the range and were not treated as clipped, now taken from the active area only, issue #1272). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Pentax PEFs without area tags (KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z) no longer keep their masked border: black, empty and saturated lines are trimmed against the maker-note BlackPoint, issue #1271, measured on 38 CC0 PEFs from 30 bodies.; Canon PowerShot and EOS M CR2s whose `ColorData` is stored as bytes opened green until the bytes were read as 16-bit words, 25 files from 25 bodies checked). Compressed RAF sensor arrays verified on 13 Fujifilm bodies (`docs/raf-compression.md`). ILCE-7RM6 ARW6 12-bit codes verified on 8 CC0 files (FF and APS-C, both compression modes). Sony ILCE-7CR (61 MP): lossless compressed L and M and compressed ARW2 samples of one scene in the corpus (black level, default crop, as-shot gains, lossless vs compressed mosaic agreement, fitted camera look), and all nine public codings (also uncompressed, small and APS-C crop) decode and fit. Grow the CC0 corpus per model and test each sample decodes with plausible colour. Nikon CropArea and untagged-preview colour-space regression tests; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Canon PowerShot G9, which writes no `ColorData`, takes its as-shot white balance from the WBInfo row (`0x0029`) of the preset ShotInfo names, issue #1273, checked on 1 CC0 file against neutral areas of the camera JPEG (within 0.1 %).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). The Olympus E-10 (no ImageProcessing directory) takes its as-shot gains and its black level (`0x1012` in `0x102c` ValidBits units: 128 at 12 bits) from the main maker note, issue #1273: its render against the camera thumbnail goes from ΔE76 18.5 with a rejected fit to 11.5 with the fit accepted (measured by AlexChambers).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone), and the Nikon D90 likewise on 10 of 550 NEFs (42 filler columns on the right that developed as a pink band; green clips below red and blue).; the Nikon D4, which records no CropArea, gets its image area and white level measured on 148 NEFs against the camera JPEG (optical-black stripes and magenta clipped highlights gone). Public raw.pixls.us coverage sweep (#1327): 30 more models verified on 40 CC0 files, with SHA-256 and FNV-1a sensor hashes pinned (`crates/raw/tests/coverage_sweep_corpus.rs`, `docs/raw/coverage-sweep.tsv`; `docs/raw/coverage.py`: 515 → 545 verified rows). Render/reference luminance correlation is 0.6801 to 0.9998; eleven candidates excluded for identity, provenance or missing/mismatched reference images. Nikon D6 / Z 6 / Z 7 uncompressed and Z 8 HE variants remain preview-only. Own-matrix DNG tone differs from the camera JPEG (#1277); sensor verification is not colour calibration. | | LR-IMP-PREVIEW-READ | Preview reads the header, not the raw | P0 | ✅ | `crates/raw/src/preview.rs` (`bounded_preview`, `embedded_preview_from_range`, `embedded_preview_covering`), `crates/engine/src/files.rs` (`preview_window`), `docs/raw-preview-reads.md`, `crates/engine/src/files.rs` (`preview_window`, `planned_preview`), `crates/raw/src/sparse.rs` (`preview_plan`) | a grid thumbnail and a loupe preview of an unedited raw show the camera's embedded JPEG, which sits near the start of the file (a 24 MP Fuji RAF keeps its 5.6 MB JPEG in a 56 MB file), so the preview loader reads the header and that JPEG instead of the whole file: 56.5 MB → 5.9 MB and ~10 s → 0.96 s measured on one real RAF over a GVFS/SMB Wi-Fi share (byte-identical preview pixels; `LIGHTCRAFT_PROFILE=1` reports the bytes); the RAF pointer is read from its fixed header fields, a CR3's boxes and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads and nothing else; a cached render as stand-in, an edited photo and a raw without an embedded JPEG still render; the formats it cannot describe (CRW/MRW/X3F are found by scanning, NEF/ORF/PEF probe from samples) keep the whole-file path, and a lying pointer, a short read or a file that changes underneath us falls back to it rather than losing the preview. An unedited raw's **grid thumbnail is that camera JPEG itself**, so no sensor decode is scheduled behind it (`Session::embedded_thumb_is_final`, `grid::skips_the_real_render`): drawing a 512 px thumbnail used to decode the whole raw — 45 s a photo at 1.2 MB/s, 8.3 GB for a 149-raw folder — and the queue it built delayed the render the user was waiting for. Stand-ins and grid thumbnails decode the smallest embedded JPEG that covers the requested size and is a copy of the largest (aspect ratio within 1 %, same component count), not always the largest, on the whole-file and the plan route alike: an A7R VI's 1616 × 1080 JPEG (0.3 MB) instead of its 9984 × 6656 one (5.6 MB), and mid-sized NEF / RW2 JPEGs | | LR-IMP-CULL-AT-IMPORT | Culling analysis at import | P2 | 🟡 | `cmd:photo.analyze` | run Assisted Culling on the imported photos (they're selected after an import); not automatic | | LR-IMP-MOVE | Move on import [Classic] | P1 | ✅ | `cmd:library.import` (`mode` move), `crates/engine/src/import_move.rs`, `crates/engine/src/tests_import_move.rs`, `crates/ui-egui/src/import.rs` | Transfer ▸ Move with Copy's destination, folders (day / month / one folder / custom template), rename template and example destination; XMP sidecars (both namings) move along; each source is removed only after its destination is written (hard link on the same volume, else copied, synced and compared byte for byte) and its catalog record is saved; failed, duplicate, unchecked files keep their sources, taken names get -1, -2…, never overwritten; files already in the destination/library are added in place; sources that can't be removed (read-only card) are kept and reported (`kept`). Undo removes the photos from the library but leaves the moved files at the destination. No Copy as DNG while moving | @@ -96,7 +98,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-BYDATE | Browse by date | P1 | ✅ | `cmd:library.filter` (`date`), `crates/ui-egui/src/panels/left.rs` (`date_row`), `crates/catalog/src/query.rs` (`date_groups`) | year → month → day tree with library-wide counts; a row filters the current source by that date. Active filters show as removable chips under the grid header (`crates/ui-egui/src/panels/chips.rs`, with “+N more” and Clear all), the header counts “N of M photos” (matching of the source's total), a badge on the top-bar filter icon counts them, and an empty result says no photos match the active filters | | LR-LIB-ALBUM | Albums | P0 | ✅ | `cmd:album.create`, `cmd:album.rename`, `cmd:album.delete`, `cmd:album.addPhotos`, `cmd:album.removePhotos`, `cmd:dialog.newAlbum`, `crates/engine/src/tests_albums_local.rs`, `crates/ui-egui/src/tests_panels.rs` | photos dragged from the grid onto an album row are added to it (LR-BEHAV-DRAGDROP); albums hold library photos (issue #684): `album.create {addSelected}`, `album.addPhotos` and `album.toggleTarget` (B) skip browsed Local photos and return `skipped` (a toast says so), a Local-only selection is an error and leaves Photo ▸ Add to Album, the grid's right-click Add to Album, a folder row's Add Selected Photos and a drop onto an album row off with the hint to Add to My Photos first (`button:contextAddToAlbum`, `button:contextAlbum:<id>`); right-click a photo ▸ Add to Album lists the albums and ends with New Album…, which creates one holding the selection — also when the library has no album yet (issue #676; `button:contextNewAlbum`); New Album's "Include selected photos" (`check:albumIncludeSelected`, on by default; `dialog.newAlbum {includeSelected}`) leaves the selection out when unticked, and, as in Lightroom, is only shown when something is selected (never for a folder) | | LR-LIB-FOLDER | Folders of albums | P0 | ✅ | `cmd:album.create` (`folder`, `parent`), `cmd:album.move`, `cmd:album.reorder`, `cmd:album.sort`, `cmd:dialog.newAlbum` / `cmd:dialog.newFolder` / `cmd:dialog.smartAlbum` / `cmd:dialog.newSmartAlbum` (`parent`), `crates/ui-egui/src/panels/left.rs` (`albums_tree`, `folder_menu`, `disclosure_triangle`), `crates/ui-egui/src/tests_panels.rs` | Delete removes a folder and its nested albums as one undoable edit, keeps photos in the library and returns a deleted album view to All Photos (issue #501); folders nest and fold with the same disclosure triangle as Local / By Date / Folders / Keywords (`albumToggle:<id>`; the row folds them too; open by default); right-click a folder ▸ New ▸ Create Album… / Create Smart Album… / Create Smart Album from Filter… / Create Folder… creates inside it (the `dialog.new*` commands take `parent`), opening the folders down to the new row; creating is only allowed inside folders; moving: drag an album or folder row onto a folder (outlined when allowed; a closed folder opens after 0.6 s under the pointer; the "Albums" header takes an item that sits in a folder back to the top level while it is dragged), the Move to menu or `album.move` — one undo step each; not onto a plain album, itself or something inside it; drop on the top / bottom half of an album or the edge of a folder to place it there by hand (`cmd:album.reorder`, a line shows the place; ordered per folder, folders stay first, new albums go after the ordered ones; right-click ▸ Sort Contents A–Z or + ▸ Sort Albums A–Z = `cmd:album.sort`) | -| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists | +| LR-LIB-SMARTALBUM | Smart albums | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:library.holdView`, `cmd:library.refresh`, `crates/catalog/src/query.rs`, `crates/ui-egui/src/panels/filterbar.rs`, `crates/ui-egui/src/lib.rs` (`album_counts`) | saved filters (rating/flag/label/kind/edited/keyword/camera/lens/date range/text/album) or a rule set from the rule editor (match all / any / none, nested groups; see LRC-LIB-SMARTCOLL), live; sidebar counts are cached per catalog revision (and per minute while an “in the last…” rule is used), counted without building id lists; View ▸ Hold View Until Refreshed (`library.holdView`, off by default) keeps a photo that an edit made stop matching (a smart album, Picks, the filter bar) in the view until Refresh View, Reshuffle or another source, filter or sort | | LR-LIB-SHARED-ALBUM | Shared albums | P2 | ⬜ | | needs a sharing service | | LR-LIB-OFFLINE | Keep album offline | P2 | 🚫 | | not applicable: local-first library | | LR-LIB-TARGET | Target album | P2 | ✅ | `cmd:album.setTarget`, `cmd:album.toggleTarget` | see LRC-LIB-COLLECTIONS | @@ -104,11 +106,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-FLAG | Pick / reject flags | P0 | ✅ | `cmd:photo.pick`, `cmd:photo.reject`, `cmd:photo.unflag`, `cmd:photo.flag` | P pick, X reject, U clear; Shift+P picks and advances in both grids (Presets elsewhere); Shift+X/U and legacy Shift+Z advance; no flag cycle; rejection does not delete the file | | LR-LIB-LABEL | Colour labels | P1 | ✅ | `cmd:photo.label`, `cmd:label.setNames`, `cmd:label.names`, `cmd:dialog.labelNames`, keys 6–9 in `crates/ui-egui/src/shortcuts.rs` | Photo menu and grid context menu (coloured, named), Info-panel swatches, label dot plus translucent colour on square-grid/filmstrip surrounds and photo-grid footers (photo pixels unchanged); colour-label toasts have pale matching backgrounds; setting/clearing through keys (including Shift), menus or Info swatches shows the same bottom toast as ratings, with the colour/custom name; editable label names (undoable, journaled); no purple key (as in Lightroom), no custom extra labels | | LR-LIB-KEYWORD | Keywords | P0 | ✅ | `cmd:panel.keywords`, `cmd:photo.setMeta` (`addKeywords`/`removeKeywords`), `cmd:keyword.list`, `cmd:keyword.rename`, `cmd:keyword.delete`, `cmd:keyword.merge`, `cmd:keyword.suggest`, `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/left.rs` | left-panel keyword tree with counts (click filters, children included; context menu rename / merge / delete / add / remove); library-wide ops are one undo step and replay from the op log; suggestions (co-occurring / most used / completions) in the Keywords panel; keywords reorganize by drag and drop in the Keyword List (LRC-LIB-KEYWORDS) | -| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE); with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | -| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; no visual-similarity auto-stack | +| LR-LIB-PEOPLE | People / faces | P2 | 🟡 | `cmd:faces.detect`, `cmd:view.people`, `cmd:view.faceBoxes`, `cmd:photo.removeRegion`, `cmd:photo.setRegion`, `cmd:library.filter`, `crates/ui-egui/src/panels/people.rs`, `crates/catalog/src/query.rs` (`people`), `crates/engine/src/media.rs` (`face_job`), `crates/meta/src/xmp.rs` | MVP: named face regions read from XMP (MWG-RS, read-only, see `docs/xmp-interop.md`; clipped to the photo; Lightroom's `Rotation` of ±π/2 and π handled, mirrored orientations taken as written; boxes follow Rotate Left/Right and flips; a sidecar stating `mwg-rs:Regions`, even empty, replaces the photo's on re-read, one without leaves them) become cards in the People view (View ▸ People, or the People button in the toolbar): a close-up of the person's largest face, name and photo count; a click filters the grid (`person` in `library.filter`, `person:` search token, filter chip). Face boxes in the loupe can be switched off (View ▸ Face Boxes, toolbar) resized with eight drag handles and removed with the × shown on hover (catalog-only and undoable, one undo step per drag; the sidecar is never rewritten for either); no moving a box by dragging it yet. Photo ▸ Detect Faces (`cmd:faces.detect`) finds faces with the YuNet detector (pure Rust, about 200 ms a photo, matches OpenCV's own YuNet on a 45-photo test set; the 232 KB model is a one-time download offered in the same step, never part of LightCraft) and adds them as unnamed boxes; names are suggested from the faces already named (recognition models are opt-in downloads, run on the CPU in a background thread; a face is named only when you confirm it); a person's page shows their cropped faces and the unnamed faces that look like them (LRC-LIB-PEOPLE), with a user-triggered ↑ action or targeted A key to name all shown suggestions at or above one score in one undo step; with recognition on, a background scan finds faces in photos that have none and embeds every face (one to three worker threads, raws through their embedded preview); an Unnamed faces section orders faces by likeness and names a selected group at once; no separated clusters, no manual tagging, no writing regions back | +| LR-LIB-STACK | Stacks | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:stack.toggle`, `cmd:stack.setTop`, `cmd:stack.remove`, `cmd:stack.auto`, `cmd:photo.delete`, `crates/catalog/src/stacks.rs` | grid/filmstrip count badges, expand/collapse, auto-stack by capture time; delete / remove folder drop the photo from the stack so `i/n` updates (#1493); no visual-similarity auto-stack | | LR-LIB-VERSIONS | Versions | P1 | ✅ | `cmd:version.create` | see section M | | LR-LIB-DELETE | Delete / Recently Deleted | P0 | ✅ | `cmd:photo.delete`, `cmd:photo.restore`, `cmd:photo.deletePermanently` | Delete and Delete Permanently retain surviving selected photos; if none remain, select the next visible survivor (previous at the end) in the current source/filter/sort; permanent deletion removes catalog records, not files on disk; Restore and Delete Permanently in the Photo menu and the photo context menus for photos in Recently Deleted (before, the UI had no way to restore); importing a file that is in Recently Deleted again restores the photo with its edits, rating and albums (issues #298, #706: before, the import skipped it as "already in the library" while the grid showed it deleted) — the review dialog checks it, badges it "Will be restored", counts it as "in Recently Deleted" rather than "already in the library", and its choice Restore them / Import as new / Leave them starts on Restore; with Leave them such a file starts unchecked and Check All and the file-type toggles leave it out, but a click still checks it and it is restored (issue #1285: before, the click did nothing and the "In Recently Deleted" badge was drawn over the checkbox, which now sits above it); dropped files restore too ("Restored 1 photo from Recently Deleted"); a deleted photo matched by content whose file is gone is relinked to the imported file; Photo ▸ Empty Recently Deleted (`cmd:library.emptyRecentlyDeleted`, one undo step, shown while the trash is the source); import takes `onDeleted` restore (default) / skip / fresh for files in the trash (`cmd:library.import`; the report lists `restored`, and with skip flags the duplicate `existingDeleted`); browsing a folder in Local (`cmd:library.browse`), Auto Import and a Lightroom catalog import never restore a deleted photo; no confirmation dialog, no auto-purge after N days (needs a deleted-at date in the catalog); Delete asks first ("Move this photo to Recently Deleted?") from the key, the Photo menu and the photo context menu, on by default and switched off in Settings ▸ General (#1239: before, it was off by default and the context menu never asked); no auto-purge after N days (needs a deleted-at date in the catalog) | -| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| LR-LIB-REMOVE-ALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos`, `crates/engine/src/tests_album_remove.rs`, `crates/ui-egui/src/tests_album_remove.rs` | changes the album only: the photos stay in the library, their library folder and their folder on disk (issue #1805); `id` defaults to the album on screen; returns `{removed, album}`. In a normal album ⌫ removes from the album (as in Lightroom; no confirmation, one undo step), in a smart album it changes nothing and says to edit its rules | | LR-LIB-DUPLICATE | Duplicate a photo | P2 | ✅ | `cmd:photo.duplicate` | Photo ▸ Duplicate: a real `-copy` file with the same settings, metadata and albums (virtual copies share the file) | | LR-LIB-RENAME | Batch rename | P1 | ✅ | `cmd:photo.rename`, `cmd:photo.renamePreview`, `cmd:photo.renameTokens`, `cmd:dialog.rename`, `crates/engine/src/rename.rs` (`TOKENS`) | template tokens {name} {num} {seq:N} {date:%Y%m%d} {folder} {camera} {lens} {iso} {rating} {title} {creator} {ext} (shared with export naming and import rename); a Tags picker beside every template field (Rename Photos, import Rename, export File name) lists each tag with meaning + live example, the date directives and the rules, and inserts the clicked tag at the text cursor; unknown tags are flagged; preview; renames files on disk with their XMP sidecars (a stem sidecar another file still shares, e.g. raw + JPEG, is copied rather than taken), never overwriting (-1, -2… suffixes; a change of letter case only is checked by file identity, so on case-sensitive volumes `img_1.jpg` next to `IMG_1.JPG` is a collision), rolls back on failure (a file that can't be moved back is reported old → new and the library follows it as an undoable partial rename; the same for undo/redo); undo/redo move the files; virtual copies follow | | LR-LIB-CAPTURETIME | Edit capture time | P1 | ✅ | `cmd:photo.setCaptureTime`, `cmd:dialog.captureTime`, `crates/catalog/src/dates.rs` | set (the other selected photos shift by the same amount, or `each`), shift by days/hours/minutes, time-zone shift; one undo step, journaled; Info panel button; no “revert to original capture time”. Impossible calendar dates cannot anchor relative shifts; rejection preserves metadata, selection, Undo and Redo. | @@ -117,25 +119,26 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-LIB-CULL | Assisted culling | P2 | 🟡 | `cmd:photo.analyze`, `cmd:dialog.cull`, `crates/pipeline/src/cull.rs` | Photo ▸ Assisted Culling…: focus score (0–100), clipping, burst grouping (look-alike shots within 10 s) with the sharpest marked; reject below a focus score, pick each burst's best; classical measures — no eyes-closed / expression detection | | LR-LIB-ACTIVITY | Comments & likes | OOS | 🚫 | | | | LR-LIB-QUICKCOLL | Quick collection [Classic] | P2 | ✅ | `cmd:album.toggleTarget`, `cmd:album.clearQuick` | B in the grids | -| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; no “Set Copy as Master” | +| LR-LIB-VIRTUALCOPY | Virtual copies [Classic] | P1 | ✅ | `cmd:photo.virtualCopy`, `cmd:photo.delete`, `crates/engine/src/cmd/organize.rs` | “Copy N” badge, stacked with the original, same albums, no XMP writes; deleting a copy dissolves or renumbers the stack (#1493); no “Set Copy as Master” | ## C. Views & navigation (VIEW) | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own | +| LR-VIEW-PHOTOGRID | Justified photo grid | P0 | ✅ | `cmd:view.photoGrid`, `cmd:library.groups`, `cmd:library.sort` (`group`), `crates/ui-egui/src/panels/grid.rs`, `crates/catalog/src/dates.rs` | grouped by capture / import / edit date (day headers “Wednesday, 30 September 2026 · 12 photos”, months or years when zoomed out, or a fixed day/month/year/none choice in the sort menu); sticky header; clicking a header selects its photos; stacks never split; scrolls to the active photo only when it changes (or the grid comes back on screen), never on its own; a mouse-wheel notch scrolls a third of the grid's height, in both grids, as in Lightroom Classic (`theme::WHEEL_STRIP_SHARE`; trackpads scroll the points they report) | | LR-VIEW-SQUAREGRID | Square grid | P0 | ✅ | `cmd:view.squareGrid` | same date headers as the photo grid | | LR-VIEW-DETAIL | Single-photo view | P0 | ✅ | `cmd:view.detail`, `crates/ui-egui/src/panels/detail.rs` | a “Rendering…” pill at the bottom of the photo (widget `loupe:rendering`; above the Navigator when that is in the way) once a render has been on its way for 0.5 s with nothing new on screen: over a stand-in (the raw's embedded JPEG, a thumbnail), the previous render after an edit, or, zoomed in, the magnified frame waiting for its window; Before views watch their own renders. Always on (Lightroom Classic makes it a View Options choice). When the render fails behind a stand-in, a caution pill says “This photo can't be opened” (“Original offline · no smart preview” when that is why) with the reason on hover and for screen readers (widget `loupe:renderFailed`; `ui.inspect` → `loupe.failed`) | | LR-VIEW-EDIT | Edit view | P0 | ✅ | `cmd:panel.edit` | | | LR-VIEW-FULLSCREEN | Full-screen preview | P1 | ✅ | `cmd:view.fullScreenPreview`, `cmd:view.enterFullScreen` | photo on black, arrows step, Esc exits; ⇧⌘F window full screen | -| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways; it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | -| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-FILMSTRIP | Filmstrip | P0 | ✅ | `cmd:view.filmstrip`, `crates/ui-egui/src/panels/detail.rs` | the photo context menu on right-click; the mouse wheel scrolls it sideways, a third of its width per notch as in Lightroom Classic (trackpads scroll the points they report); it follows the active photo only when that changes (centred if off screen), so a scrolled strip stays put; every selected photo is highlighted, the active one framed in white, as in the grid | +| LR-VIEW-ZOOM | Zoom & pan | P0 | ✅ | `cmd:view.zoomFit`, `cmd:view.zoom100`, `cmd:view.zoomIn`, `cmd:view.zoomOut`, `cmd:view.zoomToggle`, `cmd:view.clickZoom`, `cmd:view.navigate`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/headless.rs` | continuous native pinch / modifier-wheel zoom (Fit–800%, anchored under the pointer), two-finger scroll / touch pan with edge limits; Detail (including tools, Before/After and full-screen preview), Compare and Reference; fractional percentages preserve small gestures; image, picker and targeted-adjustment cursors stay steady between gesture events; keyboard steps 25–800 % (not 6–1600 %); Fill in the bottom bar; click / Z / Space eases to the chosen ratio (1:1 default, 2:1, 3:1, 4:1, 8:1). The loupe's cost follows the canvas, not the zoom (issue #323): the whole frame is rendered at about canvas size (at most the 2560 px preview source level unless a larger Preview size is chosen, and never above the GPU's texture side), and once the view is zoomed well past that (more than 25 %) the window on screen is rendered too, cut from a frame of at most the photo's own pixels, so beyond 100 % the GPU magnifies it (`region::plan`, `RenderRequest::window`, `Session::region_job`, `crates/ui-egui/src/region.rs`); 1:1 and 2:1–8:1 are sharp at any photo size. A slider tick takes 7–12 ms on a 1400 × 900 canvas and 11–25 ms on a 2800 × 1700 one at 100 % and 400 % of a 24 MP photo (M1 Max, GPU, `profile_slider_drag`); about 20–25 ms at 100 % on the big canvas is over a 16 ms frame. A slider drag renders the whole frame at full size where the GPU draws the view, so the fit view stays sharp while dragging, and at 0.6 of it where that would be too slow: on the CPU, with soft proofing or the HDR range shown, or for a control whose full-size drafts on this photo went over the 16 ms slider budget in an earlier drag (a LUT profile or refined mask the CPU renders, a photo past a GPU limit, a slow GPU); a draft's time is its render plus the frame that uploads and shows it. The size is chosen when a drag starts and kept until it ends, unless its full-size drafts take more than twice the budget, when it drops to 0.6 after three of them (`region::DragScale`, issue #330). Zoomed in, the whole-frame draft under the window is the 0.6 one (only the histogram and the window's stand-in use it). Render workers convert the pixels for their textures, not the UI thread. Handing GPU renders to egui without a CPU readback is #1228. A continuous pinch holds the render sizes (and the windows there are) until it is quiet. A fit view on a canvas up to about 3200 px (a Retina Mac) never decodes the original; larger displays get a window at fit, which does. Diagnostic overlays (Visualize Spots, Point Color range, HDR range) get windows too, tested equal to the whole-frame render's pixels. Photos whose spots or Auto Mask strokes read too far for one window (only photos longer than 8192 px) render the whole frame at the size it is drawn, as before windows; while a preset or profile is hovered, or over a picture that is not the frame (an unsupported raw's embedded JPEG), the whole frame stays canvas-sized. Before/After (side by side, stacked, `\\` and both wipes) gets the same windows on both sides, clipped to their pane or side of the line. Windows render on the GPU like whole frames (equivalence-tested against the CPU), in the browser build too (windows no wider than its GPU's texture side, at most 3072 px), and are not made while hovering a look or showing a diagnostic overlay, nor over a picture that is not the frame (an unsupported raw's embedded JPEG with another crop). No texture is larger than the GPU allows. Spots and Auto Mask brushes work while what a window reads fits one render (8192 px along an axis, 4096 in the browser): the window grows to hold the spot and its source patch, or the dabs near it, and if that would take more (a clone from far away at 8:1) the loupe keeps the whole-frame render instead, and spots with an automatic source pick it from a fixed-size render of the whole frame (so a window's choice can differ from a whole-frame render's, which picks at its own resolution). The wide stages (dehaze, clarity, highlights/shadows base) and Refine Edges on masks read a window margin capped at 768 px; measured on hard-edged blocks that costs at most a few levels at 8:1. the histogram and Navigator come from the whole-frame render. Compare and Reference get the same windows, one per pane (`Slot::CompareRegion`, `panels/compare.rs`), over each pane's whole-frame render (at most 2560 px); while either view is open two originals stay decoded (`MediaCache::set_full_capacity`), so one pane's window does not evict the other's source; a pinch, or a slider drag on the active photo in Reference, keeps the windows there are until it ends. Mask overlays and soft proofing get the same windows (the window carries the overlay or the proof; the proof renders on the CPU, so a tick at 100 % on a 1400 × 900 canvas takes about 36 ms instead of a full-size 24 MP proof). Verified: a 1:1 window of a 24 MP photo with tone and colour edits is the full-size export to within one level (`one_to_one_window_pixels_are_export_pixels`). Measured on Linux (Ryzen AI Max+ 395, Radeon 8060S, GPU, `profile_slider_drag`): median tick→shown 7 ms at fit, 33 ms at 100 % and 6 ms at 400 % on 1400 × 900; 21 ms at 100 % and 9 ms at 400 % on 2800 × 1700; 36 ms at 100 % with soft proof. Not windowed: the diagnostic overlays named above | +| LR-VIEW-FOCUSPOINT | Camera focus point overlay and zoom (beyond Lightroom) | P2 | 🟡 | `cmd:view.focusPoint`, `cmd:view.zoomToFocus`, `cmd:view.zoomToggle`, `cmd:photo.reload`, `cmd:photo.readFocusPoints`, `cmd:focus.pump`, `crates/meta/src/focus.rs`, `crates/meta/src/focus_read.rs`, `crates/engine/src/focus_scan.rs`, `crates/engine/src/tests_focus_scan.rs`, `crates/ui-egui/src/panels/detail.rs` (`focus_box`, `draw_focus_point`), `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_focus_point.rs`, `docs/focus-points.md` | an extra (Lightroom shows no focus points): the AF frame read from the file at import; photos imported earlier get it in the background from a header-only read of each file (about 32 KB, ~100 photos/s from a spinning disk; activity row, cancellable, resumable; Photo ▸ Read Focus Points) as a box on the upright photo, drawn in Detail, Compare and Survey through rotation and crop (View ▸ Show Focus Point, ⌥F); Toggle Zoom centres on it (View ▸ Zoom to Focus Point, on by default); Info panel row `AF-C · Wide · Animal Eye Tracking`; `meta.focus` in `photo.inspect`. Partial: Sony ILCE-1 and ILCE-7SM3 only (layout checked against 333 files); other Sony bodies and other makers not yet; manual focus and centre-only Wide reports show nothing; Compare panes share one pan, so Z centres on the active photo's point only | | LR-VIEW-NAVIGATOR | Navigator mini map | P1 | ✅ | `cmd:view.navigator`, `crates/ui-egui/src/panels/detail.rs` | shown while zoomed (bottom right); click/drag pans | -| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it | +| LR-VIEW-BEFOREAFTER | Before / after | P0 | ✅ | `cmd:view.showOriginal`, `cmd:view.beforeAfter`, `cmd:view.beforeAfterSplit`, `cmd:view.beforeAfterTopBottom`, `cmd:view.beforeAfterSplitTopBottom`, `cmd:beforeAfter.setBefore`, `cmd:beforeAfter.copyAfterToBefore`, `cmd:beforeAfter.copyBeforeToAfter`, `cmd:beforeAfter.swap`, `cmd:beforeAfter.resetBefore` | all four layouts; before = the import state (defaults + import preset) or a chosen history step / version / the current settings; copy and swap (View → Before/After Settings, History row menu). The chosen before lasts for the session; in side by side and top / bottom, clicks with the white-balance selector, colour samplers and targeted adjustment work in the Before view too (`crates/ui-egui/src/tests_before_after.rs`). Zoomed in, the side by side and stacked views each stay in their pane, with their labels under it. Click zoom animates both panes together, including cached window tiles; input follows the displayed image and render requests use the final size (#1821) | | LR-VIEW-COMPARE | Compare two photos | P1 | ✅ | `cmd:view.compare`, `cmd:compare.swap`, `cmd:compare.makeSelect`, `cmd:compare.set`, `crates/ui-egui/src/panels/compare.rs`, `crates/ui-egui/src/tests_compare.rs` | select / candidate, synced zoom + pan, arrows move the candidate; a filmstrip click puts the photo into the active side (a photo on the other side swaps), so the active photo is always one of the two shown; a ⌘/Ctrl- or Shift-click on the filmstrip changes the selection as in the grid while the two photos shown stay selected and the active photo is unchanged (#187); 1:1 to 8:1 sharp on both sides (zoom windows, see LR-VIEW-ZOOM); no zoom-link toggle; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LR-VIEW-SURVEY | Survey view [Classic] | P2 | ✅ | `cmd:view.survey`, `crates/ui-egui/src/panels/compare.rs` | selection tiled (≤ 48), keys act on the active photo, hover × removes | | LR-VIEW-INFOOVERLAY | Info overlay on the photo | P1 | ✅ | `cmd:view.infoOverlay` | off / file + date + size / exposure + camera; ⌘I cycles (I in full screen; elsewhere I stays the Info panel) | -| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen, every 4 s (`interval`), wrapping; Space pauses, ←/→ step, Esc ends | +| LR-VIEW-SLIDESHOW | Slideshow | P2 | ✅ | `cmd:view.slideshow` | View ▸ Slideshow (⌥⌘↩): the photos in view full screen from the selected one, played with the library's playback settings (see LRC-SS-PLAYBACK: fades, random order, repeat, previews prepared ahead; `interval` / any setting overrides one run); Space pauses, ←/→ step (a cut), Esc ends; playback controls on pointer movement | | LR-VIEW-SECONDWINDOW | Second display window [Classic] | P2 | ✅ | `cmd:view.secondWindow`, `crates/ui-egui/src/panels/second.rs` | Window ▸ Second Window (⌘F11): the active photo fitted in its own native window with its own render (a floating panel where there are no native windows); loupe view only (no grid / compare / survey there) | | LR-VIEW-CLIPPING | Clipping indicators | P0 | ✅ | `cmd:view.clipping` | | | LR-VIEW-HISTOGRAM | Histogram | P0 | ✅ | `cmd:view.histogram`, `crates/ui-egui/src/panels/edit.rs`, `crates/pipeline/src/lib.rs` (`hdr_histogram`) | HDR edits: SDR on the left half, the HDR range (0 to 5 stops above SDR white, a fixed scale) on the right with the headroom limit marked; colour bar (white, or Visualize HDR range's colours when that is on) and SDR / HDR labels; no drag-to-adjust on the histogram; it follows slider drags live (a small render beside the drafts, `Slot::HdrHistogram`) and never drops to the SDR layout mid-drag | @@ -163,11 +166,11 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people | +| LR-META-INFO | Info panel | P0 | ✅ | `cmd:panel.info`, `cmd:photo.setMeta`, `crates/ui-egui/src/panels/right.rs` (`info`, `camera_card`) | camera card (camera, lens, size, format, focal length / shutter / aperture / ISO); title, caption, alt text, extended description, copyright, copyright status, rights usage terms, copyright info URL, creator; file name (rename), file path (reveal), capture time (edit); location, city, state, country; GPS. No flash, map snippet or people; a raw with no lens name (Nikon DSLRs) shows its range from Exif LensSpecification or the Nikon maker note's Lens tag, written like Lightroom's `24.0-70.0 mm f/2.8` | | LR-META-COPYRIGHT-DEFAULT | Default copyright on import | P1 | ✅ | `cmd:library.preferences` (`import.copyright`, `import.creator`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import → Metadata; fills only photos without their own | | LR-META-LOCATION | Location editing | P2 | 🟡 | `cmd:photo.setMeta` (`location`, `city`, `state`, `country`, `gps`), `cmd:metadata.reverseGeocode` | place fields and GPS (decimal or degrees / minutes / seconds) edited in Info, read/written as IPTC Core / Photoshop / EXIF XMP; Show on Map opens OpenStreetMap; Fill Location fills empty City / State / Country from GPS offline (GeoNames, downloaded on request: `cmd:metadata.downloadPlaceNames`); no embedded map | | LR-META-COPYPASTE | Copy / paste metadata | P2 | ✅ | `cmd:photo.copyMetadata`, `cmd:photo.pasteMetadata` | title, caption, alt text, extended description, copyright (notice, status, usage terms, info URL), creator, place fields, keywords; `fields` picks a subset | -| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | +| LR-META-XMP | XMP read/write | P0 | ✅ | `cmd:photo.saveMetadataToFile`, `cmd:photo.readMetadataFromFile`, `cmd:library.xmpPreferences`, `crates/engine/src/sidecar.rs`, `docs/xmp-interop.md` | sidecar wins for metadata; a sidecar capture time (`exif:DateTimeOriginal` / `photoshop:DateCreated` / `xmp:CreateDate`) fills in only when the file has none; saving merges into an existing sidecar (`crates/meta/src/xmp_merge.rs`): another app's `crs:`, `xmpMM:History` and unknown namespaces are kept byte for byte, an unreadable sidecar is backed up first; with stem naming, files sharing a stem (raw + JPEG) get separate sidecars (`<file>.xmp` for all but the raw); a `crs:` custom white balance on a relative-WB raw is shifted from `crs:AsShotTemperature` rather than read as Kelvin, or kept As Shot and reported when the packet has no as-shot white (issue #510); heal and clone spots (`crs:RetouchAreas`, legacy `crs:RetouchInfo`) are read into our spots (`crates/engine/src/crs_masks.rs`, see `docs/xmp-interop.md` → Spot removal); radial masks (`Mask/CircularGradient`) keep Lightroom's inside / outside: `crs:Flipped="true"` is the effect-inside flag of current masks and of the legacy Radial Filter, `crs:MaskInverted` the inversion, checked against Lightroom Classic's renders (issue #1815); Read Metadata from File never writes the sidecar back, even with auto-write on, so another app's later `crs:` edits are still read (issue #934) | | LR-META-EXIF-FULL | Full EXIF/IPTC [Classic] | P1 | ✅ | `cmd:photo.allMetadata`, `cmd:dialog.allMetadata`, `crates/meta/src/tags.rs`, `crates/meta/src/exif.rs`, `crates/meta/src/iptc.rs` | read and written on export; Info ▸ All Metadata…: every TIFF / EXIF / GPS / interop tag (named, common values spelled out) and the XMP fields, searchable; metadata presets | ## F. Edit panel — global adjustments (EDIT) @@ -204,14 +207,14 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-EFFECTS-VIGNETTE | Post-crop vignette | P0 | ✅ | `ctl:vignette.*`, `crates/pipeline/src/finish.rs`, `crates/ui-egui/src/panels/edit.rs` | style picker (Highlight / Color / Paint) in the Effects section | | LR-EDIT-EFFECTS-GRAIN | Grain | P1 | ✅ | `ctl:grain.*` | | | LR-EDIT-DETAIL-SHARPEN | Sharpening | P0 | ✅ | `ctl:detail.sharpenAmount`, `ctl:detail.sharpenRadius`, `ctl:detail.sharpenDetail`, `ctl:detail.sharpenMasking` | no Alt-drag mask preview | -| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | | -| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | | +| LR-EDIT-DETAIL-NR | Luminance noise reduction | P0 | ✅ | `ctl:detail.nrLuminance`, `ctl:detail.nrDetail`, `ctl:detail.nrContrast` | radii in the original's pixels, so grid, loupe and smaller exports look like the full-size render scaled down (`local::nr_params`, `Plan::detail_scale`, #960) | +| LR-EDIT-DETAIL-CNR | Colour noise reduction | P0 | ✅ | `ctl:detail.nrColor`, `ctl:detail.nrColorDetail`, `ctl:detail.nrColorSmoothness` | preview-size independent like luminance NR (#960) | | LR-EDIT-DETAIL-DENOISE | AI denoise | P2 | 🟡 | cmd:denoise.toggle, cmd:denoise.models.install, cmd:denoise.queue, ctl:enhance.denoise, `crates/denoise/`, `docs/denoise.md` | non-destructive Bayer RAW, opt-in weights, pure-Rust CPU/GPU; GPU convolution uses independently written shared scalars (Metal equivalence regression, issue #479); tiles can also run on another computer's graphics card (`lightcraft-cli denoise-serve`, `crates/denoise/src/remote.rs`); X-Trans/linear RGB, model policy and Lightroom fidelity remain; closing the model setup without installing turns the switch back off (#922) | | LR-EDIT-DETAIL-RAWDETAILS | Improved demosaic toggle | P2 | ⬜ | | | | LR-EDIT-DETAIL-SUPERRES | Super resolution | P2 | ⬜ | | | | LR-EDIT-DETAIL-AISHARPEN | AI sharpen | OOS | 🚫 | | | | LR-EDIT-OPTICS-CA | Remove chromatic aberration | P1 | ✅ | `crates/ui-egui/src/panels/edit.rs` (checkbox), `ctl:optics.caRed`, `ctl:optics.caBlue` | | -| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus and Sony raws not read yet; Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | +| LR-EDIT-OPTICS-PROFILE | Lens profile corrections | P1 | 🟡 | `ctl:optics.profileDistortion`, `ctl:optics.profileVignetting`, `crates/pipeline/src/optics.rs`, `crates/raw/src/vendor/rw2.rs`, `crates/raw/src/vendor/raf_lens.rs`, `crates/engine/src/crs.rs` | uses corrections embedded in the file: DNG `WarpRectilinear` / `WarpRectilinear2` (opcode 14, DNG 1.6: degree-14 radial polynomial, valid-radius clamp, reciprocal option, applied in list order with the spec's skip rule for a `WarpRectilinear` after an optional one, so Pixel DNGs' two-stage warp is corrected; up to 3 chained stages, sampled on the CPU because the polynomials need `f64`; issue #635) / `FixVignetteRadial`, and the camera's own distortion correction in Panasonic / Leica RW2 / RWL files (tag `0x0119`, read as the equivalent `WarpRectilinear`; on 79 photos from 13 bodies and 11 lenses the corrected render lines up with the camera JPEG within 0.75 px at 1440 px at the corners, from up to 13 % of the half diagonal uncorrected; issue #256); an imported preset's `LensProfileEnable=0` leaves them on, as Lightroom applies built-in corrections regardless (inferred); Sony ILCE-7RM4A Bayer ARW raw-IFD distortion tables (`0x7037`, 16-sample layout, native 3:2 framing) use the same warp path, enabled only for the independently validated ILCE-7RM4A with FE 24–105 and FE 200–600 lenses; ILCE-7RM6 verified against the camera JPEG on 54 photos with three lenses (`docs/sony-ilce7rm6-lens-corrections.md`); other Sony models remain uncorrected (see `docs/sony-lens-corrections.md`); Nikon NEF distortion from NikonNEFInfo (`0xc7d5`: polynomial, frame-filling zoom and the optical-centre offset left by in-body stabilisation; `crates/raw/src/vendor/nef_lens.rs`, `docs/nikon-lens-corrections.md`) for the Z 6II, Z 8 and Z f, full-frame image area only: F-mount lenses on the FTZ match the camera JPEG within 0.2 px median, Z lenses within 0.5–1.5 px except the 24-70 f/4's wide end, where the camera applies a further correction that is not in the readable data (7 px median at 24 mm, from 16 px / 106 px p95 uncorrected); Fujifilm RAF raw-IFD tables (`0xf00b` distortion, `0xf010` vignetting; `crates/raw/src/vendor/raf_lens.rs`, `docs/fuji-lens-corrections.md`) become a `WarpRectilinear` with the camera's frame-filling zoom and a `FixVignetteRadial` gain for 59 body models with sample files checked against the camera JPEG (the table indexed by the source radius with last knot at the corner; on 67 body/lens combinations the corrected render's radial shape error against the camera JPEG falls from a median of 0.72 % to 0.05 % of the half diagonal, largest 0.42 %, and the vignetting gain of the JPEG tracks the table at a median correlation of 0.987); files whose table scale is not the recorded image's half diagonal (X-S20, X-T30 III, X-M5, X-T5 crops) and six unmatched bodies stay uncorrected; Fujifilm's lateral CA table (`0xf00f`) is real but is not applied, because our demosaic already registers the planes and the stored shift over-corrects; vignetting and lateral CA data of Olympus raws not read yet; Sony ARW shading, lateral CA and distortion tables (raw IFD `0x7031`–`0x7037`, with the encrypted `SR2SubIFD` copies as a checked fallback) are decoded with their camera settings, provenance and framing evidence for every body that records them (`crates/raw/src/lens_metadata.rs`), but only the ILCE-7RM4A and ILCE-7RM6 distortion above is applied: on the ILCE-7CR the unchanged distortion interpretation misses the camera JPEG at 24 mm (median 1.6 px, p95 6.2 px at 2376 px), and Sony shading and CA semantics are not validated beyond the ILCE-7RM6's (lateral CA off) (`docs/sony-lens-corrections.md`); Reload from Disk reads the file's lens data for photos catalogued without it and turns the correction on for those still at import settings (issue #314); lens-profile database: a lensfun-format XML database in `LIGHTCRAFT_LENSFUN_DIR` (none is bundled) supplies distortion (poly3 / poly5 / PTLens), TCA and vignetting for lenses it lists (`crates/lensdb`, `crates/engine/src/lens_profiles.rs`, `docs/lens-profiles.md`); no settings switch or bundled database yet | ) | LR-EDIT-OPTICS-DEFRINGE | Defringe | P1 | ✅ | `ctl:optics.defringe*` | no fringe eyedropper | | LR-EDIT-OPTICS-MANUAL | Manual distortion / vignetting | P1 | ✅ | `ctl:optics.distortion`, `ctl:optics.vignetting`, `ctl:optics.vignettingMidpoint` | | @@ -222,6 +225,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EDIT-LENSBLUR | Lens blur | P2 | ⬜ | | settings field reserved, not rendered | | LR-EDIT-CALIB | Calibration [Classic] | P1 | ✅ | `ctl:calibration.*` | shadows tint, red/green/blue primary hue and saturation; read/written in XMP | | LR-EDIT-SECTION-TOGGLE | Section on/off | P1 | ✅ | `cmd:develop.sectionEnabled`, `crates/develop/src/lib.rs` (`effective`) | the eye on a section header hides its adjustments: Light (with the curve), Color (white balance, presence, mixers, grading, point colour), Effects, Detail, Optics, Geometry, Calibration; masks stay on (issue #316 fixed Light, Color and Detail, which had no effect) | +| LR-EDIT-SECTION-SOLO | Solo Mode for Edit sections | P2 | ✅ | `cmd:view.soloSections`, `cmd:section.calibration`, `crates/ui-egui/src/state.rs` (`toggle_section`, `set_single_panel`, `solo_section`), `crates/ui-egui/src/panels/edit.rs` (`section`), `crates/ui-egui/src/tests_solo_sections.rs` | Lightroom Classic's panel Solo Mode: while it is on, opening one Edit section (a header click or ⌘1–⌘5) closes the others. Window ▸ Edit Sections ▸ Solo Mode (checked while on) or a right-click on any section header (`sectionMenu:solo`) turns it on and off; turning it on keeps only the section opened last. ⌥-click on a header opens just that section once, without turning the mode on (agents: `section.<id>` `{solo: true}`, refused for a section the Edit panel has not got; header clicks run `section.<id>` too, Calibration included). `view.soloSections` `{on?}` (no params toggles) answers `{soloSections}`. Kept with the UI state (`singlePanel`) | | LR-EDIT-RESET | Reset all / section / slider | P0 | ✅ | `cmd:develop.reset`, `cmd:develop.resetSection`, `cmd:develop.resetControl`, `crates/ui-egui/src/widgets.rs` (double-click) | no "reset to open" | | LR-EDIT-SHOWORIG | Show original | P0 | ✅ | `cmd:view.showOriginal` | | @@ -234,7 +238,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-PROF-ADOBE | Standard raw looks (own equivalents) | P0 | ✅ | `crates/engine/src/presets.rs` (`PROFILES`), `crates/pipeline/src/profiles.rs` | six own looks: Color, Neutral, Vivid, Landscape, Portrait, Monochrome | | LR-PROF-ADAPTIVE | Adaptive profiles | P2 | ⬜ | | | | LR-PROF-CAMERA | Camera-matching looks | P2 | ⬜ | | | -| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md) | +| LR-PROF-CAMERACOLOR | Camera colour calibration (own) | P0 | 🟡 | `crates/raw/src/color.rs`, `crates/raw/src/profile.rs`, `crates/raw/src/gaintable.rs`, `crates/engine/src/camera_preview.rs` | DNG files use the colour matrices and the camera-profile look they carry (`ProfileHueSatMap`, `ProfileLookTable`, `ProfileToneCurve`, read from the file at run time per DNG spec ch. 6 — Lightroom-converted DNGs rendered flat and muted without them, issue #138). A DNG's `ProfileGainTableMap` / `ProfileGainTableMap2` (Apple ProRAW's local tone map; both versions, DNG 1.7 precedence, the file's byte order with a big-endian fallback) is read and written back by DNG export and not rendered by default, as Lightroom Classic renders ProRAW without it (measured on one CC0 iPhone 12 Pro ProRAW); the Profile option "Camera local tone mapping" (`profile.camera_local_tone`, per photo, off by default, `crates/pipeline/src/local_tone.rs`) renders it as the camera does, per output pixel in the scene-linear stage (CPU, also under the GPU path); Sony ARW, Nikon NEF, Panasonic RW2, Fujifilm RAF, Canon CR3 and CR2, Pentax PEF, Samsung SRW and Olympus ORF get a guarded file-local fit to their own embedded JPEG — chromaticity matrix + hue/saturation/value table + tone and chroma curves, relative WB — or, with a bundled or local per-model profile pooled from many photos (`lightcraft-cli calibrate`, ARW, NEF and RAF so far, `crates/engine/src/camera_profiles.rs`), the profile's colour and only their own tone/chroma curves (on 29 held-out ILCE-7M4 photos mean ΔE vs the camera JPEG 3.54 → 3.10; bundled ILCE-7CR from 125 photos: 26 held-out 4.07 → 3.29, and every one of the 131 photos accepted, 8 of which fell back to neutral without it; bundled ILCA-99M2 from 144 photos: 30 held-out 5.11 → 4.87, 25 of them closer); the tone fit also uses dark JPEG samples excluded from colour training, so deep shadows follow the camera toe (#1307); Sony ARWs shot with DRO on (maker note `0xb025`, on by default) keep that colour but their tone curve is lowered to Sony's curve without DRO, whose lifted shadows are in the camera JPEG only (issue #244: 8 ILCE-7CR photos +6.4 → +0.8 L* from Lightroom's default, median ΔE 7.5 → 3.3); 52 models without a profile whose JPEG fit fails (or that have no usable JPEG) start from DNG-style matrices fitted to their measured spectral sensitivities instead of the neutral fallback (`crates/raw/src/spectral.rs`, rawtoaces-data v1.1.0, Apache-2.0, aliases included; not yet measured against the camera JPEG); ARW: on 10 public raw.pixls.us samples from 8 bodies 7 fits were accepted, mean ΔE vs the camera JPEG 17–26 → 3–10; NEF: 13 of 13 decodable samples (D750, D780, D850, D7500, Z 50 and local D7500 shots) accepted, ΔE 13–46 → 3–8 (issue #150); RW2 / RWL: 140 of 174 public samples accepted, from 93 of the 114 bodies with a preview, median ΔE 5.0 with matching lightness (13 of the 14 corpus files with a preview: ΔE 9.6–27 → 2.8–8.9; most rejections are compacts and kit zooms whose camera JPEG is distortion-corrected); a fit that fails the gates on all pixels is retried away from edges, where a lens-corrected camera JPEG still lines up (issue #232: the six distortion-corrected public ILCE-7RM2 samples ΔE 16.6–16.9 → 3.7–3.8, the corpus GX80 19.1 → 7.9); RAF: bundled X-H2S/X-T4 profiles from 201/545 photos; on 150 withheld files mean ΔE improves slightly over per-file fitting (X-H2S 1.70 → 1.62, X-T4 2.43 → 2.34); 23 of 24 references from 13 bodies accepted, including the dark X-H2S samples (8.74 → 2.25); ORF: the 3 decodable corpus files (E-1, E-400, XZ-2) accepted (`docs/camera-preview-colour.md`); a raw whose camera JPEG is black and white (a monochrome picture style) takes only the JPEG's tone curve, on the camera profile's, spectral or neutral colour (issue #577); this is not measured camera calibration; other raws and rejected fits use a neutral fallback (camera RGB ≈ linear sRGB, `matrix_is_fallback`) with as-shot white balance, so colours are muted and not accurate. Needs our own per-camera calibration: matrices the files carry themselves (e.g. Olympus `ColorMatrix`), fitting each camera to its own embedded JPEG, then chart shots. Adobe matrices / DCPs are never used. Biggest image-quality gap today; ILCE-7RM6: per-file fit now runs on previews above 64 MP, a pooled profile (46 photos) was tried and not bundled (withheld ΔE 5.71 → 6.40, 11 of 15 regressed), kept with smart previews (see docs/camera-preview-colour.md); the fit pairs the sensor moved into the file's lens-warp geometry when that lines up better with a lens-corrected camera JPEG (29 CC0 raws: alignment +0.13, mean ΔE 3.73 → 3.61); bundled NIKON Z 6_2 profile (296 photos; 59 held-back photos mean ΔE 2.73 → 2.54) NIKON Z 8 profile (261 photos; 55 held back 2.55 → 2.33) and NIKON D5300 profile (264 photos; 53 held back 2.86 → 2.66) | | LR-PROF-CREATIVE | Creative profiles (own) | P2 | ✅ | `cmd:develop.profile`, `crates/pipeline/src/profiles.rs` | 16 own looks in Film / Cinematic / Muted / B&W (tone + point-curve fades, colour grading, mixer / B&W mix); scale with `ctl:profile.amount`; sliders untouched | | LR-PROF-LEGACY | Legacy profiles | P2 | ⬜ | | | | LR-PROF-NONRAW | Profiles for non-raw files | P0 | ✅ | `cmd:develop.profile` | same looks apply to JPEG/TIFF | @@ -247,7 +251,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-CROP-RECT | Crop rectangle | P0 | ✅ | `cmd:crop.set`, `cmd:crop.drag`, `crates/geom/src/crop_drag.rs`, `crates/ui-egui/src/panels/detail.rs` | double-click inside the box applies the crop (like Return) | | LR-CROP-ASPECT | Aspect ratios | P0 | ✅ | `cmd:crop.aspect`, `cmd:crop.rotateAspect`, `crates/ui-egui/src/panels/right.rs` | menu with presets + custom W × H, visible Lock toggle (also A), button names the active ratio; every handle honours the lock, holding Shift while dragging a handle keeps the frame's proportions for that drag (issue #614), and the drag stops at the image edge instead of shifting the frame (issue #295); "Original" is the photo's own shape (no separate As Shot); the last chosen aspect is saved as a crop tool default, resolving Original per photo and preserving existing crops; opening Crop or browsing never edits a photo: the default applies only when a crop gesture starts, within its single undo step | -| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it to the largest crop of its aspect inside the photo (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | +| LR-CROP-STRAIGHTEN | Straighten tool | P0 | ✅ | `cmd:crop.straighten`, `cmd:crop.autoStraighten`, `crates/ui-egui/src/panels/detail.rs` (`rotate_cursor`, `angle_readout`), `crates/ui-egui/src/panels/right.rs` (Angle field), `crates/ui-egui/src/tests_crop_rotate.rs` | Straighten Tool button: drag along a horizon/vertical; double-click or Auto levels automatically. Dragging outside the crop box rotates it: there the pointer is a curved double arrow that bulges away from the side of the box it is on, diagonally off a corner and following a rotated box (`rotate_cursor_turn`, tested by `rotate_cursor_faces_away_from_the_crop_box`) (with ⌘ held, the straighten crosshair), and while rotating the angle shows next to it, kept on the canvas (issue #534); an exact angle goes in the Angle field under Straighten (`cropAngleField`: applies on Return or leaving it, Esc keeps the old angle, takes a decimal comma; a drag on it is one undo step) or by clicking the Straighten value (its hover says so). Every way of setting the angle (rotate handle, Straighten slider, Angle field, `crop.straighten`, `crop.set {angle}`, and the generic `develop.set` / `develop.adjust` / `develop.quickAdjust` / `develop.resetControl` on `crop.angle`, as MCP, the control channel and `lightcraft-cli render --set` use them) keeps the crop if it still fits, else shrinks it about its own centre, keeping its aspect, until it fits; the full photo (or a crop an earlier turn shrank to the largest of its aspect) stays the largest, so it grows back towards 0° (issue #1388), and no other crop ever grows (issue #1794: a crop dragged in from a corner turned into nearly the whole photo) (`CropGeometry::with_angle`, `crates/engine/src/tests_crop_angle.rs`; before issue #742 the generic path left the box, and the export, past the photo with smeared edge pixels in the corners). The dimming outside the box is the photo minus the (rotated) box, drawn as one mesh (`outside_crop_mesh`), so rotating never dims wedges inside the box or doubles them outside (issue #731) | | LR-CROP-AUTO | Auto straighten | P1 | ✅ | `cmd:crop.autoStraighten` | crop-angle leveling from detected horizon/plumb lines (consensus required) | | LR-CROP-ANGLE | Angle slider | P0 | ✅ | `ctl:crop.angle`, `crates/ui-egui/src/panels/right.rs` | the Straighten slider; an exact angle can also be typed in the Angle field under it (see LR-CROP-STRAIGHTEN); setting `crop.angle` through `develop.set` fits the crop inside the photo as Straighten does (issue #742) | | LR-CROP-ROTATE90 | Rotate 90° | P0 | ✅ | `cmd:photo.rotateLeft`, `cmd:photo.rotateRight` | | @@ -266,7 +270,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-REM-GEN | Generative remove | OOS | 🚫 | | | | LR-REM-DETECT | Object detection for remove | P2 | ⬜ | | | | LR-REM-BRUSH-PARAMS | Brush size / feather / opacity | P0 | ✅ | `cmd:spot.add` (`size`, `feather`, `opacity`), `cmd:brush.smaller`, `cmd:brush.larger`, `cmd:brush.featherLess`, `cmd:brush.featherMore`, `crates/ui-egui/src/panels/right.rs` | Size / Feather / Opacity sliders (for new spots and the selected one); `[` `]` size, ⇧`[` ⇧`]` feather (also the Masking brush) | -| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added | +| LR-REM-SPOT-EDIT | Edit existing spots | P0 | ✅ | `cmd:spot.select`, `cmd:spot.update`, `cmd:spot.refreshSource`, `cmd:spot.delete`, `cmd:view.toolOverlay` | click a pin to select, drag target or source, ⌫ deletes the selected spot, `/` picks another source; automatic sources are resolved when the spot is added; Tool Overlay (Remove panel, View menu, ⇧H) shows the spots' pins Auto (pointer over the photo, hidden while painting) / Always / Selected / Never, and a hidden spot can't be grabbed (#224) | | LR-REM-VISUALIZE | Visualize spots | P1 | ✅ | `cmd:view.visualizeSpots` | | | LR-REM-PEOPLE | Remove people (generative) | OOS | 🚫 | | | | LR-REM-REFLECT | Remove reflections | P2 | ⬜ | | | @@ -292,20 +296,21 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-MASK-OBJECTS | Object selection | P2 | 🟡 | `cmd:mask.add` (`object`, `prompt`), `cmd:mask.objectPoint`, `cmd:mask.refineDetail`, `cmd:segment.prepare`, `cmd:segment.model.status`, `cmd:segment.model.download`, `cmd:segment.model.cancel`, `cmd:app.openSamModelFolder`, `crates/segment`, `crates/engine/src/segment/mod.rs` | SAM 3 in pure Rust (candle; Metal on macOS, CPU elsewhere): Object tile → click to include, ⌥-click to leave out; Describe tile → a text prompt selects every instance ("sky", "the red car"); both also as Add/Subtract/Intersect components; + / − under the selected mask; comma lists (`car, road`); a zoomed-in detail pass for 5–10× finer edges on small objects; per-selection Edge (hard ↔ soft). The model runs on its own worker thread (the UI never waits; panics become errors), is unloaded after 10 min idle, and is optional: the segmentation is stored with the mask (288² logits), so renders and exports never need it. Weights are never bundled (SAM License): without them the app offers a consented background download (mirrors, resume, timeouts, SHA-256) — **but no default download location is configured yet** (users set `LIGHTCRAFT_SAM3_MIRRORS` or install by hand: the dialog names the three files, opens the configured model folder with the host opener, preserves setup on an open error, and links docs/ai-masks.md). Not verified against Lightroom's Select Object; no brush/box object mode; the first click on a photo waits for its analysis (~4 s on an M4 Pro, much longer on CPU) | | LR-MASK-PEOPLE | People parts | P2 | ⬜ | | shape exists (no UI): uses the photo's person / skin / hair / teeth / glasses mattes when it carries them (DNG semantic masks), else the subject heuristic | | LR-MASK-LANDSCAPE | Landscape classes | P2 | ⬜ | | shape exists, evaluates empty | -| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); no A/B brushes, no pressure | +| LR-MASK-BRUSH | Brush mask | P0 | 🟡 | `cmd:tool.brush`, `cmd:mask.brushStroke` (`autoMask`), `crates/pipeline/src/masks.rs`, `crates/ui-egui/src/brush_live.rs` | size/feather/flow/density/erase; Create New Mask starts a separate brush in paint mode; the stroke shows while the button is held with the committed stroke's dab spacing, feather and flow, so fast strokes have no gaps (#517); coverage builds up by passes, not hold time (holding the brush still adds nothing, live or committed, #518); Auto Mask: dabs weighted by similarity to the colour under the dab centre, refined by a guided filter on luminance (CPU + GPU); ⇧-click draws a straight stroke from where the last stroke ended (`connect`), tapering size, feather and flow from that stroke's values when they changed; no A/B brushes; pen pressure (Brush ▸ Pen Pressure, on by default; `pressure` per point in `mask.brushStroke`) scales each dab's size (a tenth to all of it) and flow, live and committed alike, where winit reports a pen force (Windows; not macOS / Linux tablets yet); no tilt | | LR-MASK-LINEAR | Linear gradient | P0 | ✅ | `cmd:tool.linear`, `cmd:mask.update` | | | LR-MASK-RADIAL | Radial gradient | P0 | ✅ | `cmd:tool.radial`, `cmd:mask.update`, `crates/ui-egui/src/panels/detail.rs` | drag the selected ellipse's body or centre pin to move; edge grips resize, outer grip rotates; rotated and compound radial components, hidden pins, outside hit rejection and one-step undo tested | -| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in), Pick button, Refine slider | -| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | +| LR-MASK-COLORRANGE | Colour range | P1 | ✅ | `cmd:mask.add` (`colorRange`), `cmd:mask.sampleColor`, `cmd:mask.update`, `crates/pipeline/src/lib.rs` (`color_range_sample`) | Color tile → click the photo to sample (⇧-click adds, up to 5; samples taken in the space the mask compares in, respecting section eyes and the requested HDR/SDR rendition), Pick button, Refine slider | +| LR-MASK-LUMRANGE | Luminance range | P1 | ✅ | `cmd:mask.add` (`luminanceRange`), `cmd:mask.update`, `crates/ui-egui/src/panels/masking.rs` (`range_controls`) | range bar with two handles (the pressed endpoint stays selected through fast/crossing drags; one undo step per drag), Smoothness, Show Luminance Map (B&W photo with the range tinted) | | LR-MASK-DEPTHRANGE | Depth range | P2 | ⬜ | | shape exists, needs depth data | | LR-MASK-COMBINE | Add / subtract / intersect | P0 | ✅ | `cmd:mask.addComponent`, `cmd:tool.brush` | Add/Subtract > Brush creates a component with the chosen operation; Erase removes paint within that brush component | | LR-MASK-INVERT | Invert | P0 | ✅ | `cmd:mask.invert` | | | LR-MASK-AMOUNT | Mask amount | P1 | ✅ | `cmd:mask.adjust` (`amount`), `crates/ui-egui/src/panels/masking.rs` | | | LR-MASK-FEATHER-EDGE | Refine mask edges | P2 | ✅ | `cmd:mask.refine`, `crates/pipeline/src/masks.rs` (`evaluate_one`) | Refine Edges slider per mask: guided filter on the photo's luminance snaps soft mask edges to the photo's edges (rendered on the CPU) | -| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve or effect presets | +| LR-MASK-SLIDERS | Local adjustment sliders | P0 | ✅ | `cmd:mask.adjust`, `crates/pipeline/src/finish.rs` | every slider renders (CPU + GPU), incl. Noise, Moiré, Defringe (negative Defringe has no effect); no local curve | +| LR-MASK-EFFECTS | Local adjustment (effect / brush) presets | P1 | ✅ | `cmd:mask.effects`, `cmd:mask.applyEffect`, `cmd:mask.saveEffect`, `cmd:mask.updateEffect`, `cmd:mask.renameEffect`, `cmd:mask.duplicateEffect`, `cmd:mask.deleteEffect`, `cmd:mask.favoriteEffect`, `cmd:mask.setEffectGroup`, `cmd:mask.renameEffectGroup`, `cmd:mask.toolEffect`, `cmd:mask.importEffects`, `cmd:mask.exportEffects`, `cmd:dialog.saveMaskEffect`, `cmd:dialog.manageMaskEffects`, `cmd:file.exportMaskEffects`, `crates/engine/src/cmd/mask_effects.rs`, `crates/engine/src/cmd/mask_effect_files.rs`, `crates/ui-egui/src/panels/mask_effects.rs` | Effect menu above the mask sliders: grouped (folders, ★ Favorites first), searchable, scrolls, ↑ ↓ Return, ✓ on the matching effect (else Custom), resting on an effect previews it on the photo; right-click: apply to all masks / to the same-named mask on the selected photos (one undo step), update, rename, duplicate, star, move to group, delete (asks first); Reset Sliders; New Effect Preset dialog: which sliders (All / None / Changed Only), Amount, brush size/feather/flow; any mask type; Manage Effect Presets window (search, inline rename, update, duplicate, stars, groups, export, delete with confirmation); a new mask starts with the effect last applied to a mask of its kind (per tool, kept with the library); imports Lightroom Classic local `.lrtemplate` presets, Camera Raw local-correction `.xmp` presets, `.lceffect` and `.zip` bundles, through File ▸ Import Profiles & Presets… and drag & drop too; folders and LrC divider presets (`10 ----- DETAIL ----- 10`) become groups; settings with no local slider here (`grain`, Point Color `pointColors`, `toningLuminance`) are reported and kept with the effect for export, not applied (🟡 for those); brush size/feather/flow aren't in LrC presets (it keeps them per brush), so only `.lceffect` carries them; no built-in effects | | LR-MASK-OVERLAY | Mask overlay | P0 | ✅ | `cmd:view.maskOverlay`, `cmd:view.maskOverlayMode`, `cmd:view.maskOverlayColor`, `crates/pipeline/src/visualize.rs` | rendered alpha of the selected mask (CPU + GPU): colour, colour on B&W, image on black/white, white on black; ⇧O cycles while masking; successful local adjustments (including Amount) hide the overlay until manually shown again with O / the eye; shape/edge edits retain it; zoomed in, the overlay is rendered on the same sharp window as the plain view (zoom and pan unchanged; `crates/ui-egui/src/tests_preview_limit.rs`, `crates/pipeline/src/tests_window.rs`); no auto-show on hover | | LR-MASK-OVERLAYALL | Show all mask overlays | P1 | ✅ | `cmd:view.maskOverlayAll`, `crates/ui-egui/src/panels/masking.rs`, `crates/pipeline/src/visualize.rs` | one diagnostic tint over every visible mask at once (CPU + GPU): the masks still stack their adjustments as before — this only paints each mask's region together, so you can see all masked areas at a glance (⌘⇧O / Ctrl+Shift+O) | -| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `crates/ui-egui/src/panels/detail.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; no pins for range/AI components, no Auto mode | +| LR-MASK-PINS | Pins | P1 | ✅ | `cmd:view.maskPins`, `cmd:view.toolOverlay`, `crates/ui-egui/src/panels/detail.rs`, `crates/ui-egui/src/tests_tool_overlay.rs` | a pin per radial/linear/brush component: click selects its mask, drag moves it; Show Pins on follows the Tool Overlay mode (Auto: while the pointer is over the photo, hidden while brushing; Always; Selected: the selected mask's; Never; ⇧H cycles, #224); no pins for range/AI components | | LR-MASK-UPDATE | Recompute AI masks | P2 | ⬜ | | | | LR-MASK-SYNC | Copy masks to other photos | P1 | ✅ | `cmd:develop.copy` (`groups`) | | | LR-MASK-ADAPTIVE | Adaptive presets | P2 | ⬜ | | | @@ -347,10 +352,10 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| -| LR-MERGE-HDR | HDR merge | P2 | ✅ | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`) | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); JPEG brackets treated as linear; raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965 | -| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG; no lens model / 360° wrap | +| LR-MERGE-HDR | HDR merge | P2 | 🟡 | `cmd:merge.hdr`, `cmd:dialog.mergeHdr`, `crates/merge/src/frame.rs` (`Frame::lens`), `crates/merge/src/hdr.rs` | auto align, deghost None–High + overlay, auto settings, float DNG, Create Stack; the DNG keeps the reference frame's embedded lens corrections (tested by `hdr_merge_keeps_the_reference_frames_lens_corrections`); raws without colour matrices of their own (CR2, NEF, ARW…) keep the colour they open with, written as DNG tags (`starting_colour_tags`; the look's chroma curve has no tag; panoramas too), issue #965; the DNG's `AsShotNeutral` is the median of the inputs' vendor white-balance multipliers, issue #1144. Tested on real Sony A7R VI brackets (67 MP, 2026-10-14): frames agree with the merged radiance within ~1 % in all channels, exposure steps match EXIF within 0.03 EV. A clipped highlight of the reference that moved in other frames (a star at 200 mm) stays instead of becoming a black hole, and deghost (≤ 2048 px maps) catches a brighter frame's ghost in a dark sky (`a_clipped_star_of_the_reference_is_not_erased_by_frames_where_it_moved`, `deghost_removes_a_bright_frames_ghost_in_a_dark_sky`). 🟡 because: AI Denoise doesn't run on merged DNGs (Bayer only; Lightroom's does), JPEG brackets are treated as linear (no camera response curve), and every frame is held as 32-bit float (~0.86 GB per 67 MP frame, so 7 frames need ~7 GB); gap 29 | +| LR-MERGE-PANO | Panorama | P2 | ✅ | `cmd:merge.panorama`, `cmd:dialog.mergePanorama` | spherical/cylindrical/perspective + auto, boundary warp, auto crop, fill edges (diffusion), DNG whose `AsShotNeutral` is the median of its frames' as-shot white balance (vendor multipliers for CR2, NEF, ARW…), issue #1144; no lens model / 360° wrap | | LR-MERGE-HDRPANO | HDR panorama | P2 | ✅ | `cmd:merge.hdrPanorama`, `cmd:dialog.mergeHdrPanorama` | brackets grouped by EXIF | -| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast` | no dialog; the options of the last merge of that kind (defaults the first time) | +| LR-MERGE-HEADLESS | Merge with last settings | P2 | ✅ | `cmd:merge.hdrLast`, `cmd:merge.panoramaLast`, `cmd:merge.hdrPanoramaLast`, `cmd:merge.hdrBatch`, `crates/catalog/src/stacks.rs` (`bracket_sets`) | no dialog; the options of the last merge of that kind (defaults the first time), kept in the library's prefs.json so they survive a restart; the HDR / Panorama / HDR Panorama dialogs open with them too (`merge_dialog_remembers_the_last_settings_per_kind`). HDR for Each Bracket Set merges a batch like Lightroom's merge of stacks: one HDR per stack among the photos, then per bracket (photos taken less than a second apart, after the shutter time, at exposures ≥ ½ EV apart; bursts at one exposure are skipped), one after the other in the activity stack (cancelling one stops the rest; a failed set doesn't); CLI `merge hdr-batch FILES…` | ## P. Enhance (ENH) @@ -365,7 +370,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). |---|---|---|---|---|---| | LR-HDR-EDIT | HDR editing | P2 | 🟡 | `cmd:develop.hdr`, `crates/pipeline/src/tone.rs` (`ToneMap::hdr`), `crates/pipeline/src/tests_hdr.rs` | HDR tone map (SDR curve through the midtones, highlights up to the headroom limit) and float HDR render, CPU only; no on-screen HDR display yet (LR-VIEW-HDR-DISPLAY); `crs:HDREditMode` from XMP / Lightroom catalogs turns HDR editing on or off (the headroom limit and SDR rendition aren't read from crs yet) | | LR-HDR-SDRPREVIEW | SDR preview of HDR | P2 | 🟡 | `ctl:hdr.sdr*` | SDR rendition sliders (brightness, contrast, highlights, shadows, whites, clarity) used by every SDR view and export; no separate SDR-preview toggle until HDR display lands | -| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it | +| LR-HDR-VISUALIZE | Visualize HDR range | P2 | ✅ | `cmd:view.visualizeHdr`, `crates/pipeline/src/visualize.rs` (`hdr_range`) | grey below SDR white, four colour bands by stops above it; ⇧H toggles it (⌘⇧H stays the Histogram panel), and a text field with the focus keeps the key | | LR-HDR-LIMIT | HDR headroom limit | P2 | ✅ | `ctl:hdr.maxEv`, `cmd:develop.hdr` | 0 to 5 stops; marked on the HDR histogram (whose scale stays at 5 stops) | | LR-HDR-EXPORT | HDR export | P2 | 🟡 | `cmd:app.export`, `crates/codecs/src/gainmap.rs`, `crates/engine/src/export.rs` (`encode_gain_map_jpeg`) | `hdr` export option: ISO 21496-1 gain map JPEG (plus Adobe hdrgm and Apple XMP), 10-bit Rec. 2020 AVIF with PQ or HLG (`hdrTransfer`, BT.2100 / BT.2408, SDR white at signal 0.75), 16-bit Rec. 2020 PQ PNG (`cICP` + `cLLI`; `crates/codecs/src/encode.rs` `encode_png_pq`), 16-bit Rec. 2020 PQ JPEG XL, 32-bit float TIFF; no HEIC (no permissive HEVC encoder) | @@ -385,9 +390,9 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | Id | Feature | Tier | Status | Evidence | Missing / notes | |---|---|---|---|---|---| | LR-EXP-DIALOG | Export dialog | P0 | ✅ | `cmd:dialog.export`, `cmd:app.export` (`preset`), `cmd:export.presets`, `cmd:export.savePreset`, `cmd:export.deletePreset`, `crates/ui-egui/src/panels/dialogs.rs`, `crates/engine/src/export.rs` | batch export; built-in presets (JPEG Small 2048 px / Large full size, Original + Settings, DNG) and saved user presets (library prefs) load into the dialog; deleting a user preset is command-only; on a short window the options scroll and Cancel / OK stay visible (LR-BEHAV-DIALOGS, issue #781) | -| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build | +| LR-EXP-TYPE | File types | P0 | ✅ | `cmd:app.export` (`format`), `crates/engine/src/export.rs` (`ExportFormat`), `crates/engine/src/tests_export.rs` | JPEG, PNG, TIFF, PSD (flat, 8/16-bit, no layers: `crates/codecs/src/psd.rs`), WebP, AVIF, JPEG XL (issue #247: `jixel`, native only; lossy with quality or lossless, 8/16-bit, effort fast/normal (jixel's slowest search left out: jxl-oxide misdecodes its files), progressive, EXIF/XMP boxes; lossy files in Adobe RGB / ProPhoto are written as Rec. 2020, lossless keep any space via ICC; `crates/codecs/src/jxl_encode.rs`), DNG (raw photos: lossless re-encode with the edits in the embedded XMP; a raw without colour matrices of its own, as a NEF, gets the colour it opens with as `ColorMatrix1` / `ForwardMatrix1` / `AsShotNeutral`, also in Convert / Copy as DNG, so other raw converters open it, issue #1745), Original (+ XMP sidecar). Non-raw → DNG not supported; no JPEG XL export in the web build. Opaque 8-bit PNG/TIFF/WebP exports omit the redundant alpha channel; real transparency is retained (#1670). | | LR-EXP-DIM | Output size | P0 | ✅ | `cmd:app.export` (`longEdge`, `shortEdge`, `width`, `height`, `megapixels`, `percent`, `dontEnlarge`, `ppi`), `crates/engine/src/export.rs` (`Resize`), `crates/ui-egui/src/panels/dialogs.rs` | full size = the cropped native size (no longer upscaled); W × H fits either orientation; ppi written to JFIF / pHYs / TIFF tags; at most 65,535 px an edge, and an export enlarged past the photo's size stops at 512 megapixels (Lightroom's ceiling too) and is refused, with the size it would need, when it needs more memory than the computer has (it aborted the process before) | -| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`) | | +| LR-EXP-QUALITY | JPEG quality | P0 | ✅ | `cmd:app.export` (`quality`, `limitKb`), `crates/codecs/src/jpeg_par.rs` | 4:4:4 chroma (4:2:0 when a file-size limit is set); Huffman tables made for each image from its own symbol counts (T.81 Annex K.2) instead of the example tables: the same pixels, 12–14 % smaller files at quality 80–100 on a 24 MP photo-like image, still interleaved baseline (issue #1373) | | LR-EXP-BITDEPTH | Bit depth | P1 | ✅ | `cmd:app.export` (`bitDepth`), `crates/pipeline/src/output.rs` (`OutputDepth`), `crates/engine/src/export.rs` | 16-bit PNG/TIFF/JPEG XL rendered at 16 bits (TIFF defaults to 16), 32-bit float linear TIFF with a linear profile, 10-bit AVIF; high-bit-depth renders run on the CPU | | LR-EXP-COMPRESSION | TIFF compression | P1 | ✅ | `cmd:app.export` (`tiffCompression`: none / lzw / zip) | | | LR-EXP-COLORSPACE | Output colour space | P0 | ✅ | `cmd:app.export` (`colorSpace`), `crates/pipeline/src/output.rs`, `crates/engine/src/export.rs` | sRGB, Display P3, Adobe RGB (1998) compatible, ProPhoto RGB, Rec. 2020: rendered from the working space with gamut mapping into the target gamut (CPU + GPU), own ICC profile embedded; AVIF stays sRGB (muxer has no ICC); lossy JPEG XL declares sRGB / P3 / Rec. 2020 (Adobe RGB and ProPhoto written as Rec. 2020), lossless JPEG XL embeds the profile | @@ -396,13 +401,13 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-EXP-METADATA | Metadata policy | P1 | ✅ | `cmd:app.export` (`metadata`, `removeLocation`) | | | LR-EXP-WATERMARK | Watermark | P1 | ✅ | `cmd:app.export` (`watermark`: text, or `{text, vertical, size, opacity, anchor, insetX, insetY, inset, rotation, color, shadow, image, imageWidth}`), `crates/engine/src/export.rs` (`Watermark`, `WATERMARK_PARAMS`) | text (`size` = height as a fraction of the short edge, 0.005..0.5, default 0.035; `color` sRGB `[r,g,b]`; `shadow`; Japanese vertical glyph forms and origins, tested by `biz_ud_vertical_watermarks_place_punctuation_at_the_top_right`; extended grapheme cells keep decomposed dakuten/handakuten and Latin accents together, tested by `vertical_watermarks_keep_combining_marks_in_one_cell` and `upright_watermark_cell_retains_multiple_glyphs` (see `docs/vertical-watermarks.md`)) or a graphic with transparency (`image` path, `imageWidth` = fraction of the photo's width, 0.01..1, converted to the output colour space); `anchor` (nine positions), `insetX` / `insetY` (margins from the side and from the top/bottom edges, each a fraction of the short edge, 0..0.4; the Export dialog's Horizontal and Vertical inset sliders, as in Lightroom Classic; the older single `inset` still reads as both; tested by `watermark_insets_are_horizontal_and_vertical`), `rotation` (0, 90, 180 or 270 degrees clockwise, the Export dialog's Rotate row, as Lightroom Classic's rotate buttons; sized before it is turned, placed and inset by the turned box; tested by `watermark_rotation_turns_the_text_in_its_corner` and `graphic_watermark_is_placed_scaled_and_blended`), `opacity` 0..1. Unknown keys and out-of-range values are errors (issue #183). The Export dialog previews it on the first exported photo's thumbnail (`draw_watermark_preview` scales the 6 px minimum text size to the preview so it matches the file), tested by `watermark_preview_scales_the_minimum_text_size` and `export_dialog_previews_the_watermark` | | LR-EXP-NAMING | File naming | P1 | ✅ | `cmd:app.export` (`naming`, `startNumber`), `crates/engine/src/rename.rs` (`expand_tokens`) | one template language for export, Rename Photos and import renaming: `{name}`, `{num}` (number at the end of the name), `{seq}` / `{seq:N}`, `{date}` / `{date:%Y-%m-%d}`, `{folder}`, `{camera}`, `{lens}`, `{iso}`, `{rating}`, `{title}`, `{creator}`, `{ext}`; free-form template rather than a list of named schemes | -| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | +| LR-EXP-LOCATION | Destination folder | P0 | ✅ | `cmd:app.export` (`dir`, `destinationMode`: specificFolder / sameAsOriginal, `subfolder`, `conflict`: unique / overwrite / skip), `cmd:export.checkTarget`, `crates/ui-egui/src/panels/dialogs.rs` (Choose… folder picker), `crates/engine/src/originals.rs`, `crates/catalog/src/safe_file.rs` | folder picker on the desktop; Export to: Specific folder or Same folder as original photo (each photo into its original's folder + the subfolder; a demo/browser photo or a missing folder is that photo's error, issue #956); existing files get `-2`, `-3`… by default; the policy covers a file and its sidecars together; a catalogued original (or its sidecar) is never written over, whatever the policy or an exact `path` (export, `ui.render`, MCP `render_photo`, merge `previewPath`, `lightcraft-cli render`); files are written to a temp file and renamed into place (issue #93; not synced file by file — exports can be made again, issue #134) | | LR-EXP-PREVIOUS | Export with previous settings | P0 | ✅ | `cmd:app.exportPrevious`, `cmd:dialog.export` | last options persist in prefs.json; dialog prefilled; no named export presets yet | -| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP or none; no embedded JPEG preview size, no lossy DNG output | +| LR-EXP-DNGOPT | DNG options | P2 | 🟡 | `cmd:app.export` (`dngCompression`), `crates/engine/src/export.rs` | compression: lossless JPEG (default), ZIP, none, or lossy (demosaiced linear camera RGB as 8-bit JPEG tiles at the export quality, a `LinearizationTable` restoring linear values, scaled to the resize long edge when set; `crates/raw/tests/lossy_dng.rs`); no embedded JPEG preview size | | LR-EXP-ORIGINAL | Original + XMP | P1 | ✅ | `cmd:app.export` (`format: original`), `crates/engine/src/tests_export.rs` | file copied byte for byte, sidecar named after the output and subject to the conflict policy | | LR-EXP-RENDERDNG | Render to DNG (Classic 15.5) | P2 | 🟡 | `cmd:app.export` (`format: rendereddng`), `crates/raw/src/dngwrite.rs` (`write_rendered_dng`), `crates/raw/tests/rendered_dng.rs` | export format `rendereddng` (dialog: Format DNG, "DNG of: Rendered photo"): the rendered photo (edit, crop, size, sharpening, watermark baked in) as a LinearRaw DNG of 16-bit float Deflate tiles in the export colour space, with `ColorMatrix1` / `ForwardMatrix1` for that space, a neutral `AsShotNeutral` and an identity `ProfileToneCurve` so raw converters show it as rendered; HDR edits keep highlights above white. Not a menu command on selected photos (Lightroom adds the DNG to the catalog next to the original); export only | | LR-EXP-PHOTOS | Export to the system photo library | P2 | 🟡 | `cmd:app.export` (`addToPhotos`, `photosAlbum`), `cmd:export.addToPhotos`, `cmd:export.photosImports`, `crates/engine/src/apple_photos.rs`, `crates/ui-egui/src/panels/dialogs.rs`, `packaging/macos/entitlements.plist`, `docs/apple-photos.md` | macOS: the exported files go to Apple Photos through Apple Events to its import command (a constant AppleScript run by `/usr/bin/osascript`; album names and paths are separate arguments), into a top-level album of exactly that name, made when missing (several of that name: refused); the Export dialog's Add to Apple Photos, Export with Previous, presets, MCP and `lightcraft-cli` all reach it; imports are jobs, off the UI thread in the desktop app and awaited in one-shot headless runs, one at a time, reserved by an export before it writes; permission, missing Photos, timeouts and partial imports are reported; the packaged app carries the Apple Events entitlement and usage text. Tested end to end with the packaged app on macOS 26.6.2 / Photos 11.0 (manual steps in `docs/apple-photos.md`). Other platforms' photo libraries are not covered | -| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos and missing files are skipped with the reason). No "Edit a copy / Edit original" choice for non-raw files yet | +| LR-EXP-PSD | Round trip to an external editor | P2 | ✅ | `cmd:photo.editExternal`, `cmd:photo.editInPhotoCraft`, `cmd:photo.editInExternal`, `cmd:photo.openOriginalInExternal`, `crates/engine/src/cmd/convert.rs`, `apps/lightcraft/src/editors.rs` | an `-Edit` copy of each selected photo (up to 50 at once, issue #920) with the edits (PSD or TIFF, 16- or 8-bit, Adobe RGB / ProPhoto / P3 / sRGB, chosen per editor in Settings ▸ General ▸ External Editing) next to the original, added stacked on top of it and opened in PhotoCraft (found where its installers put it; a PSD by default, which PhotoCraft saves back to in place) or the other editor set there (or the system default); also on the photo's right-click menu (Edit In). Re-read when LightCraft is focused again. Open Original in Other Editor (RAW, no copy) hands that editor each selected photo's original file itself instead (a raw stays a raw, e.g. for DxO PhotoLab: no render, no copy, nothing added; several photos go to one start of the editor, which DxO PhotoLab 8 needs to get them all; demo photos, missing files and originals that are not a file type LightCraft reads are skipped with the reason, so a path an imported catalog named cannot hand a program to the system opener, issue #2135). No "Edit a copy / Edit original" choice for non-raw files yet | ## T. Share (SHARE) @@ -459,7 +464,7 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-BG-TASKS | Background tasks | P0 | ✅ | `cmd:activity.list`, `cmd:activity.cancel`, `crates/engine/src/activity.rs`, `crates/ui-egui/src/panels/activity.rs`, `docs/background-tasks.md`, `crates/preview/src/lib.rs` (`JobPool`) | renders off the UI thread; exports run several photos of a batch side by side (`export_parallelism`, memory-gated; files, names and order as one at a time, #496); one activity stack in the top-left corner under the top bar, as in Lightroom Classic (issue #345): imports, folder scans, exports (and the contact sheet PDF), Synchronize Folder (its scan and its run), Build / Smart Previews, Lightroom catalog inspect / import, Photo Merge, model downloads (SAM 3, face, AI Denoise), the face scan, AI Denoise and Find Missing Photos each show a row (name, progress bar or a sweep while the amount isn't known, count and current item) once 0.5 s old, three rows then "+N more"; ✕ cancels the jobs that can stop ("Stopping…" until they do); quitting asks while a cancellable task runs; agents and `ui.inspect` (`activity`) see the same list. No pause (Lightroom can pause some tasks) | | LR-BEHAV-OFFLINE | Offline editing | P1 | ✅ | | local-first: everything works offline | | LR-BEHAV-WEB-SAFETY | Library safety in the browser build | P1 | 🟡 | `cmd:file.backupLibrary`, `cmd:file.restoreLibrary`, `apps/lightcraft-web/src/safety.rs`, `apps/lightcraft-web/src/backup.rs`, `apps/lightcraft-web/src/files.rs` | web only (experimental): Back Up Library (zip of the catalog and every stored original) and a non-destructive Restore; failed saves show as unsaved and are retried; a photo that can't be stored isn't added; one tab per library (Web Locks); notices for storage that isn't persistent and for a library that can't open; `?reset` asks first; a panic shows a message. Missing: `?safe` start, deleting originals of removed photos, zip64 (backups > 4 GB) | -| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | +| LR-BEHAV-GPU | GPU acceleration | P0 | ✅ | `crates/gpu/src/render.rs`, `cmd:app.gpu`, `docs/gpu-pipeline.md`, `crates/ui-egui/src/panels/notices.rs` | CPU fallback per render on device limits, out of memory, device errors / loss, incomplete or black results (`perf.gpuReason`, `perf.gpuFallback`; each render reports its own reason — `lightcraft_gpu::try_render`, `gpuFallback` per exported file — issue #675); short submissions for slow iGPUs; backend choice (DX12 only on Windows, `LIGHTCRAFT_GPU_BACKEND` / `WGPU_BACKEND`), device created after the window opens, crash sentinel (issue #136); a GPU lost while LightCraft runs (driver reset) stops it with that message and turns GPU rendering off for the next start, which says why (issue #1337; the window's renderer can't survive the lost device); quitting waits (up to 2 s) for renders in flight and starts no new GPU work, so none is inside the driver when the process exits (`crates/gpu/src/exit.rs`, issue #620; seen crashing only on Linux + NVIDIA, other drivers unverified); with GPU rendering on and no adapter found at all (e.g. no Vulkan loader on NixOS outside `nix develop`), the desktop app says once per session that it renders on the CPU and points to Help ▸ System Info (`gpu_notice`; not when the GPU is turned off or only a software adapter exists); no tiling beyond the buffer limit (CPU) | | LR-BEHAV-RENDER-FIDELITY | Rendering matches Lightroom | P1 | 🟡 | `crates/pipeline/src/lib.rs`, `crates/pipeline/src/tone.rs` | every slider exists and works, but the character of the result (tone curve shape, highlight recovery, texture / clarity / dehaze, noise reduction, sharpening, default look) is tuned by eye; there is no systematic side-by-side comparison against Lightroom on the same CC0 raws. Needs a fidelity suite: Lightroom reference renders kept only in the local `plan/` (never committed), compared per slider and preset with a perceptual metric. Tuning that changes how existing settings render ships as a new process version (LRC-DEV-PROCESS, `docs/process-versions.md`) | | LR-BEHAV-DRAGDROP | Drag and drop | P1 | ✅ | `crates/ui-egui/src/lib.rs`, `crates/ui-egui/src/panels/grid.rs` (`drag_feedback`), `crates/ui-egui/src/panels/left.rs` (`drop_target`, `album_drag_over`, `top_level_drop_target`, `drag_auto_scroll`) | files → app (import); grid photos → an album row (adds the selection, with a count badge while dragging); sidebar album / folder rows → a folder (moves it; its name follows the pointer); a drag of albums or grid photos near the top or bottom edge of the sidebar scrolls it (faster nearer the edge, only while the pointer is over the sidebar) | | LR-BEHAV-TOAST | Toast notifications | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` | | @@ -470,17 +475,17 @@ differences and missing bindings are in [ui-parity.md](ui-parity.md#shortcuts). | LR-BEHAV-SIDEBAR-REORDER | Reorder left-sidebar sections | P2 | ✅ | `cmd:view.sidebarMoveSection`, `crates/ui-egui/src/sidebar.rs` (`move_before`, `move_by`), `crates/ui-egui/src/panels/left.rs` (`section_drag`, `section_drop_before`, `section_drag_feedback`), `crates/ui-egui/src/tests_panels.rs` | drag a section by its header: its name follows the pointer, a line (`sidebarSectionDrop`) shows the gap it would go to (above the section whose upper half the pointer is over, or below the last), releasing over the sidebar moves it there, Esc or releasing elsewhere leaves it; the sidebar scrolls when the drag nears its top or bottom edge, also over a pinned header (a section picked up by the pinned header scrolls once the pointer has left it). A press on a header's button is never a section drag. A click on the header still folds the section, and an album dragged onto the Albums header is still an album drag. The header's menu has Move Up / Move Down (`sidebarMenu:moveUp` / `moveDown`), one shown section at a time, stepping over hidden ones, disabled at the ends. `view.sidebarMoveSection` `{section, direction: up\|down}` or `{section, before: <section>\|null}` (null: last) answers `{moved, order}`. The order is kept with the UI state (`sidebar`). Recently Deleted is listed with My Photos (below Picks / Missing Photos), not after the sections. Not here: dragging My Photos, or rows from one section into another | | LR-BEHAV-SIDEBAR-STICKY | Pinned section header and section look | P2 | ✅ | `crates/ui-egui/src/panels/left.rs` (`pinned_header`, `section_header`, `empty_hint`), `crates/ui-egui/src/tests_panels.rs` | while the rows of an open section are in view but its header has scrolled away, a copy of the header (`sidebarSectionPinned:<id>`, with the section's buttons and menu) stays at the top of the sidebar; the next section's header pushes it out; folding from it brings the section's own header into view. Rows scrolled beneath the pinned header are covered by it: an album or photos dropped on the pinned Albums header go to the top level / nowhere, never to the row under it; folding from it keeps the sideways scroll. Sections are told apart by a hairline in the gap above each; the fold chevron sits in the gutter, in the column of the rows' triangles (`sidebarSectionChevron:<id>`); a header highlights under the pointer; a folded section shows how many top-level rows it holds (`sidebarSectionCount:<id>`, with Show Photo Counts); a count of 0 is fainter. Not here: a height of its own per section (one scroll area serves the whole sidebar), a count for Recently Added, Local's Browse Folder… as a header button | | LR-BEHAV-PANEL-RESIZE | Resizable side panels | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`resizable_side`), `crates/ui-egui/src/state.rs` (`LEFT_WIDTH`, `RIGHT_WIDTH`), `crates/ui-egui/src/tests_panels.rs` | drag the left sidebar's right edge (200–480 pt) or the right panel's left edge (250–520 pt); the photo area keeps ≥ 360 pt; widths are kept across panels, views and restarts (`leftWidth` / `rightWidth` in the UI state); the Presets column stays fixed | -| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`) | | +| LR-BEHAV-EMPTY-STATES | Empty states | P1 | ✅ | `crates/ui-egui/src/panels/mod.rs` (`empty_message`, `empty_state`), `crates/ui-egui/src/panels/empty_library.rs`, `cmd:file.addPhotos`, `cmd:file.importLightroom`, `crates/ui-egui/src/tests_empty_library.rs` | each view says why it is empty (no photo selected, filters, an empty folder). A library with no photos (none, or only deleted or browsed ones) opens on the Photo Grid (Square Grid stays) with the sidebar, and the grid and Detail view (not while a folder is browsed under Local) offer Import Photos… (primary) and Import Lightroom Catalog… (desktop) as buttons, with a line saying photos and folders can be dropped into the window and the key Import Photos… is bound to now; the right-hand panel says to import photos first. The top bar has an Import button beside Export in every library. No drop-target highlight while files are dragged over the window yet, and a drop imports without the review the menu opens | | LR-BEHAV-TOOLTIPS | Tooltips with shortcuts | P0 | ✅ | `crates/ui-egui/src/panels/bottombar.rs` | | | LR-BEHAV-TEXTFIELD | Text fields behave alike | P1 | 🟡 | `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/panels/right.rs` (`meta_field`), `crates/ui-egui/src/panels/dialogs.rs`, `crates/ui-egui/src/panels/unnamed.rs` | one shared field: right-click Cut / Copy / Paste / Select All (no Paste in the browser), Esc gives back the text from before the edit, Return / leaving / Esc reported to the caller, optional select-on-focus; agents use `ui.clipboard` (MCP `clipboard`) and `ui.inspect` → `copied`. Used by Search Photos, slider values, the Info panel's metadata fields (multi-line captions), the one-field dialogs (New / Rename Album, New Smart Album, Rename / Merge Keywords, text prompts) and the unnamed faces' name field so far; the other text fields (most dialogs, import, settings, masking, keywords …) still use egui's own and have no menu | | LR-BEHAV-HEADLESS | Headless UI snapshots | P2 | ✅ | `apps/lightcraft-cli/src/main.rs`, `crates/ui-egui/src/headless.rs`, `docs/control-protocol.md` | `snapshot` rasterizes the UI and defaults photo development to the CPU before the first frame, without adapter discovery; scaled dimensions are validated before allocation, and UI zoom preserves the requested physical viewport; JSON-lines control scripts are supported | -| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab. Canvas handles, spot and mask placement are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | -| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | -| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian and Ukrainian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | +| LR-BEHAV-ACCESS | Accessibility | P2 | 🟡 | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/panels/grid.rs`, `crates/ui-egui/tests/accessibility.rs` | AccessKit (VoiceOver / Narrator / AT-SPI): sliders announce control, value, range and step and take Increment / Decrement, buttons / icon buttons / dropdowns / section headers / sources their labels and state, text fields their caption, grid thumbnails file name, rating, flag and label. `tests/accessibility.rs` fails on an unnamed control, a slider without range or an untitled dialog in every view, Develop tool and 13 dialogs, and checks that Crop and Remove sliders are reached with Tab and that Tab walks the right panels top to bottom. Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, `crates/ui-egui/src/tests_canvas_keys.rs`): crop corners, edges and box, spot targets and sources, a New spot point (Return adds), radial / linear gradient handles and mask pins take the focus, are announced with their position and move with the arrows (Shift: further). Brush strokes and red-eye ellipses are pointer-only; not tried with a real screen reader ([audit](ui-parity.md#accessibility-audit)) | +| LR-BEHAV-LOCALE | Language options | P2 | ✅ | `cmd:app.language.english`, `cmd:app.language.simplifiedChinese`, `cmd:app.language.traditionalChinese`, `cmd:app.language.japanese`, `cmd:app.language.portuguese`, `cmd:app.language.french`, `cmd:app.language.spanish`, `cmd:app.language.german`, `cmd:app.language.russian`, `cmd:app.language.ukrainian`, `cmd:app.language.georgian` | Edit > Language lists every language in the table and the active one is checked; shared with Settings > General (docs/localization.md) | +| LR-BEHAV-LOCALIZE | Localisation | P2 | 🟡 | `crates/ui-egui/src/i18n.rs`, `crates/ui-egui/locales/`, `docs/localization.md`, `docs/localization-zh-hans.md`, `docs/localization-zh-hant.md`, `docs/localization-ja.md`, `docs/localization-pt-br.md`, `docs/localization-es.md`, `docs/localization-de.md`, `docs/localization-ru.md`, `docs/localization-ka.md` | One table entry per language (code, endonym, ISO 15924 script, catalog): English, Simplified Chinese, Traditional Chinese (Taiwan), Japanese, Brazilian Portuguese, Spanish, German, Russian, Ukrainian and Georgian UI with a persisted language preference and craft-fonts regular/bold faces per script (a variable face, as for Arabic, is drawn heavier for bold from its one file); menus, edit, crop/masks, settings, import/export and primary progress messages. Adding a language registers the locale and its language command, plus two catalogs. Stock presets/profiles, history steps, library headings, date headings and capture times are localised (user-named items stay verbatim); the Ukrainian catalog covers every existing catalog key, command/control label and rule-editor label; the German catalog reports newly added gaps; the other catalogs fall back to English for messages they lack. Technical errors remain English; release notes are translated in German and Ukrainian; craft-fonts faces for other scripts (Thai, Arabic, …) are UI fallbacks in every language, for file, folder and album names (#736); Traditional Chinese borrows the Simplified Chinese face (no TC face in craft-fonts yet), and the web build embeds the Japanese face in WASM while loading the Simplified Chinese face from a separate font file. | | LR-BEHAV-LEARN | Tutorials | OOS | 🚫 | | | | LR-BEHAV-WHATSNEW | What's new | P2 | ✅ | `cmd:app.whatsNew`, `docs/whats-new.md` | Help ▸ What's New: release highlights | | LR-BEHAV-AI-EA | Early-access badges | P2 | ⬜ | | | -| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen. Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | +| LR-BEHAV-TITLEBAR | Title bar drag and double-click zoom (macOS) | P2 | 🟡 | `crates/ui-egui/src/titlebar.rs`, `crates/ui-egui/src/panels/topbar.rs`, `crates/ui-egui/src/tests_titlebar.rs` | the native title bar is hidden on macOS, so the top bar's empty space (including the traffic-light margin) moves the window and a double-click zooms or restores it; buttons and the search field keep their clicks; ignored in full screen, where the traffic lights are gone and the bar's left buttons move into their space (#1763). Zoom is winit's `Maximized`: it does not follow System Settings › “Double-click a window's title bar to” (Fill / Minimize). a press that starts on a button or the search field never moves the window. Known gap: egui-winit reads the zoomed state only at start-up and after our own `Maximized` command, so a window zoomed another way (green button, Window ▸ Zoom, snapping) makes the first double-click a no-op before the toggle catches up. Checked by hand on a real macOS window (drag, zoom, restore) | ## Y. Menus @@ -548,7 +553,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-VIEW-SIDEBARSECTIONS | Sidebar sections | P2 | ✅ | `cmd:view.sidebarSection`, `cmd:view.sidebarReset` | View ▸ Sidebar Sections ▸ a checkable item per left-sidebar section, then Reset Sidebar Sections (see LR-BEHAV-SIDEBAR-HIDE) | | MENU-VIEW-HDR | HDR display options | P2 | ⬜ | | | | MENU-PHOTO-ADDTOALBUM | Add to album | P0 | ✅ | `cmd:album.addPhotos` | off for a Local-only selection, with an Add to My Photos First hint item (issue #684) | -| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | | +| MENU-PHOTO-REMOVEFROMALBUM | Remove from album | P0 | ✅ | `cmd:album.removePhotos` | Photo ▸ Remove from Album, shown in a normal album only, carries the ⌫ shortcut there (Delete Photo then has none) (issue #1805) | | MENU-PHOTO-RATE | Rate submenu | P0 | ✅ | `cmd:photo.rate` | | | MENU-PHOTO-FLAG | Flag submenu | P0 | ✅ | `cmd:photo.flag` | | | MENU-PHOTO-LABEL | Colour label submenu | P1 | ✅ | `cmd:photo.label`, `cmd:dialog.labelNames` | shows custom names; Edit Label Names… | @@ -556,7 +561,7 @@ visible menu bar yet: the menu model is only exposed through the control channel | MENU-PHOTO-FLIP | Flip horizontal / vertical | P0 | ✅ | `cmd:photo.flipHorizontal`, `cmd:photo.flipVertical` | | | MENU-PHOTO-CREATEVERSION | Create Version… | P1 | ✅ | `cmd:version.create` | no name prompt | | MENU-PHOTO-STACK | Stack submenu | P1 | ✅ | `cmd:stack.group`, `cmd:stack.ungroup`, `cmd:dialog.autoStack` | | -| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast` | | +| MENU-PHOTO-MERGE | Photo merge submenu | P2 | ✅ | `cmd:dialog.mergeHdr`, `cmd:dialog.mergePanorama`, `cmd:dialog.mergeHdrPanorama`, `cmd:merge.hdrLast`, `cmd:merge.hdrBatch` | | | MENU-PHOTO-ENHANCE | Enhance… | P2 | ⬜ | | | | MENU-PHOTO-AUTO | Auto settings | P0 | ✅ | `cmd:develop.auto` | | | MENU-PHOTO-BW | Convert to B&W | P0 | ✅ | `cmd:develop.treatment` | | @@ -634,7 +639,7 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-QUIT | Quit — ⌘Q | P0 | ✅ | `cmd:app.quit` | ⌘Q (Ctrl+Q off macOS) | | KEY-CREATEVERSION | Create version — ⇧M | P1 | ✅ | `cmd:version.create` | secondary binding (primary ⌘⇧S) | | KEY-CLOSEWINDOW | Close window — ⌘W | P1 | ✅ | `apps/lightcraft/src/native_menu.rs` | native File ▸ Close Window | -| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete` | | +| KEY-DELETE | Delete photo — ⌫ | P0 | ✅ | `cmd:photo.delete`, `cmd:album.removePhotos` | in a normal album ⌫ removes from the album only (`album.removePhotos`, issue #1805); in a smart album it does nothing; elsewhere it deletes (to Recently Deleted) | | KEY-ADDPHOTOS | Import photos — ⇧⌘I | P0 | ✅ | `cmd:file.addPhotos` | | | KEY-VERSIONS | Versions panel — ⇧V | P1 | ✅ | `cmd:panel.versions` | | | KEY-SECTIONS | Expand/collapse edit sections — ⌘1…⌘6 | P1 | ✅ | `cmd:section.light`, `cmd:section.color`, `cmd:section.effects`, `cmd:section.detail`, `cmd:section.optics` | ⌘1–⌘5 (Light, Color, Effects, Detail, Optics) as observed; our Edit panel has no Lens Blur / Geometry section for ⌘6 / ⌘7; Zoom 100% moved to ⌥⌘0 | @@ -667,16 +672,17 @@ From `06-shortcuts.md` part 1. Evidence is our binding; conflicts are explained | KEY-CROP-OVERLAYORIENT | Crop overlay orientation — ⇧O | P1 | ✅ | `cmd:view.cropOverlayOrientation` | while cropping: O cycles the overlay, ⇧O its orientation | | KEY-CROP-RESET | Reset crop — ⌥⌘R | P0 | ✅ | `cmd:crop.reset` | | | KEY-STRAIGHTEN | Straighten while held — ⌘ drag | P1 | ✅ | `cmd:crop.straighten`, `crates/ui-egui/src/panels/detail.rs` (`straighten_overlay`) | ⌘-drag in Crop draws a level line; the crop tool stays active | -| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs` | | -| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each | +| KEY-SLIDER-RESET | Reset slider — double-click | P0 | ✅ | `crates/ui-egui/src/widgets.rs`, `crates/ui-egui/src/tests_slider_reset.rs` | double-click a slider's name or thumb: back to its default as one undo step (a click on the thumb no longer moves it first, which made it two); resting the pointer on either says so (“Exposure — double-click to reset it”, as the curve channels), the value keeps “Click to type a value” (issue #1436); a disabled slider's name doesn't reset it. No per-slider reset button (Lightroom has none); no ⌥ panel headings turning into Reset buttons yet (`cmd:develop.resetSection` is command-only) | +| KEY-SLIDER-NUDGE | Nudge slider — ↑/↓ | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`nudged`), `cmd:develop.adjust` | ↑/↓ over any slider: ≈ 1/200 of its range (exposure 0.05, most sliders 1, temperature 50 K); ⇧ ×5; one undo step each. Scrolling sideways over any slider (trackpad swipe, tilting wheel, ⇧+wheel) nudges it the same way, one wheel notch a step, without selecting it first; only a mostly sideways movement counts, so scrolling the panel leaves sliders alone (`crates/ui-egui/src/tests_masking.rs`, `sideways_scrolling_adjusts_the_slider_under_the_pointer`) | | KEY-SLIDER-TYPE | Type a slider value | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`typed_value`), `crates/ui-egui/src/text_field.rs`, `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`opening_release_honors_return_and_escape_immediately`), `crates/ui-egui/src/tests_keymap.rs` (`opening_numeric_release_keeps_keys_in_the_editor`) | click the value next to any slider's name and type one (`1.5`, `+0,25`, `-20`, `5600 K`): Return or clicking away applies it on the control's steps and range as one undo step, Esc keeps the old value; issue #322; Return/Escape take effect on the opening release, retaining the stored value on Escape; typing stays in the editor without triggering global shortcuts | +| KEY-SLIDER-SCRUB | Scrub a slider value — drag the number | P1 | ✅ | `crates/ui-egui/src/widgets.rs` (`slider`, `SCRUB_START`), `crates/ui-egui/src/widgets/tests_numeric_entry.rs` (`dragging_the_value_sideways_scrubs_it_like_the_thumb`, `scrubbing_back_and_with_shift`, `a_press_that_barely_moves_still_types`) | drag the value next to any slider's name sideways: it moves as dragging the thumb would (the pointer's travel over the track's width, ⇧ a tenth as far), on the control's steps and range, as one undo step; a press that moves less than 3 points sideways stays a click that types a value (KEY-SLIDER-TYPE); the pointer over the value is the horizontal-resize cursor, as Lightroom's scrubby value | | KEY-SHORTCUTS | Shortcut list — ⌘/ | P1 | ✅ | `cmd:app.shortcuts` | | | KEY-HELP | Help — F1 | P2 | ✅ | `cmd:app.help` | | | KEY-VIDEO-PLAY | Play/pause video — Space | P1 | ⬜ | | | | KEY-ESC | Leave tool / view — Esc | P0 | ✅ | `cmd:view.back`, `crates/ui-egui/src/icons.rs` (`navigation_arrowheads_have_both_halves`), `crates/ui-egui/src/tests_crop_tool.rs` | top-bar navigation arrowheads render both halves at 1× and 2× scale; in the crop tool Esc closes the tool first (the crop stays), the next Esc goes back to the grid; Esc that ends an edit in a text field does nothing else | | KEY-COMMIT | Commit tool — Return | P1 | ✅ | `cmd:tool.done` | Return closes Crop / Remove / Red Eye / Masking back to Edit (edits apply live, as in Lightroom) | | KEY-DELETE-PIN | Delete selected pin — ⌫ | P0 | ✅ | `cmd:mask.delete`, `cmd:spot.delete` | ⌫ deletes the active mask (Masking) or the selected spot (Remove), never the photo while retouching | -| KEY-HIDEPINS | Hide pins — H | P2 | ⬜ | | H = Remove panel | +| KEY-HIDEPINS | Hide pins — H | P2 | ✅ | `cmd:view.toolOverlay`, `crates/ui-egui/src/tests_tool_overlay.rs` | ⇧H cycles the Tool Overlay (Auto → Always → Selected → Never) for spot and mask pins, since H opens Remove; also View ▸ Tool Overlay and the Remove / Masking panels (#224) | ## Lightroom Classic extras @@ -692,11 +698,11 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-SURVEY | Survey view | P2 | ✅ | `cmd:view.survey` | N | | LRC-LIB-REFVIEW | Reference view | P2 | ✅ | `cmd:view.reference`, `cmd:photo.setReference`, `crates/ui-egui/src/panels/compare.rs` (`show_reference`) | View ▸ Reference View (⇧R): the reference photo beside the active one (edited with the Edit panel); photo menu ▸ Set as Reference Photo; each pane says “Rendering…” (widget `compare:rendering:<pane>`) or “This photo can't be opened” (`compare:renderFailed:<pane>`) as the loupe does | | LRC-LIB-CATALOG-PANEL | Catalog sets | P1 | ✅ | `cmd:library.source` (`missing`) | all, recently added, picks, recently deleted, missing photos (when any) | -| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also a catalog whose tables are all still in its WAL: the main file's schema format 0 is an empty schema), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | +| LRC-LIB-CATALOG-IMPORT | Native Lightroom Classic catalog import | P1 | 🟡 | `cmd:library.inspectLightroom`, `cmd:library.importLightroom`, `crates/engine/src/lightroom_catalog.rs`, `docs/lightroom-catalog-import.md` | read-only `.lrcat` plus committed WAL pages (also WAL-only tables with main schema format 0), in-tree read-only Rust SQLite B-tree reader; background inspection/import with progress/cancellation, per-open library identity checks; persistent result with destination library, offline-original counts and Show in Library, dismissed when switching libraries; offline Local records are promoted without probing missing files; originals in place, ratings/flags/labels, XMP, hierarchical keywords, collection sets/regular collections, virtual copies, mapped develop settings; stable reimport identities, undo, missing-file placeholders, a root folder not found at its recorded path looked up relative to the catalog (Lightroom's `relativePathFromCatalog`, so a project folder copied from another computer stays linked; reported as a warning), existing edits preserved. Best-effort deduplicated recovery archives retain settings/history/snapshots/collection rules (32 MiB each, eight archives/128 MiB aggregate); archive limits/write failures and optional-table limits warn without blocking photo import; unrelated unsupported tables and opaque SQL BLOBs skipped. Custom white balance on relative-WB raws (`RAW` in Lightroom's terms) stays As Shot and is reported, since the catalog keeps no as-shot white to shift from (issue #510). Adobe rendering, AI data and native history replay remain incomplete; no writes to Lightroom catalogs. Open Library given a Lightroom catalog folder, an `.lrdata` / `.lrcat-data` folder or the `.lrcat` makes no library there and offers this import with the catalog found (`cmd:app.openLibrary`, `crates/engine/src/lightroom_folder.rs`; issue #891) | | LRC-LIB-CATALOG-LOCK | Catalog open in one program at a time | P1 | ✅ | `cmd:app.openLibrary`, `crates/catalog/src/lock.rs`, `crates/engine/src/library.rs` (`open_library`) | exclusive OS lock on `catalog.lock` for the session (app and `lightcraft-cli --library`); a second opener is refused with who has it (`catalog.lock.owner`: program, process, computer); released by the OS on a crash, so never stale; file systems without locks open unprotected with a warning (the app's notices, `library.info` → `settingsWarnings`, and the CLI's stderr; issue #171); no read-only mode; the browser build (OPFS) has no guard across tabs yet. A library that can't be opened at launch (locked, unreadable, newer format, drive missing) shows a window — Try Again, Choose Another Library…, Continue Without Saving (then a banner; nothing written), Quit — instead of a silent demo session (`crates/ui-egui/src/panels/library_problem.rs`); the lock is released explicitly when the library closes, not by letting its file handle close, so a program another thread is starting at that moment (which holds a copy of every file handle until it is launched) cannot keep the library "already open" for a moment (issue #680) | -| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open | +| LRC-LIB-CATALOG-SWITCH | New / Open Recent catalog, catalog name shown | P1 | ✅ | `cmd:app.newLibrary`, `cmd:app.openLibrary`, `cmd:app.recentLibraries`, `cmd:app.clearRecentLibraries`, `crates/ui-egui/src/panels/settings.rs`, `crates/ui-egui/src/panels/left.rs` | File ▸ New Library… (name, then location), File ▸ Open Recent Library (last 8; missing ones greyed out, dropped when opened; Clear), the open library's name under My Photos and in the window title (issue #902); a library that can't be opened leaves the current one open; ⌘O (Ctrl+O on Windows and Linux) runs Open Library… and asks for a folder once (`crates/ui-egui/src/tests_keymap.rs`: `cmd_o_opens_the_library_picker_once`); a dialog or a text field with the focus keeps the key | | LRC-LIB-FOLDERS | Disk folder tree | P1 | ✅ | `cmd:library.browse`, `cmd:folder.rename`, `cmd:folder.move`, `crates/ui-egui/src/panels/left.rs` (`local_section`, `folder_tree`) | standard places + the browsed folder + Browse Folder…, each an expandable tree of subfolders, breadcrumb navigation; context menu: Rename Folder…, Move Folder To…, Show in Finder (on disk, photos relinked; one undo step each: undo / redo move the folder back and forth, refused when the destination is taken) | -| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu: files and XMP sidecars move, virtual copies follow, across volumes copied and verified before the source goes, a name taken in the destination refuses the whole move, one undo step), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | +| LRC-LIB-LIBFOLDERS | Library folders | P1 | ✅ | `cmd:library.folders`, `cmd:library.source` (`libraryFolder`), `cmd:library.filter` (`libraryFolder`), `cmd:library.removeFolder`, `cmd:folder.rename`, `cmd:folder.move`, `cmd:folder.label`, `cmd:folder.create`, `cmd:photo.moveToFolder`, `cmd:folder.scanChanges`, `cmd:folder.synchronize`, `crates/catalog/src/folders.rs`, `crates/engine/src/sync.rs`, `crates/ui-egui/src/sync.rs`, `crates/ui-egui/src/panels/left.rs` (`folders_section`, `folder_menu_for_library`, `row_menu`), `crates/ui-egui/src/tests_panels.rs` | sidebar Folders: the folders the library's photos were imported from, with photo counts (subfolders included), as another way to explore the library next to By Date and Keywords; Local stays the way to browse any folder on disk. Each disk is a row of its own and starts open: a drive letter, a Windows share (`\\server\share`, `\\?\UNC\…`, `\\wsl$\…`), a macOS share or disk (`/Volumes/tokyo`), a Linux mount (`/media`, `/run/media`, `/mnt`, gvfs `smb-share:…`, `/net/<host>`), NFS (`/Network/Servers/<host>`), else “This Computer” — told apart from the stored paths alone, so an offline share lists like a mounted one and the sidebar never waits on a network. Only folders that hold imported photos are listed (browsed, deleted and demo photos don't count); every folder is a row of its own (`Users` → `me` → `Pictures`), and a folder that holds nothing itself and leads to one folder starts open; every level indents one step more than its parent however deep it goes: the whole sidebar (Albums, Local, By Date, Folders, Keywords) scrolls sideways when its widest row is wider than the panel (rows are measured one frame behind; Create Album and the counts stay at the visible edge); a name that does not fit ends in an ellipsis at the count (the full name on hover; a row asks for room for at most 140 pt of its name, so depth, not one long name, makes the sidebar scroll), as does the grid title; selection and hover bars end at the panel's edge; click makes the folder the source, like an album or a Local folder (its photos and those of the folders inside it fill the grid, titled “photos/travel”; the filter bar narrows it further; `library.source` `libraryFolder` + `path`; kept with the library's view), and its row is always opened into view; the shown folder follows a rename or move and their undo (engine-tested; the sidebar test covers rename), is dropped back to All Photos when it is removed or no longer holds a photo, and a smart album made from the view keeps it, also when its rules are edited (the editor has no folder field). Only a folder that shows exactly its own photos can be shown (not the startup disk, not `/Volumes`-like folders above disks, not an empty one). A row that would cover other disks' photos too (the startup disk, `/Volumes`, `/mnt`…) only opens. Right-click (row or triangle): Rename Folder…, Move Folder To…, New Folder on Disk… (`folder.create`; one undo step, undo removes it while empty), Move Selected Photos Here (`photo.moveToFolder`, also *Move to Folder…* in the photo context menu, and photos dragged from the grid onto a Folders row: the row lights up, the dragged label says “Move 3 photos to “Trip””, a closed folder held under the pointer opens, a row covering several disks and Local photos are refused with the reason, photos already there say so; a drop asks first (“Move 2 photos to “Porto”?”, Don't ask again, Settings → Confirm before moving photos to another folder on disk), and photos that leave the view leave the selection; files and XMP sidecars move, virtual copies follow, across volumes copied (streamed) and verified before the source goes, a name taken in the destination refuses the whole move, one undo step; an undo with the share offline or read-only is refused with the reason and kept until it can run, nothing deleted), Show in Finder (on disk, photos relinked, as in Local), Set Color Label ▸ (the five colours with their custom names, None, Edit Label Names…; `folder.label`, one undo step; the row shows the label's dot before its count and says it with its name; kept in the catalog as a folder record under the folder's identity, so it follows Rename / Move and their undo and redo, the folders inside going along), Synchronize Folder… (the dialog opens at once and scans the folder and the folders inside it on a worker thread — the look for new files beside parallel checks of the library's photos — then offers Import new photos (checked; files only browsed in Local count as new), Relink photos whose file was renamed or moved within the folder (checked; matched by content — a Duplicate's too — or, for a photo with no hash on record such as one from a Lightroom catalog, by file size and dimensions when exactly one file agrees), Remove missing photos from the library (to Recently Deleted) and Read metadata updates from XMP sidecars (saved after the photo came in or was last edited here, and saying something the library doesn't; the sidecar wins), with counts and skipped duplicates / unreadable files; `folder.scanChanges` / `folder.synchronize`, which acts on exactly the scan shown — a scan whose folder's photos changed meanwhile is refused and made again in the background — and is one undo step, also with a full history; a folder that isn't there (an unplugged disk) is reported as such, not as every photo missing; a whole disk or a folder holding disks needs `disk: true`; Esc stops the scan; Synchronize hands everything to a worker thread — the import, the checks that moved and missing files are still as found, the sidecar reads — and commits its steps between frames, shown in the activity stack with ✕ to stop, so no file is touched on the UI thread) and Remove from Library… (asks; moves the folder's photos to Recently Deleted, one undo step, no file touched); a disk row (not the startup disk) offers Set Color Label ▸, Synchronize Folder… and Remove Disk from Library… (the command needs `disk: true` for a whole disk or a folder spanning several). Limits: names are compared as written (macOS `/Volumes/tokyo` vs `/Volumes/TOKYO` are two disks; `/Volumes/Macintosh HD/…` and `/…` two rows); no disk capacity / online status; no “include subfolders” switch (a folder always includes the ones inside it); photos can't yet be dragged onto a folder row (use the menus); a name clash on moving photos is refused rather than renamed -1, -2; folders can't be filtered or sorted by label, and a label stays with the old path when the folder is moved outside LightCraft (Find Missing relinks photos, not folder records); Synchronize has no “show import dialog first” or “show missing photos” option, and only knows when a photo came in or was last developed here: a rating, label or metadata change made here (auto-write off) after another app saved the sidecar is listed as an update, and reading it restores the sidecar's older value (reading is never the default); the walk for new files isn't cancelled midway and its depth/entry limits are only logged | | LRC-LIB-COLLECTIONS | Collections & sets | P1 | ✅ | `cmd:album.create`, `cmd:album.createSmart`, `cmd:album.toggleTarget`, `cmd:album.setTarget`, `cmd:album.clearQuick` | albums + folders (sets), smart albums (rule editor; rules incl. File Path — the whole string, either separator, case-insensitive), Quick Collection and target album (B in the grids adds / removes the selection; album menu ▸ Set as Target Album; the target is marked +) | | LRC-LIB-SMARTCOLL | Smart-collection rules | P1 | ✅ | `cmd:album.createSmart`, `cmd:album.setRules`, `cmd:album.ruleFields`, `cmd:dialog.smartAlbum`, `crates/catalog/src/rules.rs`, `crates/ui-egui/src/panels/rules_editor.rs` | rule editor: match all / any / none, nested groups (⌥+ or + Group), 47 fields. The field menu shows Rating, Pick Flag, Color Label and Any Searchable Text, then one submenu per group (`FIELD_GROUPS`; `album.ruleFields` reports each field's `group`): Source (album — a plain or a smart album, so one smart album can include or exclude another; a rule that would make an album include itself, directly or through others, is refused, and a loop saved anyway can't recurse: inside it, the album being evaluated counts as holding no photos, so an "isn't" rule on it matches every photo, and the sidebar marks it ⚠ —, virtual copy, copy name, in a stack), File (filename, extension, file path, type, format, video duration), Date (capture / import / edit), Keywords & People (keywords, keyword count, people on named faces, people count), Description (title, caption, alt text, creator, copyright, copyright status), Camera Info (camera, lens, focal length, aperture, shutter speed as 1/250, ISO), Location (any, city, state / province, country, GPS), Size (long / short edge, aspect ratio and megapixels, all of the photo as shown: oriented and cropped), Develop (has edits, cropped, treatment), Assisted Culling (focus, best of similar shots); text / number / date / in-the-last operators; live match count; also usable as a library filter (`ruleSet`). Missing vs Classic: develop preset, flash, exposure bias, camera serial, colour profile / bit depth, IPTC Job, smart-preview status. Choice values read as words in every language (Public Domain, Black & White; `album.ruleFields` gives `choiceLabels`), while rules store the ids; yes/no fields read Yes / No and take `true`/`false`, `"yes"`/`"no"`, `"true"`/`"false"` or 1/0. Every value is checked against its field (`RuleSet::check`): numbers (rating 0–5), shutter speeds, dates (`2026`, `2026-08`, `2026-08-14`, then `T10`, `T10:00` or `T10:00:00`, a space working as the `T`, and after whole seconds a fraction and a time zone as imported capture times carry them (`.250+02:00`); no 30 February; `rules::rule_date` is the one reading for the check and the matcher; each date field has a calendar picker, `crates/ui-egui/src/date_picker.rs`, that picks a year, a month or a day and keeps a "between" in order), between = two bounds in order, in the last = more than 0 of a known unit, choices (ids, the flag's picked / rejected, a colour label's custom name), text with something to look for, an existing album (plain or smart, not a folder), no empty groups; each problem names its rule (`rule 2.1: no rating 0–5 is 9`), and album.createSmart / setRules / library.filter refuse them. The editor marks each bad rule under its row (inside groups too), keeps OK disabled until they are fixed (and its confirm refuses them before renaming anything), picks an Album rule's album with an album picker (`crates/ui-egui/src/album_picker.rs`: the sidebar's tree with folders, or type part of a name or folder; ↑ / ↓ / Enter; albums that can't be picked shown greyed with the reason) (is / isn't; a folder can't be tested), and drawing it never changes a rule: a rating of 9, "in the last 0 days" or 20,000 days, a "between" that isn't a pair all stay as stored (and are marked where they can't match) until edited. Album rules saved with an old operator (≥, between) are read as the "isn't" they always meant (`RuleSet::upgrade`). Saved smart albums whose rules no longer check are marked ⚠ in the sidebar (tooltip: what to fix; `albums.list` `problems`, `Catalog::smart_album_problems`); the name and rules of an album are saved as one undo step (`album.setRules` `name`). Problems read as the field's name and the issue in the editor's language (“Titel: braucht einen Suchbegriff”; `Problem::issue` / `field`, `Issue::text`, translated in every locale), while commands keep the English `rule 2.1: …` message for agents | | LRC-LIB-PUBLISH | Publish services | P2 | ⬜ | | | @@ -704,10 +710,10 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-LIB-STACKS | Stacks (full) | P1 | ✅ | `crates/catalog/src/stacks.rs`, `cmd:stack.group`, `cmd:stack.split`, `cmd:stack.moveUp`, `cmd:stack.moveDown` | group / ungroup / toggle / set top / remove / auto by time / split / move up and down | | LRC-LIB-VC | Virtual copies | P1 | ✅ | `cmd:photo.virtualCopy` | ⌘' | | LRC-LIB-LABELS | Colour-label sets | P1 | ✅ | `cmd:label.sets`, `cmd:label.applySet`, `cmd:label.saveSet`, `cmd:label.deleteSet`, `cmd:label.setNames`, `crates/engine/src/cmd/manage.rs` | built-in Colors / Review sets + user sets (Photo ▸ Set Color Label, Edit Label Names… dialog); names written to and read from `xmp:Label` | -| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | +| LRC-LIB-KEYWORDS | Hierarchical keywords, sets, painter, Keyword List | P1 | ✅ | `crates/catalog/src/keywords.rs`, `crates/ui-egui/src/panels/keyword_list.rs`, `cmd:keyword.list`, `cmd:keyword.create`, `cmd:keyword.edit`, `cmd:keyword.move`, `cmd:keyword.info`, `cmd:keyword.purgeUnused`, `cmd:keyword.setDefaultParent`, `cmd:keyword.sets`, `cmd:keyword.toggleFromSet`, `cmd:keyword.saveSet`, `cmd:tool.keywordPainter` | hierarchical `a\|b\|c` keywords (tree, parent filters include children, rename moves children); keyword sets + Recent Keywords (⌥1–⌥9); keyword painter (Keywords panel ▸ Paint: click photos in the grid to toggle a keyword, Esc stops). Keyword List (Keywords panel): every keyword, those without photos too (a keyword list in the catalog, format version 4), with counts, a filter box, a tri-state tick box for the selection and an arrow to show its photos; + / Create Keyword Tag (name, synonyms, put inside, add to selected photos), double-click / Edit Keyword Tag, − / Delete with confirmation, context menu (create inside, Put New Keywords Inside This Keyword, purge unused keywords); drag a keyword onto another to nest it or onto the list's title for the top level (a merge asks first), drag photos onto a keyword to tag them. The list is there without a selection too; Put New Keywords Inside This Keyword (kept with the keyword in the library, undoable) also places new keywords typed in the Keywords panel. Keyword tag options include on export, export containing keywords and export synonyms shape exported files (names in `dc:subject`, paths in `lr:hierarchicalSubject`), and files' `lr:hierarchicalSubject` paths come back as keywords on import and from sidecars; Person is stored only. Keywording box (`crates/ui-egui/src/panels/keywording.rs`): the selection's keywords as chips (one only some selected photos have marked with an asterisk; × only removes, from every selected photo; with no selection, the active photo's), a Keywords / & Containing / Will Export switch (Lightroom Classic's Keyword Tags views: the keywords to edit, them with their parents, what exported files carry), keyword-set buttons and suggestions that follow the selection, shared text fields for typing and painting. Settings ▸ Interface ▸ Keywords of the selection ▸ Grouped by parent (`keywordChips: grouped` in the app settings; Whole names stays the default): keywords inside the same keyword share a line naming it (`keywordGroup:<parent>`: the deepest keyword a chip shares with another) and their chips say only what is below it, side by side; a list of more than ten lines stops after eight with “+N more” (`keywordChipsMore`, then `keywordChipsFewer`); the whole list is for the selection it was asked of, another selection's is short again, and the suggestions say the keyword's own name (its whole path when two share a name, and in the tooltip). The titles of Keywords and of the Keyword List fold their sections shut (`section:keywording`, `section:keywordList`; kept in the UI state as `foldedSections`), and a selection without keywords says “No keywords yet”. The Add keyword box offers the library's keywords for what is being typed (after the last comma) right under itself (`kwComplete:<keyword>`, at most eight, those every selected photo has left out, a keyword spelled two ways once; above the box where the window has no room under it, and not at all while the box is scrolled out of the panel): ↓ / ↑ without Shift or Alt move through them (`kwCompleteAt:<keyword>`; the highlight stays with its keyword when the library changes), Return or a click gives the highlighted one with the keywords typed before it and leaves the cursor in the box; with none highlighted Return gives what was typed, as before. A name the library doesn't have is announced as a new keyword, with the keyword it would go inside (`kwCompleteCreate`). The suggestions below no longer change while typing. A refused add says why and keeps the text. With several photos selected the box says how many its keywords are of (`keywordScope`); a chip's × is a target as tall as the chip and 22 pt wide that lights up on its own, and taking a keyword off several photos (× or the chip's menu) says which keyword, from how many photos, and that Edit ▸ Undo puts it back; a refused removal says why. Chips and their × show the keyboard focus. Edit Keyword Set (Keyword Set ▸ Edit Set…): a set's name and nine slots typed in place, empty slots kept so each keyword stays on its ⌥ key, a set renamed in place (`cmd:keyword.saveSet` `replace`) or saved as a new set (`new`, a taken name refused). File ▸ Import Keywords… / Export Keywords… (`cmd:keyword.import`, `cmd:keyword.export`): keyword list files in Lightroom Classic's format (tab-indented, `{synonym}`, `[left out of export]`), which Capture One, darktable, Bridge and Photo Supreme (its Formatted Vocabulary File) read and write too; import adds what is missing in one undo step, export names the keywords Capture One refuses (; , < >). Not yet: synonyms in search, person keywords tied to faces, Keyword Shortcut, IPTC keywords in exported files (Piwigo, RawTherapee), hierarchy from digiKam / ACDSee / Microsoft XMP fields | | LRC-LIB-METADATA | Metadata panel & presets | P1 | ✅ | `cmd:photo.setMeta`, `cmd:metadata.savePreset`, `cmd:metadata.applyPreset`, `cmd:metadata.presets`, `cmd:metadata.deletePreset`, `cmd:dialog.captureTime` | IPTC core, accessibility, place, capture-time edit; copyright status (unknown / copyrighted / public domain), rights usage terms and copyright info URL (`photo.setMeta` `copyrightStatus`, `usageTerms`, `copyrightUrl`; XMP Rights Management fields, kept by "copyright only" exports, a smart-album rule); metadata presets (Photo → Metadata Preset, Save Metadata Preset… from the active photo; applied on import from Settings) | | LRC-LIB-QUICKDEV | Quick develop | P2 | ✅ | `cmd:develop.quickAdjust`, `crates/ui-egui/src/panels/edit.rs` (`quick_develop`) | grid with several photos selected: Edit panel ▸ Quick Develop steps (exposure ⅓ / 1 stop, contrast, highlights, shadows, whites, blacks, clarity, vibrance, temperature) added to each photo's own value, one undo step | -| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm, × to hide). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people +| LRC-LIB-PEOPLE | People view | P2 | 🟡 | `cmd:view.people`, `cmd:view.person`, `cmd:faces.person`, `cmd:faces.unnamed`, `cmd:faces.nameFaces`, `crates/ui-egui/src/panels/people.rs`, `crates/ui-egui/src/panels/person.rs`, `crates/ui-egui/src/panels/unnamed.rs`, `crates/engine/src/face_tiles.rs` | Named People cards (face close-up, the photo count on the picture, name) for the photos the active filters let through, with the filter chips shown above them, sized by the thumbnail slider. Below them the **Unnamed faces**: every face nobody has named as a cropped picture, look-alikes next to each other once recognition has looked at them, a suggested name on a face the named ones recognise; click or Shift-click to select, type a name, Enter names the whole group in one undo step. A click on a person opens their page: only cropped faces, then **More** (unnamed faces that look like them, best first; click to confirm one, × to hide for the session, or ↑ / hovered-or-focused A key to confirm all currently shown non-hidden suggestions at or above that face's score, including ties, in one undo step). Every face is shown by the detector's box where the scan has one, so faces are equally close. Until recognition is set up a line at the top offers the next step (Settings ▸ Faces, or Turn on), as does the loupe's name box. Face pictures of unedited raws are cut from the largest embedded preview (planned read: header and JPEG only, never the sensor data) when it holds at least ¾ of the tile's pixels across the face, else developed as before; all are kept in the thumbnail disk cache at 16 px size steps, so a later launch shows them without reading the originals (#1440, `docs/faces.md`). No clusters with separators between groups, no persistent reject, no merge of two people | LRC-LIB-COMMENTS | Comments panel | P2 | ⬜ | | | | LRC-LIB-VISUALSEARCH | Find similar photos | P2 | ✅ | `cmd:library.findSimilar`, `crates/pipeline/src/cull.rs` (`signature`) | photo menu / Photo ▸ Find Similar Photos: look-alike photos (composition and tones), most similar first, as a filter (`only`) | | LRC-LIB-MISSING | Missing files & relink | P1 | ✅ | `cmd:library.missing`, `cmd:photo.relink`, `cmd:library.findMissing`, `cmd:file.findMissing`, `cmd:photo.locate`, `crates/engine/src/cmd/missing.rs` (`checked_path`), `crates/ui-egui/src/panels/left.rs` (`locate_missing_item`) | File → Find Missing Photos… (anywhere in a folder: same name, size and content hash; renamed files by size + content hash; same name and size alone only when unambiguous — look-alikes are skipped and reported; searched on a worker thread, one undo step), photo menu → Locate Missing File…; a folder or disk row's Locate Missing Files… chooses its new location and scopes the same recursive background finder to that folder and its subfolders. Unreadable files show "!" in the grid and a reason in the loupe; undo never moves files. Missing Photos in the sidebar (counted on a worker thread; the view, the Info panel, the grid's context menu and thumbnails read file availability from a cache a worker thread fills, so an offline NAS never stalls a frame); it covers library photos only — Local browse records are never checked, for the count, the view, `library.missing` and Find Missing alike | @@ -724,6 +730,7 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-DEV-DEFAULTS | Per-camera raw defaults | P1 | ✅ | `cmd:library.preferences` (`camera`, `import.perCamera`), `crates/ui-egui/src/panels/settings.rs` | Settings → Import: raw default and per-camera presets | | LRC-DEV-PROCESS | Process versions, Update to Current Process | P1 | ✅ | `cmd:develop.updateProcess`, `cmd:develop.reset`, `cmd:develop.get`, `crates/develop/src/settings.rs`, `crates/pipeline/src/finish.rs`, `docs/process-versions.md` | each photo records the rendering process its settings are interpreted by: settings saved before this existed load as V1, new photos and Reset get the latest, virtual copies inherit, copy / paste / sync / presets keep each photo's own; Photo ▸ Update to Current Process (one undo step, enabled while a selected photo is on an older process); a number from a newer LightCraft is kept and renders with the newest known process. Only V1 exists so far: this is what lets a retuned default tone (#146) or stronger Light sliders (#196) ship without changing existing edits | | LRC-DEV-VIEWOPTIONS | Develop view options | P2 | ⬜ | | | +| LRC-DEV-LOUPEOVERLAY | Loupe overlay: layout image | P2 | 🟡 | `cmd:view.chooseLayoutImage`, `cmd:view.layoutImage`, `cmd:view.layoutImageOpacity`, `cmd:view.layoutImageMatte`, `crates/ui-egui/src/panels/layout_overlay.rs` | View ▸ Loupe Overlay: a PNG with transparency (a cover layout, a banner's safe areas) fitted and centred over the loupe photo, following zoom and pan; opacity and matte (the photo darkened outside the graphic's box) in steps; ⌥⌘O shows / hides it; read off the UI thread; saved with the UI state. Not yet: the Grid and Guides overlays, moving and resizing the layout with ⌘ held, free opacity / matte sliders | | LRC-DEV-VIDEO | Video frame capture | P2 | ⬜ | | | | LRC-MAP-VIEW | Map view | P2 | ⬜ | | | | LRC-MAP-GEOTAG | Drag photos onto the map | P2 | ⬜ | | | @@ -741,23 +748,23 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-BOOK-BG | Book backgrounds | P2 | ⬜ | | | | LRC-BOOK-VIEWS | Book views | P2 | ⬜ | | | | LRC-BOOK-EXPORT | Book export (PDF/JPEG) | P2 | ⬜ | | | -| LRC-SS-TEMPLATES | Slideshow templates | P2 | ⬜ | | | -| LRC-SS-OPTIONS | Slideshow options | P2 | ⬜ | | | -| LRC-SS-LAYOUT | Slideshow layout | P2 | ⬜ | | | -| LRC-SS-OVERLAYS | Slideshow overlays | P2 | ⬜ | | | -| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ⬜ | | | -| LRC-SS-TITLES | Slideshow titles | P2 | ⬜ | | | +| LRC-SS-TEMPLATES | Slideshow templates | P2 | 🟡 | `cmd:slideshow.templates.list`, `cmd:slideshow.templates.apply`, `cmd:slideshow.templates.save`, `cmd:slideshow.templates.update`, `cmd:slideshow.templates.rename`, `cmd:slideshow.templates.delete`, `cmd:slideshow.templates.import`, `cmd:slideshow.templates.export`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_template.rs`, `path:crates/ui-egui/src/panels/slide_templates.rs` | Template Browser at the top of Settings ▸ Slideshow: a template is the Playback (not "prepare previews"), Layout / Options / Backdrop, Overlays and Titles settings as one versioned JSON document. Five built-ins of our own (Plain Black, Gallery Wall, Edge to Edge, Cinema Wide, Shooting Data; no Lightroom names or designs, no image or font files) and the user's own, saved as files in `<settings>/slideshow-templates` (machine-wide, like Classic's): save current settings, Update with Current Settings, rename, delete (confirmed), import / export `.json`. Click applies (one step, saved with the library); pointing at one previews it on the selected photo (or the intro / ending) in the live preview without changing anything (`slideshow.preview {template}`). No undo for library preferences: the settings a template replaced stay as "Previous settings" for the session (applying it again goes forward). Every section is read through its settings' own validation (out-of-range values clamped, wrong types or unknown keys refused), files are capped (256 KB, 1000 listed), a newer format version is refused with a message, a bad file is listed as skipped, names are sanitised into file names that can't leave the folder, a missing plate / watermark image or background photo applies with a warning. Missing: the Slideshow module's left-panel browser (it lives in Settings until the module view exists), template folders (New Folder, moving templates between folders), saving a slideshow with a collection, a preview thumbnail inside the browser itself | +| LRC-SS-OPTIONS | Slideshow options | P2 | ✅ | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/slideshow/src/compose.rs` | Zoom to Fill Frame (the photo covers its image cell, cropped to it; off: fits inside), Stroke Border (colour, width as a share of the slide's shorter side, drawn around the photo), Cast Shadow (opacity, offset, radius, angle; a blurred offset rectangle under the photo and its stroke). Composed by `lightcraft-slideshow` (CPU, no UI) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`; edited in Settings ▸ Slideshow until the Slideshow module view exists | +| LRC-SS-LAYOUT | Slideshow layout | P2 | 🟡 | `cmd:slideshow.layout`, `cmd:slideshow.preview`, `path:crates/ui-egui/src/panels/slide_layout.rs` | Margins left / right / top / bottom (linked or each on its own; shares of the slide's shorter side, so any screen or output size looks the same), Show Guides, Aspect Preview (Screen, 16:9, 4:3). Missing: the Slideshow module's slide editor — guides are drawn only in the Settings ▸ Slideshow preview and can't be dragged to set the margins; the aspect preview applies to that preview and `slideshow.preview` (playback always fills the screen, as in Classic) | +| LRC-SS-OVERLAYS | Slideshow overlays | P2 | 🟡 | `cmd:slideshow.overlays`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_overlay.rs`, `path:crates/ui-egui/src/panels/slide_overlays.rs` | Identity plate (text with size / colour / opacity, or a PNG / JPEG file with its transparency; render behind image), rating stars (our own vector stars, colour, size), text overlays (up to 16) with tokens (`{file}`, `{caption}`, `{title}`, `{date}`, `{exposure}`, `{shutter}`, `{aperture}`, `{focal}`, `{iso}`, `{camera}`, `{lens}`, `{rating}`, `{copyright}` and the other rename tokens; unknown tags show as typed and are reported), size / colour / opacity and a drop shadow (opacity, offset, radius, angle); each at one of nine anchors of the slide or the photo plus an offset, kept on the slide; the export watermark on the photo. Sizes relative to the slide; drawn by the engine over the composed slide (Inter + craft-fonts CJK, the export watermark's text renderer) for playback, the Settings ▸ Slideshow preview and `slideshow.preview`. Missing: dragging overlays on the slide in a Slideshow module editor, font family / style choice (Inter SemiBold only), the Identity Plate Editor's styled multi-run text, right-to-left reordering (Arabic / Hebrew draw in logical order, and without glyphs in Inter) | +| LRC-SS-BACKDROP | Slideshow backdrop | P2 | ✅ | `cmd:slideshow.layout`, `path:crates/slideshow/src/compose.rs` | Background colour, Color Wash (a gradient from a second colour, full at the side its angle points to, at an opacity), Background Image (a library photo filling the slide at an opacity: "Use selected photo" in Settings ▸ Slideshow, `backgroundImage` in `slideshow.layout`; rendered once and shared by the slides). In the web build the composition runs on the main thread when a worker's render comes back | +| LRC-SS-TITLES | Slideshow titles | P2 | 🟡 | `cmd:slideshow.titles`, `cmd:slideshow.preview`, `path:crates/engine/src/slide_title.rs`, `path:crates/ui-egui/src/panels/slide_titles.rs` | Intro screen and ending screen, each on or off (library preference, `slideshow.titles`; Settings ▸ Slideshow ▸ Titles): a background colour and, centred on it, the identity plate of the Overlays section (its text or image; Add Identity Plate) or the screen's own line of text, in the plate's colour or an override colour, at a scale (height as a share of the slide's shorter side; text wider than the slide shrinks to fit), for its own duration (default: the slide duration). Titles are items of the playback timeline: composed off the UI thread by the same composer at the screen's size, prepared ahead, waited for and faded like slides; the intro plays once before the first photo, the ending after the last photo when repeat is off; random order never moves them; ←/→ step across them; they never change the selection and the control bar shows Intro / End instead of n / N. `slideshow.preview {title}` renders a screen; the Settings preview shows Photo / Intro / Ending. An empty view still refuses to play ("no photos to show"). Missing: Classic's exact behaviour with Repeat on is not verified (we never show the ending, and show the intro only at the start, while repeating), the Identity Plate Editor's styled text (Inter SemiBold only), override colour leaves an image plate's colours alone, the Slideshow module's Titles panel (settings live in Settings ▸ Slideshow). Slideshow export (LRC-SS-EXPORT) includes them: a PDF page / JPEG each, and in video they hold and fade like slides | | LRC-SS-MUSIC | Slideshow music | P2 | ⬜ | | | -| LRC-SS-PLAYBACK | Slideshow playback | P2 | ⬜ | | | -| LRC-SS-EXPORT | Slideshow export | P2 | ⬜ | | | -| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | 🟡 | `cmd:dialog.contactSheet`, `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | paginated contact sheets; single-image/custom package layouts missing | -| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ⬜ | | | -| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:export.contactSheet`, `crates/ui-egui/src/panels/dialogs.rs` | A4/Letter, portrait/landscape, 1–8 columns and 1–10 rows; fixed margins and spacing | -| LRC-PRINT-GUIDES | Print guides | P2 | ⬜ | | | -| LRC-PRINT-CELLS | Picture-package cells | P2 | ⬜ | | | -| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:export.contactSheet`, `crates/engine/src/contact_sheet.rs` | optional filename captions; no page backgrounds/identity plates | -| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:export.contactSheet`, `cmd:app.contactSheet`, `crates/ui-egui/src/export_task.rs` | PDF export at fixed 150 dpi in sRGB, current edits, background progress/cancel and original protection; no native printer integration, ICC soft proof/print profiles or selectable resolution | -| LRC-PRINT-TEMPLATES | Print templates | P2 | ⬜ | | | +| LRC-SS-PLAYBACK | Slideshow playback | P2 | 🟡 | `cmd:slideshow.playback`, `cmd:view.slideshowOptions`, `cmd:slideshow.togglePause`, `cmd:slideshow.next`, `cmd:slideshow.previous`, `cmd:slideshow.end` | Playback settings (library preference, `slideshow.playback`; Settings ▸ Slideshow, View ▸ Slideshow Options…): slide duration 0.5–120 s, fade 0–10 s (≤ slide; 0 = cut) as a crossfade or through a colour, random order (seeded shuffle, every photo once per pass, a new order each pass), repeat (off: ends after the last slide), prepare previews in advance (the next 2 slides render off the UI thread; a transition never starts before its slide is rendered — the current one is held). Space / ←/→ (cut) / Esc and a control bar (previous, play/pause, next, n / N, end) that shows on pointer movement. Missing: the Slideshow module view and its "preview in the view" (phase 2 of the module plan), music and sync to music (LRC-SS-MUSIC), pan & zoom, manual-advance mode, choosing the screen to play on. Intro and ending screens play as items of the same timeline (LRC-SS-TITLES). Slides are composed with the layout, options and backdrop (LRC-SS-LAYOUT / OPTIONS / BACKDROP) and carry the overlays (LRC-SS-OVERLAYS) | +| LRC-SS-EXPORT | Slideshow export | P2 | 🟡 | `cmd:slideshow.export`, `cmd:slideshow.exportStatus`, `cmd:dialog.slideshowExport`, `cmd:app.slideshowExport`, `path:crates/engine/src/slideshow_export.rs`, `path:crates/codecs/src/video.rs`, `path:crates/ui-egui/src/slideshow_export.rs` | View ▸ Export Slideshow… (and Export PDF / JPEG / Video in Settings ▸ Slideshow): the photos in view as their composed slides (layout, overlays) between the intro and ending screens that are on, in view order. **PDF**: one page per slide at 1024 / 1920 / 3840 px (any 64–8192 by command; page = slide pixels in points, aspect from the aspect preview, "screen" = 16:9), JPEG quality, each page carrying the slide duration and a fade transition for presentation mode, "Automatically show full screen" (`/PageMode /FullScreen`). **JPEG**: `<name>-001.jpg`… in a new folder. **Video**: AV1 (rav1e, fast preset) in MP4 or WebM written by our own muxers, 720p / 1080p (64–2160 by command), 24 / 30 fps (25 / 60 by command); each slide held for its duration (one long sample: variable frame rate) and fades rendered as blended frames (crossfade or through the fade colour); at most 2 hours. Runs in the background with a row in the activity stack (frames or slides done, ✕ cancels); files are written as `.part` and renamed when complete, so a cancelled or failed export leaves nothing; a photo that can't be rendered is skipped with a warning. Missing: H.264 (Classic's codec; patents, no pure-Rust encoder), audio / music in the video (LRC-SS-MUSIC), random order and pan & zoom in exports, the PDF's per-page fades follow the viewer (Acrobat), export presets, Classic's export dialog sizes beyond these, the web build (no export) | +| LRC-PRINT-LAYOUTSTYLE | Print layout styles | P2 | ✅ | `cmd:dialog.print`, `cmd:print.set`, `cmd:print.get`, `cmd:print.exportPdf`, `cmd:export.contactSheet`, `crates/ui-egui/src/print_view.rs` | File → Print… (⌘P): Single Image, Contact Sheet, Picture Package and Custom Package, one page preview each; also `print.*` commands for the CLI, control channel and MCP; the older File → Contact Sheet PDF stays | +| LRC-PRINT-IMAGESETTINGS | Print image settings | P2 | ✅ | `cmd:print.set`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | Zoom to Fill Frame, Rotate to Fit, Photo Border (width, colour; package styles) and Inner Stroke (width in points, colour) in the dialog, drawn in the preview and in the PDF; pixel-tested | +| LRC-PRINT-LAYOUT | Print layout | P2 | 🟡 | `cmd:print.set`, `cmd:print.layout.autoLayout`, `cmd:print.layout.newPage`, `cmd:print.layout.clear`, `cmd:app.print`, `cmd:export.contactSheet`, `crates/engine/src/print/layout.rs` | A3/A4/A5/Letter/Legal/Tabloid/4x6/5x7/8x10/13x19 and custom paper, portrait/landscape, per-side margins, grid with column/row spacing and square cells, 72–720 ppi, in a Print dialog with a scaled page preview; no printer-driver Page Setup, no cell size / alignment presets | +| LRC-PRINT-GUIDES | Print guides | P2 | ✅ | `cmd:print.set`, `cmd:print.cell.move`, `crates/ui-egui/src/print_view.rs` | rulers in in/cm/mm/pt, snap off/cells/grid with a grid size, and Page Margins / Page Bleed (a fixed 0.125 in outline) / Page Grid / Image Cells / Dimensions guides drawn on the preview | +| LRC-PRINT-CELLS | Picture-package cells | P2 | ✅ | `cmd:print.cell.add`, `cmd:print.cell.move`, `cmd:print.cell.resize`, `cmd:print.cell.delete`, `cmd:print.cell.stack`, `cmd:print.cell.photo`, `cmd:print.cell.page`, `cmd:print.layout.autoLayout`, `crates/ui-egui/src/print_view.rs` | add cells at standard or custom sizes, drag and resize them on the page preview (snap to cells, grid or off), Send to Back / Backward / Forward / Bring to Front, a photo per cell (Custom Package), Auto Layout packing, New Page, Clear Layout; no Auto Layout for Custom Package | +| LRC-PRINT-PAGE | Print page options | P2 | 🟡 | `cmd:print.set`, `cmd:export.contactSheet`, `crates/engine/src/print/compose.rs`, `crates/ui-egui/src/print_view.rs` | page background colour and margins in the Print dialog; Page Options for Single Image and Contact Sheet (`print.set {page: {pageNumbers, pageInfo, cropMarks}}`, checkboxes in the Page section, kept in templates): the page number at the bottom right, the print settings line (sharpening, profile, resolution) at the bottom left, crop marks at each photo's corners, drawn in the preview and in the PDF (`print::compose::tests::crop_marks_sit_outside_each_photo_corner`); filename captions in the contact-sheet PDF; no identity plate, watermark, photo info or package Cut Guides | +| LRC-PRINT-JOB | Print job & colour management | P2 | 🟡 | `cmd:print.set`, `cmd:print.exportPdf`, `crates/ui-egui/src/print_view.rs`, `crates/engine/src/print_color.rs`, `crates/codecs/src/print.rs`, `crates/engine/src/pdf.rs`, `cmd:export.contactSheet` | the Print dialog's Print Job panel and `print.set {job}`: print resolution 72–720 ppi; colour management: Managed by Printer (sRGB / Adobe RGB, profile embedded) or a printer / paper ICC profile (RGB, CMYK or grey) converted here and named as the PDF's output intent; perceptual / relative colorimetric with black point compensation; Print Adjustment brightness / contrast; print sharpening Low / Standard / High for Matte / Glossy; 16-bit (or 8-bit) ICC-tagged lossless page images; a bad profile fails before rendering (`tests_print::export_pdf_with_a_printer_profile`). The contact sheet keeps its fixed 150 dpi sRGB JPEG PDF. Missing: printing to a printer (OS print dialog), JPEG file output, Draft Mode | +| LRC-PRINT-TEMPLATES | Print templates | P2 | 🟡 | `cmd:print.template.save`, `cmd:print.template.apply`, `cmd:print.template.list`, `cmd:print.template.delete`, `crates/ui-egui/src/print_view.rs` | save, apply and delete named templates (layout, image settings, page, job) at the top of the Print dialog, kept with the library; no built-in templates, no folders, no import / export of template files | | LRC-WEB-LAYOUT | Web gallery layouts | OOS | 🚫 | | | | LRC-WEB-SITEINFO | Web gallery site info | OOS | 🚫 | | | | LRC-WEB-COLOR | Web gallery colours | OOS | 🚫 | | | @@ -765,13 +772,13 @@ From `08-lightroom-classic-extras.md` (Classic-only features) and part 2 of `06- | LRC-WEB-IMAGEINFO | Web gallery image info | OOS | 🚫 | | | | LRC-WEB-OUTPUT | Web gallery output | OOS | 🚫 | | | | LRC-WEB-UPLOAD | Web gallery upload | OOS | 🚫 | | | -| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | | +| KEYC-PANELS | Classic panel keys (Tab, ⇧Tab, T, F5–F8, solo) | P2 | ⬜ | | solo itself (⌥-click a header, Solo Mode) is done: see LR-EDIT-SECTION-SOLO | | KEYC-MODULES | Classic module switching (⌘⌥1–7) | P2 | 🚫 | | no modules in LightCraft | | KEYC-VIEWS | Classic view keys (E, G, C, N, L, F, I, ⇧R, ⌘⌥0) | P2 | 🟡 | `cmd:view.photoGrid`, `cmd:view.zoom100` | G works; E opens Edit (not loupe); no compare/survey/lights-out/screen modes | | KEYC-SECONDWINDOW | Classic secondary-window keys | P2 | ⬜ | | | | KEYC-CATALOG | Classic photo/catalog keys (⇧⌘I, ⌘', ⌘R, F2, ⌫, ⇧⌘E…) | P2 | ✅ | `cmd:library.import`, `cmd:photo.delete`, `cmd:photo.virtualCopy`, `cmd:app.showInFinder`, `cmd:dialog.rename`, `cmd:photo.editInExternal` | ⇧⌘I add, ⌘' virtual copy, ⌘R show in Finder, F2 rename, ⌫ delete, ⇧⌘E external editor | -| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle` | Z toggles zoom; no compare, stacks, thumbnail-size keys | -| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), rating `[` / `]`, flag cycle and filter-bar keys | +| KEYC-COMPARE | Classic grid/compare keys (Z, Home/End, =/−, ⌘⇧D, S…) | P2 | 🟡 | `cmd:view.zoomToggle`, `cmd:view.thumbSizeUp`, `cmd:view.thumbSizeDown`, `crates/ui-egui/src/tests_zoom_keys.rs`, `cmd:library.select`, `crates/ui-egui/src/shortcuts.rs`, `crates/ui-egui/src/tests_keymap.rs` (`home_and_end_select_the_ends_of_the_grid`, `shift_arrows_extend_the_grid_selection`) | Z toggles zoom; `=` / `-` (and `+`) step the grid thumbnail size by 30 pt within the bottom-bar slider's 90–480 range in Photo Grid, Square Grid and People (nothing in Detail, Compare, Survey or Reference; a text field keeps the keys; ⌘= / ⌘− still zoom); Home / End make the first / last photo of the view the active, selected one and ⇧← / ⇧→ add the previous / next photo to the selection (the added photo becomes active; nothing is ever removed; at either end nothing happens), in Photo Grid and Square Grid only: they wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when the user gave the key to a command; no compare, stacks keys | +| KEYC-RATING | Classic rating/flag keys (1–5, ⇧1–5, 6–9, P, X, U, ⇧X, ⇧U, `[` `]`, \`, ⌘↑ ⌘↓) | P2 | 🟡 | `cmd:photo.rate`, `cmd:photo.pick`, `cmd:photo.flag`, `cmd:photo.cycleFlag`, `cmd:photo.increaseFlag`, `cmd:photo.decreaseFlag` | 0–5 / Shift+0–5 and 6–9 / Shift+6–9; P/X/U, Shift+X/U and grid Shift+P work; ` (backtick) cycles each selected photo's flag unflagged → pick → reject → unflagged and ⌘↑ / ⌘↓ (Ctrl on Windows/Linux) raise / lower it (reject < unflagged < pick, clamped); one undo step, Compare/Survey act on the active photo, Auto Advance follows; tests cover batch/undo, active Compare candidate and text focus. Missing: Shift+P outside grids (Presets), filter-bar keys | | KEYC-COLLECTIONS | Classic collection keys (⌘N, B…) | P2 | 🟡 | `cmd:dialog.newAlbum` | ⌘N new album; no quick collection | | KEYC-METADATA | Classic keyword/metadata keys (⌘K, ⌘S, ⌘⌥⇧C/V…) | P2 | 🟡 | `cmd:photo.saveMetadataToFile` | ⌘S saves metadata; no keyword sets, metadata copy/paste | | KEYC-DEVELOP | Classic develop keys (V, ⌘U, ⇧⌘U, R, Q, K, M, ⇧M, ⇧W, ⇧J, ⇧Q…) | P2 | 🟡 | `cmd:develop.treatment`, `cmd:develop.reset`, `cmd:crop.reset` | V, ⇧⌘R, ⌥⌘R, W, J, Y, ⇧Y, \ match; R/K/M/⇧M differ; no Classic keymap layer | @@ -791,32 +798,85 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | LR-VIEW-ZOOM: Visualize Spots, Point Color range and Visualize HDR render zoom windows; LRC-DEV-SOFTPROOF: output-space proofs on the GPU | +| 2026-10-14 | patch | LR-EXP-TYPE: opaque 8-bit PNG/TIFF/WebP use RGB; transparent exports retain alpha (#1670). Statuses and feature counts unchanged. | +| 2026-10-15 | minor | LRC-PRINT-PAGE: Page Options (Page Numbers, Page Info, Crop Marks) on Single Image and Contact Sheet pages, in the Print dialog, `print.set` and templates; still 🟡 (no identity plate, watermark, photo info or Cut Guides) | +| 2026-10-15 | minor | LR-IMP-FORMATS: Hasselblad 3FR and FFF show their uncompressed RGB preview (no JPEG in these files) in the grid and loupe until developed, and as the stand-in when a file fails to decode; status unchanged (🟡) | +| 2026-10-15 | minor | LR-IMP-FORMATS: a Hasselblad FFF develops with the camera's colour matrix from the original 3FR header it embeds (tag `0xC51B`) instead of the neutral matrix; status unchanged (🟡) | +| 2026-10-12 | minor | LR-EDIT-OPTICS-PROFILE: Sony ARW shading, CA and distortion tables decoded with settings and provenance (raw IFD, `SR2SubIFD` fallback); applied corrections unchanged (ILCE-7RM4A and ILCE-7RM6 distortion); status unchanged (🟡) | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (coverage.py 515 → 545 verified); sensor verification does not establish colour parity (#1277); status unchanged (🟡) | +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama's DNG takes its `AsShotNeutral` from the median of its frames' vendor white-balance multipliers (CR2, NEF, ARW…) instead of the first frame's tags, and frames now carry the multipliers (issue #1144); status unchanged (✅) | +| 2026-10-13 | minor | LR-MERGE-HDR: an HDR merge's DNG takes its `AsShotNeutral` from the median of its inputs' vendor white-balance multipliers instead of the reference frame's tags (issue #1144); status unchanged (🟡) | +| 2026-10-13 | minor | LR-VIEW-FOCUSPOINT (new, 🟡): the camera's focus point from Sony α1 / A7S III maker notes, drawn in Detail, Compare and Survey, the Info panel's Focus row, and Z zooming to it; photos already in the library read in the background, header only (#1480) | +| 2026-10-14 | patch | LR-MERGE-HDR: the merge no longer depends on the order of its frames, and pixels next to clipped photosites lose their weight with them (no magenta fringes along clipped windows); status unchanged (🟡) | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 17 uncompressed Olympus and Samsung Adobe models verified on one CC0 file each; status unchanged (🟡) | +| 2026-10-13 | minor | LR-EDIT-SECTION-SOLO: Solo Mode for Edit sections (Window ▸ Edit Sections, section-header menu, ⌥-click); KEYC-PANELS notes it | +| 2026-10-14 | minor | LR-MERGE-HDR ✅ → 🟡: tested on real 67 MP Sony brackets; moving clipped highlights (black-hole stars) and dark-sky ghosts fixed, merge memory cut ~25 %; AI Denoise on merged DNGs, JPEG response curves and per-frame memory remain (gap 29). LR-MERGE-HEADLESS, MENU-PHOTO-MERGE: HDR for Each Bracket Set (`merge.hdrBatch`) | +| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged | +| 2026-10-14 | patch | LR-META-XMP: automatic sidecar writes that fail (folder offline or read-only) wait and are written again once the files are reachable, with a notice (`cmd:library.retrySidecars`); status unchanged | +| 2026-10-13 | minor | LR-BEHAV-WINDOW (new, 🟡): the desktop app's window opens at the size it was left at, zoomed / maximized if it was; full screen and the place on the desktop are not remembered | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D600, D610 and D5000 NEFs drop the filler and noise columns on the right (a band), and the D600 and D610 measure their white level inside the image (#1627); status unchanged (🟡) | +| 2026-10-15 | patch | LR-VIEW-BEFOREAFTER: side by side and stacked panes share click-zoom animation for whole-frame images, window tiles and input; render requests keep the final target (#1821); status unchanged | +| 2026-10-14 | patch | LR-MASK-BRUSH: ⇧-click with the brush draws a straight stroke from where the last stroke ended, tapering when the brush changed in between (`cmd:mask.brushStroke` `connect`); status unchanged | | 2026-10-14 | minor | LR-VIEW-ZOOM 🟡 → ✅: soft proofing gets zoom windows (mask overlays already had them; row text corrected), 1:1 pixels tested equal to the full-size export, slider-drag latency at 1:1 measured on Linux | | 2026-10-13 | patch | LR-VIEW-COMPARE, LRC-LIB-COMPARE: ⌘/Ctrl- and Shift-clicks on the filmstrip in Compare change the selection as in the grid; the two photos shown stay selected and the active photo is unchanged (#187); statuses unchanged | | 2026-10-13 | minor | KEYC-SELECTONLYACTIVE: Edit → Select Only Active Photo (⌘⇧D) lands as a new row; status 🟡 (headless-tested, native macOS menu path unverified) | | 2026-10-13 | patch | LRC-LIB-CATALOG-IMPORT: a catalog whose tables are all still in its `-wal` file (main file schema format 0) opens instead of failing as an unsupported schema format; status unchanged | | 2026-10-14 | patch | LR-REM-HEAL, LR-REM-CLONE: zero-feather edges preserve exact-radius pixels; zero-opacity regression covers all spot modes and image corners. Statuses and feature counts unchanged. | | 2026-10-13 | patch | LR-EXP-DIM: an enlarged export stops at 512 megapixels and is refused when it needs more memory than the computer has, instead of aborting the app; status unchanged | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled ILCA-99M2 profile with its held-out measurement | +| 2026-10-12 | minor | LR-MASK-EFFECTS (new, ✅): local adjustment (effect / brush) presets: Effect menu, New Effect Preset dialog, Manage window, groups and favorites; imports Lightroom Classic local `.lrtemplate`, Camera Raw local `.xmp`, `.lceffect` and `.zip`. LR-IMP-PROFILES and LR-MASK-SLIDERS notes updated | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 21 Sony and Leica Adobe models verified on one CC0 file each; status unchanged (🟡) | +| 2026-10-13 | minor | KEY-HIDEPINS ⬜ → ✅: Tool Overlay (Auto / Always / Selected / Never, `view.toolOverlay`, ⇧H cycles; H stays Remove) for the Remove tool's spot pins and the mask pins (issue #224); LR-MASK-PINS loses its "no Auto mode" note, LR-REM-SPOT-EDIT notes the modes | +| 2026-10-13 | minor | LR-EDIT-DETAIL-NR, LR-EDIT-DETAIL-CNR: noise-reduction radii in the original's pixels, so previews at any size and smaller exports look like the full-size render scaled down (issue #960); status unchanged | +| 2026-10-13 | minor | KEYC-RATING: backtick cycles the flag (`photo.cycleFlag`), ⌘↑ / ⌘↓ raise / lower it (`photo.increaseFlag`, `photo.decreaseFlag`); status unchanged (🟡) | +| 2026-10-13 | patch | LR-MASK-LUMRANGE / LR-MASK-COLORRANGE: range drags keep the grabbed endpoint; colour samples use the render's enabled sections and HDR/SDR settings; regression coverage, status unchanged | +| 2026-10-13 | minor | LR-IMP-DEVICE: Settings → Import → Cameras and Cards can open the import review when a camera or card is connected; status unchanged (✅) | +| 2026-10-13 | minor | KEY-SLIDER-SCRUB: drag a slider's value sideways to scrub it, as in Lightroom (new row) | +| 2026-10-13 | minor | KEY-SLIDER-RESET: hovering a slider's name or thumb says a double-click resets it; a double-click on the thumb is one undo step, not two; a disabled slider's name doesn't reset it (issue #1436); status unchanged (✅) | | 2026-10-11 | minor | LR-MERGE-HDR: HDR and panorama merges of raws without colour matrices (CR2, NEF, ARW…) write the colour they open with as DNG tags (issue #965); status unchanged | | 2026-10-12 | minor | LR-META-XMP: Read Metadata from File no longer auto-writes the sidecar it has just read, which added `lc:settings` and hid another app's later `crs:` edits (#934); status unchanged (✅) | | 2026-10-12 | patch | LR-IMP-CAMERA-COVERAGE: CR2 white level from the active area, not the saturated masked rows above it (issue #1272); status unchanged (🟡) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Pentax PEFs without area tags lose their masked border lines (issue #1271); status unchanged (🟡) | | 2026-10-12 | minor | Camera-look deep shadows follow the JPEG toe (#1307); camera calibration remains partial | | 2026-10-11 | minor | LR-IMP-PREVIEW-READ: a preview reads the raw's header and its embedded JPEG instead of the whole file (a 56 MB RAF: 56.5 MB → 5.9 MB, ~10 s → 0.96 s over SMB, byte-identical pixels), and an unedited raw's grid thumbnail is that camera JPEG itself, so no sensor decode is queued behind it. A CR3's JPEG track and a TIFF raw whose preview tag sits past the prefix are read through `lightcraft_raw::sparse::preview_plan`, which names the ranges `embedded_preview` reads; the formats it cannot describe (CRW/MRW/X3F, NEF/ORF/PEF) keep the whole-file path | +| 2026-10-12 | minor | LR-BEHAV-ACCESS: canvas handles by keyboard with AccessKit names and values; Tab order test; status stays 🟡 | | 2026-10-12 | minor | LR-BEHAV-ACCESS: AccessKit tree audit test; text fields named, sliders expose range and Increment / Decrement; status stays 🟡 | | 2026-10-12 | minor | LR-PREF-DISPLAY: on Linux, previews follow the display profile the desktop assigns in colord | | 2026-10-12 | minor | LR-IMP-FORMATS: transparent areas are flattened onto white in linear light instead of rendering black (issue #1212); status unchanged (🟡) | | 2026-10-12 | minor | LRC-LIB-MISSING: folder and disk context menus locate a moved tree recursively without relinking missing photos outside it (issue #1194) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Canon PowerShot G9 as-shot white balance from WBInfo (issue #1273); status unchanged (🟡) | | 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: Olympus E-10 as-shot white balance and black level from the main maker note (issue #1273); status unchanged (🟡) | +| 2026-10-13 | minor | KEY-SLIDER-NUDGE: scrolling sideways over a slider adjusts it | | 2026-10-13 | minor | LR-IMP-CAMERA-COVERAGE: Nikon D90 NEFs drop their 42 filler columns (pink band on the right) and clip at one measured white level; status unchanged (🟡) | | 2026-10-13 | minor | LR-VER-HISTORY, LRC-DEV-HISTORY: History cursor (go to a step without adding one; Previous/Next History Step) | +| 2026-10-13 | minor | KEYC-COMPARE: Home / End select the first / last photo of the view and ⇧← / ⇧→ add the previous / next photo to the selection in Photo Grid and Square Grid (not in the loupe, the full-screen preview, a text field or a dialog); LRC-LIB-CATALOG-SWITCH: ⌘O runs Open Library…; LR-HDR-VISUALIZE: ⇧H toggles Visualize HDR. Statuses unchanged (KEYC-COMPARE stays 🟡: no thumbnail-size keys) | | 2026-10-12 | minor | LR-IMP-FORMATS: the Hasselblad Lunar (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data instead of opening as its preview; an uncompressed Leaf MOS imports as a preview-only raw instead of a greyscale image of its mosaic (#1275); status unchanged (🟡) | | 2026-10-12 | minor | LR-HDR-EXPORT, LR-EXP-HDR: HLG AVIF export (`hdrTransfer`); LR-VIEW-HDR-DISPLAY: tested SDR/HDR display decision | | 2026-10-12 | minor | LR-EXP-PSD, MENU-FILE-PHOTOSHOP: Photo ▸ Edit In ▸ Open Original in Other Editor (RAW, no copy) hands the original files (the raws themselves) to the other editor in one start; statuses unchanged | +| 2026-10-12 | minor | LRC-PRINT-JOB: the Print dialog's Print Job panel sets the profile (Managed by Printer or a printer / paper profile picked from disk), intent, black point compensation, print adjustment, print sharpening and depth | +| 2026-10-12 | minor | LRC-PRINT-JOB: print colour management (printer / paper ICC profiles or Managed by Printer, intent, black point compensation, print adjustment, print sharpening) and a 16-bit ICC-tagged PDF with an output intent; still 🟡 (no OS print dialog or JPEG output) | +| 2026-10-12 | minor | LRC-PRINT-GUIDES, LRC-PRINT-CELLS, LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS (✅), LRC-PRINT-LAYOUT, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES (🟡): File → Print… opens the Print dialog with a scaled page preview, rulers and guides, cells dragged and resized with snapping, image settings, page setup and templates, and prints to PDF in the background | +| 2026-10-12 | minor | LRC-PRINT-LAYOUTSTYLE, LRC-PRINT-IMAGESETTINGS, LRC-PRINT-LAYOUT, LRC-PRINT-CELLS, LRC-PRINT-PAGE, LRC-PRINT-TEMPLATES: the Print module is reachable as 18 `print.*` commands (settings, cells, Auto Layout, PDF, templates saved in prefs.json) from the CLI, control channel and MCP; 🟡 until the Print UI lands, LRC-PRINT-GUIDES stays ⬜ | +| 2026-10-12 | minor | LRC-PRINT-IMAGESETTINGS: `print/compose.rs` composes pages (Zoom to Fill, Rotate to Fit, Photo Border, Inner Stroke, background) from the layout model into 16-bit Rec. 2020 `PageRaster`s, with an interim sRGB PDF writer in `print/output.rs`; rows stay until commands and UI reach them | +| 2026-10-12 | minor | LRC-PRINT-LAYOUT: the Print layout model (paper, margins, grid, package cells, Auto Layout packer, snapping) lands in `print/layout.rs`; no row changes status until commands and UI follow | +| 2026-10-12 | minor | LR-MASK-BRUSH: pen pressure scales brush size and flow | | 2026-10-13 | patch | LR-BEHAV-AUTOSAVE: a damaged snapshot that still parses is repaired on open (ids, stacks, folder loops, id counters) instead of panicking or hanging; status unchanged | | 2026-10-13 | minor | LR-EDIT-OPTICS-PROFILE: Nikon Z 6II / Z 8 / Z f NEFs use the camera's in-file distortion correction (NikonNEFInfo); status unchanged (🟡) | +| 2026-10-12 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: 22 more CR3 bodies verified on one CC0 file each (all 110 CC0 CR3s decode); EOS R6 Mark III `ColorData` 66 white balance; status unchanged (🟡: CRX variants without public samples remain refused) | | 2026-10-11 | minor | Model setup folder action, opener error and retry (LR-MASK-OBJECTS). | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: the camera look follows the lens warp of lens-corrected camera JPEGs; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 6II camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon Z 8 camera profile; status unchanged (🟡) | +| 2026-10-13 | minor | LR-PROF-CAMERACOLOR: bundled Nikon D5300 camera profile; status unchanged (🟡) | +| 2026-10-12 | minor | LR-VIEW-ZOOM: slider-drag drafts are timed with the frame that shows them, drop to 0.6 mid-drag when far over budget, stay 0.6 under a zoom window; workers convert texture pixels (issue #330); status unchanged | +| 2026-10-11 | minor | LR-VIEW-ZOOM: a slider drag keeps the loupe's full resolution where the GPU draws the view and keeps up (0.6 drafts on the CPU, with soft proofing or the HDR range, or after over-budget drags; issue #330); status unchanged | +| 2026-10-11 | minor | LR-EXP-LOCATION: Export to "Same folder as original photo" (`destinationMode`), issue #956; status unchanged (✅) | +| 2026-10-12 | minor | LR-IMP-CAMERA-COVERAGE: 117 Nikon and Fujifilm Adobe models verified on one CC0 file each; COOLPIX 5700 / 8400 / 8800 found decoding wrongly; status unchanged (🟡) | +| 2026-10-13 | patch | LR-EXP-QUALITY: JPEG exports use Huffman tables made for each image (same pixels, 12–14 % smaller files, issue #1373); status unchanged | +| 2026-10-13 | patch | LR-BEHAV-GPU: a GPU lost while LightCraft runs (driver reset) is reported as a stop, not a failed start, and the next start renders on the CPU and says why (issue #1337); status unchanged | +| 2026-10-13 | minor | LR-META-INFO: Nikon DSLR raws show their lens from the maker note; status unchanged (✅) | +| 2026-10-13 | patch | LR-IMP-LOCAL: browsing a Local folder again forgets the untouched records of files deleted or moved by another app (issue #1392); status unchanged | +| 2026-10-13 | minor | LR-MERGE-PANO: a panorama keeps its photos' resolution (up to 512 MP / 65,000 px) instead of a fixed 40 MP, held to what fits in memory (issue #1455); status unchanged | | 2026-10-11 | minor | KEY-SLIDER-TYPE: honor immediate Return/Escape and keep typed keys in the value editor; add regression references | | 2026-10-11 | minor | LR-PROF-CAMERACOLOR: raws with a black-and-white camera JPEG take its tone curve (issue #577) | | 2026-10-11 | minor | LR-VIEW-ZOOM and LR-VIEW-COMPARE: Compare and Reference are sharp at 1:1 (zoom windows per pane, two originals kept decoded while they are open); statuses unchanged | @@ -824,4 +884,11 @@ Latin stays upright. Advanced Japanese composition remains open. Regression cove | 2026-10-11 | minor | LR-IMP-FORMATS and LR-IMP-CAMERA-COVERAGE: EOS R5 Mark II CR3 decodes from sensor data (57% of its C-RAW files opened as a preview: QP-map escape); status unchanged (🟡) | | 2026-10-10 | patch | LR-BEHAV-UNDO: file/XMP repair retries and merged rename Undo/Redo evidence; status and summary counts unchanged. | | 2026-10-10 | minor | Renamed from `parity.md` (progress-docs standard); Top gaps moved to gaps.md (its duplicated items 1–2 from a bad merge resolved there), shortcut notes moved to ui-parity.md. Rows unchanged; a recount of the rows gives 81.1% of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%), so the generated summary above is due a `cargo xtask parity --write` | +| 2026-10-14 | minor | LR-IMP-CAMERA-COVERAGE: 97 Canon CR2, Pentax and Ricoh Adobe models verified on one CC0 file each; status unchanged (🟡) | +| 2026-10-13 | minor | LR-LIB-SMARTALBUM: `cmd:library.holdView` and `cmd:library.refresh`: a view can hold the photos it shows until it is refreshed; status unchanged (✅) | | 2026-09-30 – 2026-10-11 | — | Rows added and updated with each feature (see `git log -- docs/parity.md docs/parity-checklist.md`) | +| 2026-10-13 | minor | LR-IMP-PROFILES: presets that name a profile apply the imported profile of that name, and Lightroom's basic profiles map to ours (issue #1001); status unchanged (✅) | +| 2026-10-12 | minor | LRC-LIB-CATALOG-IMPORT: lasting destination/result, offline records and virtual copies count as added; redundant missing-file probes and staged photo copies removed. Status and summary counts unchanged. | +| 2026-10-13 | minor | LRC-LIB-CATALOG-IMPORT review: offline Local promotion and result dismissal across library switches covered; redundant operation/settings copies removed. Status and summary counts unchanged. | +| 2026-10-14 | patch | LRC-LIB-CATALOG-IMPORT: reconciled with upstream phased progress, cancellation kinds, WAL-only import, translations and current progress tracking. Status and summary counts unchanged. | +| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` (also `+`) increase / decrease the grid thumbnail size (`view.thumbSizeUp` / `view.thumbSizeDown`, View menu), grids only; status unchanged (🟡) | diff --git a/docs/raw-parity.md b/docs/raw-parity.md index 811353a70..08e70732d 100644 --- a/docs/raw-parity.md +++ b/docs/raw-parity.md @@ -1,12 +1,13 @@ # RAW parity: cameras, formats, demosaic, colour and lens profiles -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (Nikon Z 6II / Z 8 / Z f in-file distortion correction; Leica Q3 verified by a recorded check of 38 private DNGs; EOS R5 Mark II decoded and verified: CRX QP-map escape, odd-height planes) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-18 · **Change:** patch (X-Trans CMOS 4 bodies take the X-T3's spectral matrices) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 (Camera Raw 18.7) Raw processing is the deepest single dimension of Lightroom parity: a photographer whose camera does not decode, or decodes with the wrong colour, cannot switch. This document measures it model by model. The numbers feed [target-app-parity.md](target-app-parity.md); individual shortfalls are ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), -[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), [nikon-lens-corrections.md](nikon-lens-corrections.md), +[raf-compression.md](raf-compression.md), [sony-lens-corrections.md](sony-lens-corrections.md), +[sony-ilce7rm6-lens-corrections.md](sony-ilce7rm6-lens-corrections.md), [nikon-lens-corrections.md](nikon-lens-corrections.md), [camera-preview-colour.md](camera-preview-colour.md), [denoise.md](denoise.md). ## Headline @@ -14,18 +15,16 @@ ranked in [gaps.md](gaps.md). Details per format: [cr3.md](cr3.md), | Measure | Value | Kind | |---|---:|---| | Adobe-listed camera models | **1,446** | measured: [raw/adobe-cameras.tsv](raw/adobe-cameras.tsv) | -| Decoded from sensor data and **verified** on a real file | **236** (16%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | -| Decoder path should handle it, **unverified** | **941** (65%) | measured from the format rules (631 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **233** (16%) | measured | -| Decoder path should handle it, **unverified** | **942** (65%) | measured from the format rules (632 of them DNG, mostly phones) | -| **Preview-only** (embedded JPEG) | **232** (16%) | measured | -| **Unsupported** (not imported, refused or decodes wrongly) | **36** (2%) | measured | -| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 83 / 639 / 24 / 8 of 754 | measured | -| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~85%** | estimated: measured classes × written usage weights × per-format confidence | -| …of which on a body we have verified | **~42%** | estimated, same weights | -| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **4 bundled profiles** + per-file fits + 52 spectral matrices | measured (bundle listing, Adobe list, `assets/camera-profiles`) | +| Decoded from sensor data and **verified** on a real file | **545** (38%) | measured: [raw/evidence.tsv](raw/evidence.tsv) | +| Decoder path should handle it, **unverified** | **651** (45%) | measured from the format rules (631 of them DNG, mostly phones) | +| **Preview-only** (embedded JPEG) | **212** (15%) | measured | +| **Unsupported** (not imported, refused or decodes wrongly) | **38** (3%) | measured | +| Models added since 2017 (Camera Raw 10+): verified / unverified / preview / unsupported | 182 / 542 / 22 / 8 of 754 | measured | +| **Share of photographers whose camera fully works** (decodes from sensor data), use-weighted | **~94%** | estimated: measured classes × written usage weights × per-format confidence | +| …of which on a body we have verified | **~85%** | estimated, same weights | +| Camera colour: Adobe profiles vs ours | 1,500 Adobe Standard profiles, 481 models with Camera Matching profiles vs **5 bundled profiles** + per-file fits + 52 spectral matrices (plus same-sensor aliases) | measured (bundle listing, Adobe list, `assets/camera-profiles`) | | Lens profiles: Adobe vs ours | **3,635 vs 0** (in-file corrections only) | measured | -| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~56%** (range 50–60%) | estimated, see [Scores](#scores) | +| **RAW readiness** (decoding 60%, colour 25%, lens 15%) | **~60%** (range 54–64%) | estimated, see [Scores](#scores) | Re-measure with `python3 docs/raw/coverage.py` (standard library only): it classifies every Adobe model from the format rules in the script (which mirror `crates/raw/src`) plus the per-model evidence, @@ -43,9 +42,10 @@ committed table is stale. `lightcraft_engine::import::EXTENSIONS`, and `KNOWN_UNSUPPORTED` in `crates/raw/tests/corpus.rs`. Doc claims were not used. - **Verified** means a real file from that body decoded from sensor data in: the corpus test over the - `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 101 CC0 files; bodies whose sample is in + `RAW_SAMPLES` manifest (`xtask/src/main.rs`, 105 CC0 files; bodies whose sample is in `KNOWN_UNSUPPORTED` excluded), `crates/raw/tests/cr3_corpus.rs` (3 bodies exact against an external decoder; - the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files), the RAF reference + the EOS R5 Mark II pinned, with a QP-map check of 33,665 private files; 22 more bodies pinned on one CC0 + file each and checked against the camera JPEG, [cr3.md](cr3.md#further-bodies-one-cc0-file-each)), the RAF reference check ([raf-compression.md](raf-compression.md)), and the raw.pixls.us bulk checks recorded in issue #535 (all 100 Sony bodies there; 87 decode) and PR #215 (every Panasonic / Leica RW2 / RWL / RAW body there, 178 files from 118 bodies), and a recorded check of 38 private Leica Q3 DNGs (2026-10-11: every file @@ -53,6 +53,17 @@ committed table is stale. at 400 px; the files are not committed). Bulk-check body lists were matched to Adobe names through the raw.pixls.us folder names. Checks without a body list (≈ 60 NEFs, 35 CR2 bodies for the CFA layout) are not counted as verified. +- **2026-10-12 public sweep:** 30 further models on 40 CC0 files, independently downloaded and + decoded on main `25ef65c8`; [raw/coverage-sweep.tsv](raw/coverage-sweep.tsv) records the URLs, + SHA-256, dimensions, FNV-1a sensor hashes and render/reference luminance correlations (0.6801 + to 0.9998). The hashes pin LightCraft's own output. The regression test is + `crates/raw/tests/coverage_sweep_corpus.rs`; downloads are in `RAW_SAMPLES`. Ten references are + embedded TIFF images; the rest are JPEGs. FFF TIFF previews are not display-ready and check + image structure only. Eleven candidate models were excluded for identity, provenance or + missing/mismatched reference images. D6 / Z 6 / Z 7 verification covers compressed NEF only; + uncompressed remains preview-only (#671), as do Z 8 HE / HE* files. Own-matrix phone, drone + and other DNGs can decode correctly while rendering darker than their camera JPEG (#1277). + Verified means sensor data decoded, not colour parity. - **Usage weights** ([raw/usage-weights.tsv](raw/usage-weights.tsv)): maker buckets from 2025 market data (Nikkei production: Canon ~36%, Sony ~29%, Nikon ~14%, Fujifilm ~12% of mirrorless; BCN retail: Sony 29.9%, Canon 27.4%, Nikon 15.1%), adjusted for the DSLR installed base and for the makers named @@ -61,51 +72,51 @@ committed table is stale. ~8 years (listed in the file), 20% to the bucket's other Adobe models. - **Per-format confidence** for unverified bodies (the chance their files decode correctly): DNG, CR2, RAF, RW2 0.95; NEF, ARW, PEF 0.9; ORF, SRW, SR2 0.8; **CR3 0.6** (the CRX decoder refuses several - coding variants and only four bodies are verified; maintainers report the R7 working). + coding variants; 26 bodies are verified, every CC0 CR3 on raw.pixls.us decodes). ## By maker (measured) | Maker | Adobe models | verified | unverified | preview | unsupported | Main reason for preview / unsupported | |---|---:|---:|---:|---:|---:|---| -| Sony | 149 | 86 | 51 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 and DSC-F828 refused (regression #702); SRF not imported | -| Canon | 134 | 12 | 99 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | -| Samsung | 128 | 1 | 118 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | -| Apple | 113 | 1 | 112 | 0 | 0 | — (DNG / ProRAW) | +| Sony | 149 | 94 | 43 | 9 | 3 | DSLR-A200…A390 and A850 12-bit compressed ARW (preview, #535); A100 preview-only and DSC-F828 refused (#702 fixed the regression; their raws aren't decoded); SRF not imported | +| Canon | 134 | 95 | 16 | 21 | 2 | CRW (2000–2004 bodies) preview; EOS-1D / 1Ds TIFF raws not imported | +| Samsung | 128 | 10 | 109 | 9 | 0 | compressed SRW (NX1, NX30, NX300, NX500, NX2000, NX3000, NX3300, NX mini, Galaxy NX) | +| Apple | 113 | 4 | 109 | 0 | 0 | — (DNG / ProRAW) | | Panasonic | 104 | 93 | 11 | 0 | 0 | — | -| Nikon | 99 | 5 | 94 | 0 | 0 | — (HE / HE★ is a per-shot setting, preview-only) | -| Fujifilm | 97 | 16 | 55 | 26 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | -| Google | 88 | 1 | 87 | 0 | 0 | — | -| Olympus | 71 | 3 | 9 | 59 | 0 | **compressed ORF** | -| Leica | 61 | 14 | 47 | 0 | 0 | — | +| Nikon | 99 | 77 | 19 | 0 | 3 | COOLPIX 5700 / 8400 / 8800 decode wrongly (two stacked half-height fields, CMYG read as RGB); HE / HE★ is a per-shot setting, preview-only | +| Fujifilm | 97 | 65 | 9 | 23 | 0 | pre-X FinePix RAF without a raw IFD (estimated split) | +| Google | 88 | 5 | 83 | 0 | 0 | — | +| Olympus | 71 | 17 | 3 | 51 | 0 | **compressed ORF** | +| Leica | 61 | 27 | 34 | 0 | 0 | — | | Xiaomi, Oppo, LG, Huawei, OnePlus, Motorola, Nokia | 139 | 0 | 139 | 0 | 0 | — (DNG) | -| Pentax / Ricoh | 60 | 4 | 56 | 0 | 0 | — | -| Hasselblad | 34 | 1 | 2 | 31 | 0 | Hasselblad-compressed 3FR / FFF | -| Phase One / Leaf / Mamiya | 59 | 0 | 0 | 43 | 16 | IIQ, MOS preview; TIFF raws and MEF not imported | +| Pentax / Ricoh | 60 | 40 | 20 | 0 | 0 | — | +| Hasselblad | 34 | 7 | 2 | 25 | 0 | Hasselblad-compressed 3FR / FFF | +| Phase One / Leaf / Mamiya | 59 | 1 | 0 | 43 | 15 | IIQ, MOS preview; TIFF raws not imported (MEF decodes) | | Casio | 26 | 0 | 26 | 0 | 0 | — (DNG) | -| DJI | 21 | 0 | 21 | 0 | 0 | — (DNG) | +| DJI | 21 | 4 | 17 | 0 | 0 | DNG | | OM Digital Solutions | 7 | 0 | 0 | 7 | 0 | **compressed ORF** | -| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 0 | 14 | 27 | 15 | DCR, GPR not imported; KDC, MRW, ERF, X3F preview | -| **All** | **1,446** | **236 (16%)** | **941 (65%)** | **233 (16%)** | **36 (2%)** | | +| Kodak, Konica Minolta, Epson, Sigma, GoPro, Contax, others | 56 | 6 | 11 | 24 | 15 | DCR, GPR not imported; KDC, MRW, X3F preview (Epson ERF decodes) | +| **All** | **1,446** | **545 (38%)** | **651 (45%)** | **212 (15%)** | **38 (3%)** | | ## By use (estimated) | Bucket | Weight | Camera fully works | On a verified body | Why | |---|---:|---:|---:|---| -| Canon | 33% | 73% | 19% | 18 of the 22 popular bodies write CR3; 4 of them verified (M50, R8, R100, R5 Mark II), the rest at 0.6 | -| Sony | 27% | 98% | 91% | bulk-checked | -| Nikon | 16% | 91% | 12% | decoders cover every NEF coding but HE; few bodies verified | -| Fujifilm | 10% | 91% | 45% | | +| Canon | 33% | 95% | 93% | 18 of the 22 popular bodies write CR3; 26 CR3 bodies verified (raw.pixls.us has a CC0 file for each), the rest at 0.6 | +| Sony | 27% | 98% | 92% | bulk-checked | +| Nikon | 16% | 98% | 89% | decoders cover every NEF coding but HE; 77 bodies verified | +| Fujifilm | 10% | 94% | 92% | | | Panasonic | 4% | 100% | 98% | bulk-checked | -| OM / Olympus | 3% | 3% | 1% | compressed ORF | -| Phones | 3% | 93% | 1% | DNG | -| Pentax / Ricoh | 1.5% | 94% | 14% | | -| Leica | 1% | 96% | 21% | the Q3 verified | -| DJI | 1% | 95% | 0% | | -| Medium format and others | 0.5% | 23% | 1% | IIQ, 3FR, X3F | -| **Weighted** | 100% | **85%** | **42%** | | +| OM / Olympus | 3% | 6% | 5% | compressed ORF | +| Phones | 3% | 93% | 4% | DNG | +| Pentax / Ricoh | 1.5% | 99% | 80% | | +| Leica | 1% | 99% | 72% | the Q3 verified, 13 more bodies pinned on CC0 files | +| DJI | 1% | 96% | 19% | | +| Medium format and others | 0.5% | 29% | 8% | IIQ, 3FR, X3F | +| **Weighted** | 100% | **94%** | **85%** | | -About 5 in 6 raw photographers' cameras decode, but only about 2 in 5 use a body we have actually -checked, and Canon, the largest bucket, is the weakest of the big four. +About 19 in 20 raw photographers' cameras decode, and about 5 in 6 use a body we have actually +checked (the per-model CC0 sweeps of October 2026); compressed ORF (OM / Olympus) is the largest gap left. ## Variant matrix (depth per format) @@ -113,6 +124,8 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | Format | Variant | Status | Evidence | |---|---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (`vendor/mef.rs`): ZD verified; 241 verified, 35 unsupported | +| 2026-10-12 | minor | Epson ERF decoded (`vendor/erf.rs`): R-D1, R-D1s, R-D1x verified; 240 verified, 229 preview (merged after the Leica Q3 and Hasselblad Lunar; duplicated summary rows removed) | | CR2 | lossless JPEG slices | ✅ | 8 corpus bodies; CFA phase from `CR2CFAPattern` (#85, 35 bodies); masked-column black level; white level from the active area only (#1272: masked rows at 16383 hid the image plateau on the EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1), clipped highlights at 81–98%) | | CR2 | sRAW / mRAW (YCbCr) | ✅ | `vendor/cr2/sraw.rs`, 31 samples from 16 models | | CR3 | CRX lossless, version 0x100 | ✅ | M50, R100, R8 exact against a reference; R5 Mark II (odd-height planes) pinned | @@ -122,7 +135,7 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | CR3 | HEIF / HDR PQ | ⬜ | HEVC tracks recognised, not decoded | | CR3 | dual-pixel raw | ⬜ | never substituted | | CRW | all | ⬜ | preview | -| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4 and D90 image area and white level measured against the camera JPEG | +| NEF | uncompressed, lossless, lossy type 1 / 2, lossy after split | ✅ | D5100, D7000, D7500, Z f corpus; ~60 NEFs by hand; #633; D4, D90, D5000, D600 and D610 image area measured against the camera JPEG (white level too for the D4, D90 and D5000) | | NEF | packed 14-bit stored under a compressed tag (Z 6II uncompressed) | ⬜ | #769; fix in PR #671 (not merged) | | NEF | High Efficiency / HE★ (JPEG XS) | ⬜ | named, preview (#193); needs a JPEG XS decoder | | ARW | uncompressed, cRAW (ARW2), lossless LJ92 (L / M / S) | ✅ | 86 bodies (#535); M/S black level 1024; the Hasselblad Lunar, a NEX-7 under Make HASSELBLAD, routed by Sony's raw coding (#1275) | @@ -144,12 +157,14 @@ checked, and Canon, the largest bucket, is the weakest of the big four. | DNG | uncompressed, LJ92, lossy JPEG / Smart Preview, Deflate incl. float, LinearRaw, tiles / strips, row / column interleave | ✅ | 6 corpus DNGs; 4 ILCE-6400 JXL DNGs; 38 Leica Q3 DNGs (lossless JPEG, 14-bit, private check) | | DNG | JPEG XL (DNG 1.7, lossless and lossy) | ✅ | synthetic bit-exact; real lossless and lossy files | | DNG | Apple ProRAW (incl. semantic mattes) | ✅ | iPhone 12 Pro corpus; ProfileGainTableMap read but off (ΔE00 19 against Lightroom with it on) | -| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL only; white-level pile-up fix #548 | +| DNG | phone DNGs (Pixel, Galaxy, Xiaomi…) | 🟡 | Pixel 2 XL corpus; 13 further Apple, Google and Samsung models in the pinned sweep; white-level pile-up fix #548 | | DNG | GoPro GPR (VC-5) | ⬜ | refused | -| 3FR / FFF | DNG-like layouts | 🟡 | decoded when uncompressed or LJ92 | +| 3FR / FFF | DNG-like layouts | 🟡 | six Hasselblad models verified on nine files; read by `vendor::hasselblad`: an FFF has no colour matrix of its own and now takes the camera's from the original 3FR header it embeds in tag `0xC51B` (it used to develop with the neutral matrix); RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; the embedded preview is the uncompressed 8-bit RGB image (no JPEG in these files), handed on as a baseline TIFF | | 3FR / FFF | Hasselblad compression | ⬜ | preview | -| IIQ, MOS, MRW, ERF, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | -| DCR, MEF, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | +| ERF | 12-bit in 16-byte blocks (R-D1, R-D1s, R-D1x) | ✅ | 3 CC0 corpus files; crop registered and WB measured against the embedded preview (no WB tag found); `vendor/erf.rs` | +| IIQ, MOS, MRW, KDC, X3F | all | ⬜ | preview; an uncompressed MOS, whose IFD0 is the mosaic itself with no Make tag, is recognised by Leaf's data block `0x8606` (#1275) | +| MEF | uncompressed 12-bit (Mamiya ZD) | ✅ | 1 CC0 corpus file; crop and WB from its uncompressed RGB preview (no JPEG, no WB tag); `vendor/mef.rs` | +| DCR, MFW, DXO, GPR, TIFF raws | all | ⬜ | not imported | ### Compressed ORF: a clean-room starting point @@ -178,7 +193,7 @@ sample files. A pointer for contributors, not legal advice. | Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed); CR2 white level taken from saturated masked rows (#1272, fixed) | | Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Black and white levels | per model, calibrated | read from each file (masked columns, maker notes, SR2SubIFD, CMP1) | 🟡 per-body bugs keep surfacing: A450–A700 SR2 black level and M/S ARW pedestal (#535, fixed); 5D Mark IV CR2 renders dark (#455, open); white-level pile-up in phone DNGs (#548, fixed) | -| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | +| Crop masks and aspect | ✅ | DefaultCrop, Sony recorded size, Canon AspectInfo, Panasonic in-camera crops, Nikon CropArea, measured Nikon bodies without it (D4, D90, D5000, D600, D610) | 🟡 7RM3 / 7RM4 crop origin 8–32 px off (cosmetic) | | Lens metadata | ✅ read for profile selection | EXIF lens fields read; used only for the in-file corrections below | 🟡 | | Highlight reconstruction | ✅ | neutral clip, chromaticity diffusion (`highlight.rs`) | 🟡 tuned by eye | | AI denoise | ✅ Bayer and X-Trans, model bundled | Bayer only, user-installed weights, pure-Rust CPU / GPU | 🟡 no X-Trans, no shippable weights | @@ -190,7 +205,7 @@ sample files. A pointer for contributors, not legal advice. | Item | Adobe | LightCraft | |---|---|---| -| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**4 bundled**: ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data); else a neutral matrix | +| Default profile per model | Adobe Standard for every model (1,500 profile names in the bundle) | the DNG's own matrices; else a pooled profile (**5 bundled**: ILCA-99M2, ILCE-7CR, ILCE-7M4, X-H2S, X-T4); else a guarded per-file fit to the camera's embedded JPEG; else spectral matrices (**52 models**, rawtoaces-data, and the bodies sharing their sensors: 6 X-Trans CMOS 4 Fujifilm bodies take the X-T3's); else a neutral matrix | | Camera Matching looks | 481 models (plus phone-maker looks) | none (the per-file JPEG fit imitates the camera's own look) | | Creative profiles | Adobe Color, Portrait, Landscape, Vivid, Monochrome… | our own looks (Color, Neutral, Vivid, Landscape, Portrait, Monochrome) | | DCP / dual-illuminant model | ✅ | dual-illuminant DNG colour (ColorMatrix / ForwardMatrix 1-2, CameraCalibration, AnalogBalance), HueSatMap, LookTable, ProfileToneCurve; external `.dcp` never loaded (clean-room) | @@ -219,7 +234,7 @@ essentials user (the default look is what they keep). | Item | Adobe | LightCraft | |---|---|---| | Profile database | 3,635 profile files, 56 makers, auto-selected | **0** (Adobe LCPs never used; no database of our own) | -| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | +| In-file corrections | ✅ | DNG opcodes (WarpRectilinear, FixVignetteRadial, GainMap…) applied; Panasonic / Leica RW2 distortion (79 photos, 13 bodies, 11 lenses); Sony tag 0x7037 distortion for the ILCE-7RM4A (2 lenses) and the ILCE-7RM6 (3 lenses, 54 photos; `sony-ilce7rm6-lens-corrections.md`); Nikon NikonNEFInfo distortion (tag `0xc7d5`, about the sensor-shifted optical centre) for the Z 6II and Z f: F-mount lenses on the FTZ within 0.2 px of the camera JPEG, Z lenses within 0.5–1.5 px except the 24-70's wide end (7 px median; `docs/nikon-lens-corrections.md`) | | Vignetting / lateral CA from maker notes | ✅ | ⬜ | | Manual distortion, vignetting, CA, defringe | ✅ | ✅ | @@ -230,10 +245,10 @@ drone DNG users (~9% of photographers by the weights above); Adobe reaches nearl | Part | Weight | Score | Basis | |---|---:|---:|---| -| Decoding | 60% | **77%** | use-weighted 85% × 0.9 (76.5%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | +| Decoding | 60% | **83%** | use-weighted 92% × 0.9 (82.8%) for per-body decode bugs still surfacing (levels, crops, WB: #535, #455, #461, #702) | | Colour | 25% | **35%** | see [Colour](#colour) | | Lens profiles | 15% | **10%** | see [Lens profiles](#lens-profiles) | -| **RAW readiness** | 100% | **~56%** (range 50–60%) | 45.9 + 8.75 + 1.5; demosaic and highlight quality unmeasured | +| **RAW readiness** | 100% | **~60%** (range 54–64%) | 49.7 + 8.75 + 1.5; demosaic and highlight quality unmeasured | The previous estimate (earlier on 2026-10-10) was ~50%, with decoding at ~78% weighted by maker from the format list alone. Measured model by model, decoding comes out about the same; the change is @@ -249,15 +264,15 @@ works, from [raw/usage-weights.tsv](raw/usage-weights.tsv). | # | Camera / variant | Gain (points of photographers) | Hours | Gain per hour | Needs | |---:|---|---:|---:|---:|---| | 1 | Compressed ORF (every Olympus / OM body since ~2008) | +2.6 | 8–15 | ~0.20 | CC0 samples (have 2) | -| 2 | CR3: remaining CRX variants, then verify the other 14 popular CR3 bodies | +6.2 | 25–45 | ~0.19 | samples per body (raw.pixls.us has most) | -| 3 | Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks) | +2.0 | 8–15 | ~0.17 | public samples | +| 2 | CR3: remaining CRX variants (no public file uses them) | +0.3 | 10–25 | ~0.02 | files that open as a preview, from users | +| 3 | ~~Verify popular Nikon, Fujifilm, Canon CR2 and Pentax bodies (one CC0 file each, fix what breaks)~~ done (#1045, #1081) | (+2.0, taken) | — | — | public samples | | 4 | Nikon packed 14-bit under a compressed tag (Z 6II uncompressed, #769) | +0.2 | 1–2 | ~0.13 | PR #671 exists | | 5 | Sony DSLR-A200…A390 / A850 12-bit compressed ARW | +0.4 | 3–6 | ~0.08 | samples on raw.pixls.us | | 6 | Pre-X FinePix RAF | +0.6 | 4–8 | ~0.08 | samples | | 7 | Sony A100 and DSC-F828 regression (#702) | +0.1 | 1–2 | ~0.07 | samples on raw.pixls.us | | 8 | Canon CRW (2000–2004 bodies) | +1.2 by the uniform tail weight, far less by real use | 6–12 | ≤ 0.1 | samples | | 9 | Nikon HE / HE★ NEF (Z 8, Z 9, Z 6III, Z f, Z50II, Z5II set to HE) | +1.1 | 20–40 | ~0.04 | a pure-Rust JPEG XS decoder | -| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, ERF, DCR, MEF, GPR | +0.6 together | 45–85 | ~0.01 | samples; low use | +| 10 | Compressed SRW, Hasselblad 3FR, IIQ, X3F, MRW, KDC, DCR, GPR (ERF, MEF done) | +0.6 together | 45–85 | ~0.01 | samples; low use | Beyond decoding: @@ -272,15 +287,30 @@ Beyond decoding: | Date | Change | Summary | |---|---|---| +| 2026-10-18 | patch | X-T30, X-T30 II, X-Pro3, X100V, X-S10 and X-E4 take the X-T3's spectral matrices (same 26.1 MP X-Trans CMOS 4 sensor): an X-T30 raw with a black-and-white (Acros) camera JPEG no longer opens in the muted neutral fallback | +| 2026-10-15 | minor | Hasselblad 3FR / FFF embedded previews: the uncompressed 8-bit RGB image, handed on as a baseline TIFF (checked on the CC0 H4D-40 FFF, 1217 × 913, and CFV 3FR, 218 × 218, upright through the file's orientation); counts unchanged | +| 2026-10-15 | minor | Hasselblad 3FR / FFF get their own reader (`crates/raw/src/vendor/hasselblad.rs`): an FFF takes its colour matrix from the original 3FR header in tag `0xC51B` instead of developing with the neutral matrix; RGGB checked against the camera preview on the CC0 H4D-40 FFF and CFV 3FR; counts unchanged | +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277) | +| 2026-10-13 | minor | Bundled camera profiles 4 → 5 (ILCA-99M2) | +| 2026-10-14 | minor | 21 Sony and Leica models verified (one CC0 raw.pixls.us file each, pinned; `sony_leica_corpus.rs`): 258 verified, 920 unverified; Leica on a verified body 21% → 72%, Sony 91% → 92%, use-weighted 85% and 43% | +| 2026-10-14 | minor | 17 uncompressed Olympus and Samsung models verified on one CC0 file each (`olympus_samsung_corpus.rs`): 254 verified, 932 unverified, 224 preview; OM / Olympus 3% → 6% (5% on verified bodies); use-weighted 85% and 42% unchanged; duplicated summary rows from a merge removed | +| 2026-10-14 | minor | Nikon D600 and D610 image area (6016 × 4016 at 8, 6) measured on 4 CC0 NEFs of each, and the D90's area and white level applied to the D5000 (same sensor and filler, 2 CC0 NEFs): the filler and noise columns on the right no longer develop as a band, and for these bodies the white level is measured inside the image area, issue #1627 | | 2026-10-11 | minor | Leica Q3 verified by a recorded check of 38 private DNGs (no decode failure; mean ΔE to the camera JPEG 6.3–13.9): 236 verified, 941 unverified, Leica bucket on a verified body 5% → 21%, weighted shares unchanged | | 2026-10-12 | patch | CR2 white level from the active area, not the saturated masked rows above it (#1272: EOS 50D, 60D, 450D (Rebel XSi), 500D (Rebel T1i), 550D (Rebel T2i), 1200D (Rebel T5) and 100D (Rebel SL1)); scores unchanged | | 2026-10-12 | minor | Canon PowerShot G9: as-shot white balance from WBInfo (`0x0029`, the row of the preset in ShotInfo), issue #1273; render unchanged (the camera-look fit already absorbed it), As Shot now the camera's; counts unchanged | | 2026-10-12 | minor | Olympus E-10: as-shot white balance (`0x1017`/`0x1018`) and black level (`0x1012` in `0x102c` ValidBits units) from the main maker note, issue #1273; ΔE76 against its thumbnail 18.5 → 11.5, camera-look fit now accepted; counts unchanged | | 2026-10-13 | minor | Nikon D90 image area (4288 × 2848 at 10, 10) and white level (3830) measured on 10 of 550 NEFs from one body: its 42 filler columns no longer develop as a pink band | | 2026-10-12 | minor | Hasselblad Lunar verified (a Sony NEX-7 ARW under Make HASSELBLAD, now decoded): 236 verified, 232 preview; an uncompressed Leaf MOS is recognised as a raw (preview only) instead of opening as a greyscale image of its mosaic (#1275) | +| 2026-10-12 | minor | 22 more CR3 bodies verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; all 110 CC0 CR3s decode); EOS R6 Mark III ColorData 66 white balance: 259 verified, 919 unverified; use-weighted 92% (59% on verified bodies); Canon 73% → 94%; decoding 83%, RAW readiness ~56% → ~60%; CR3 gain +6.2 → +0.3 | +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `body_corpus.rs`); COOLPIX 5700 / 8400 / 8800 decode wrongly (now unsupported); #702 fixed, A100 preview-only: 354 verified, 824 unverified, 230 preview, 38 unsupported; use-weighted 86% (58% on verified bodies); decoding 77% unchanged | +| 2026-10-11 | minor | Nikon: every body without `CropArea` surveyed (#1053); 36 model / size / depth groups cropped to their measured camera frame (#976, #914), 6 pending; NEF row no longer claims every coding but HE decodes (compressed NEFs without a linearization table listed) | | 2026-10-10 | minor | Compressed ORF: pointer to the two Olympus patents that describe the method, as a clean-room starting point | | 2026-10-12 | minor | PEF without area tags: masked border rows and columns trimmed against the BlackPoint (issue #1271; KP, KF, K-3 Mark III, K-70, K-1, K-1 Mark II, K-S1, K-S2, 645Z); counts unchanged | | 2026-10-13 | minor | Lens profiles: Nikon in-file distortion (NikonNEFInfo `0xc7d5`) for the Z 6II, Z 8 and Z f, validated on 83 frames from three lenses and the CC0 Z f sample (`docs/nikon-lens-corrections.md`); lens score unchanged | | 2026-10-11 | minor | EOS R5 Mark II verified (CRX QP-map first literal and limited-length escape, odd-height Bayer planes; 4 CC0 files pinned, QP maps of 33,665 private files): 235 verified, 942 unverified; use-weighted 85% (42% on verified bodies); decoding 77%, RAW readiness unchanged at ~56% | | 2026-10-10 (later) | major | Model-by-model measurement against Adobe's published list (1,446 models: 234 verified, 943 unverified, 233 preview, 36 unsupported); `docs/raw/` data and re-measure script; use-weighted share 84% (40% on verified bodies); variant matrix; colour and lens sections with scores; ranked next cameras; RAW readiness ~56% from written parts | | 2026-10-10 | major | Created: formats, makers, demosaic, colour and lens checklists; target counts from the installed Lightroom 9.6 bundle listing | +| 2026-10-10 (later) | minor | Lens profiles: Sony ILCE-7RM6 in-file distortion next to the ILCE-7RM4A's (its lateral CA parsed, off by default); lens score unchanged (~10%: one more body) | +| 2026-10-14 | minor | 97 Canon CR2, Pentax and Ricoh models verified (one CC0 raw.pixls.us file each, pinned, checked against the camera JPEG; `canon_pentax_corpus.rs`): 334 verified, 844 unverified, 232 preview; 50% of photographers on a verified body; use-weighted 85% unchanged | +| 2026-10-14 | minor | Integration batch 36: CC0 per-model verification for 117 Nikon/Fujifilm, 97 Canon CR2/Pentax/Ricoh, 21 Sony/Leica and 23 Olympus/Samsung bodies, 22 more CR3 bodies and 30 more models; Epson ERF and Mamiya MEF decode; measured Nikon image areas (#1053, #1770): 515 verified, 675 unverified, 218 preview, 38 unsupported (re-measured with `coverage.py`) | +| 2026-10-15 | minor | By-maker and by-use tables re-measured with `coverage.py` after batches 36–39 (545 verified, 651 unverified, 212 preview, 38 unsupported; ~94% of photographers' cameras fully work, ~85% on a verified body); rows the per-model PRs had updated are current again | diff --git a/docs/raw/coverage-sweep.tsv b/docs/raw/coverage-sweep.tsv new file mode 100644 index 000000000..8e3b2449a --- /dev/null +++ b/docs/raw/coverage-sweep.tsv @@ -0,0 +1,45 @@ +# CC0 raw.pixls.us camera-coverage check, 2026-10-12; main 25ef65c8. File SHA-256 matches the public listing. +# FNV-1a-64 pins LightCraft's own complete decoded samples, row-major little-endian, including masked borders. +# Default renders at up to 1200 px; Pearson r on Lab L* at 200 px after orientation/aspect alignment, threshold 0.6. +# Hasselblad PreviewTIFF references check image structure only. Own-matrix DNG tone remains subject to issue #1277. +maker camera id file url sha256 width height cpp samples fnv1a64 luma_r reference notes +Apple iPhone 6s Plus 915 coverage-915.dng https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG 845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c 4032 3024 1 u16 17e4d3c620d593c9 0.9712 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -10.109 +Apple iPhone 7 Plus 939 coverage-939.dng https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e 4032 3024 1 u16 d0095a8b553f0f8a 0.9904 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.913 +Apple iPhone XS 3372 coverage-3372.dng https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng 50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6 4032 3024 1 u16 a8e819ac4e5beb2a 0.9805 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -14.751 +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) 3009 coverage-3009.dng https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG 7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec 5568 3648 1 u16 b207313fa7dee601 0.9781 embedded JPEG 16bit (3:2); comparison Normal/crop-render; mean dL -8.41 +DJI DJI Mavic 3 (Hasselblad L2D-20c) 5140 coverage-5140.dng https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4 5376 3956 1 u16 2c78ceb8cb893ff1 0.8618 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL 0.418 +DJI DJI Mavic 4 Pro (DJI FC9287) 8041 coverage-8041.dng https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG 901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0 8192 4608 1 u16 d1babb4646097d6f 0.6801 embedded JPEG 16bit (16:9); reference has a different distortion/crop; comparison Normal/crop-render; mean dL 2.583 +DJI DJI Mini 2 4785 coverage-4785.dng https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG 21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851 4000 3000 1 u16 d1e66e08f335b55b 0.9328 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -9.016 +DJI DJI Mini 2 4898 coverage-4898.dng https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d 4000 2250 1 u16 acaab4fe42d3dc58 0.9716 embedded JPEG 16bit (16:9); comparison Normal/crop-render; mean dL -8.153 +Google Pixel 4 XL 3502 coverage-3502.dng https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng 0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54 3700 2774 1 u16 d121f233fdbaff2c 0.9143 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.351 +Google Pixel 4a Rear Wide Camera 4176 coverage-4176.dng https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng 11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be 4032 3024 1 u16 ac056154d440cb2d 0.9515 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.361 +Google Pixel 6 Pro Rear Main Camera 5961 coverage-5961.dng https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng 3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d 4080 3072 1 u16 96631250f12729ad 0.944 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -0.402 +Google Pixel 8 Pro Wide Camera* 7751 coverage-7751.dng https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng 45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5 8160 6144 1 u16 5e30a4d3bfc914b3 0.9485 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -5.099 +Hasselblad CFV-50c 7217 coverage-7217.3fr https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0 8374 6304 1 u16 14e60b95775930f2 0.9984 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -14.199 +Hasselblad CFV-50c 7218 coverage-7218.fff https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932 8374 6304 1 u16 db63ffcd176381ee 0.9443 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.828 +Hasselblad H5D-40 1640 coverage-1640.fff https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff 209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832 7410 5586 1 u16 1d34f6f4e45b5eab 0.9574 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 29.541 +Hasselblad H5D-50c 1639 coverage-1639.fff https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff 1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0 8374 6304 1 u16 02a189d2a6b78d4e 0.9453 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 28.943 +Hasselblad X1D 2058 coverage-2058.3fr https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR 395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3 8384 6304 1 u16 0b480efe4d2ed7c2 0.9753 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -9.059 +Hasselblad X1D II 50C 3874 coverage-3874.fff https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff 8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794 8384 6304 1 u16 c7372897d70da8a8 0.9436 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 22.474 +Hasselblad X1D II 50C 3941 coverage-3941.3fr https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR 809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47 8384 6304 1 u16 3c3a77bf67197196 0.9982 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -11.176 +Hasselblad X2D 100C 6568 coverage-6568.fff https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff 44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc 11904 8842 1 u16 849906d491fba55a 0.9196 exiftool:PreviewTIFF 16bit (4:3); TIFF preview is not display-ready; neutral-matrix colour remains unverified; comparison Normal/crop-render; mean dL 26.781 +Hasselblad X2D 100C 6565 coverage-6565.3fr https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280 11904 8842 1 u16 c07a267c0bc60a5f 0.9962 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -2.632 +Nikon D6 4726 coverage-4726.nef https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233 5584 3728 1 u16 272425c271bb0d76 0.9557 embedded JPEG 12bit 12bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.037 +Nikon D6 4720 coverage-4720.nef https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF 827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f 5584 3728 1 u16 1d6a9f10a502fb14 0.9581 embedded JPEG 14bit 14bit compressed (Lossless) (3:2); comparison Normal/crop-render; mean dL 0.048 +Nikon D6 4721 coverage-4721.nef https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF 081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838 5584 3728 1 u16 2d6c8af38e4a0ed4 0.9592 embedded JPEG 14bit 14bit compressed (Lossy (type 2)) (3:2); comparison Normal/crop-render; mean dL 0.043 +Nikon Z 6 3584 coverage-3584.nef https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF 3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886 6064 4040 1 u16 a63b22e83bb7c82f 0.8749 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 0.327 +Nikon Z 6 3581 coverage-3581.nef https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF 8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02 6064 4040 1 u16 1e23d74a11c01a97 0.8752 embedded JPEG 14bit 14bit compressed (3:2); comparison Normal/crop-render; mean dL 0.353 +Nikon Z 6 3582 coverage-3582.nef https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31 6064 4040 1 u16 0a0bc73568e125e0 0.8755 embedded JPEG 14bit 14bit lossless compressed (3:2); comparison Normal/crop-render; mean dL 0.464 +Nikon Z 7 2792 coverage-2792.nef https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded 8288 5520 1 u16 c989690cb3e19ef9 0.7417 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.417 +Nikon Z 7 2789 coverage-2789.nef https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e 8288 5520 1 u16 627867ac6a8dd4ad 0.7412 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.299 +Nikon Z 7 2790 coverage-2790.nef https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114 8288 5520 1 u16 8a52e6f3d8ff64dd 0.7389 embedded JPEG 12bit 12bit compressed (3:2); comparison Normal/crop-render; mean dL 2.585 +Nikon Z 8 6617 coverage-6617.nef https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40 8280 5520 1 u16 716e1158bab28b76 0.9563 embedded JPEG 8bit 8bit compressed (3:2); comparison Normal/crop-render; mean dL 2.044 +Parrot Anafi 2912 coverage-2912.dng https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6 5344 4016 1 u16 07a188c932a25a31 0.9895 embedded JPEG 16bit (4:3); comparison Normal/crop-render; mean dL -3.924 +Samsung Galaxy S21 Ultra Rear Main Camera 6728 coverage-6728.dng https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng 5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e 4000 3000 3 u16 16c5d6faed83390d 0.9912 embedded JPEG 4:3; comparison Rotate180/crop-render; mean dL -10.83 +Samsung Galaxy S22 Ultra Rear Wide Camera 6261 coverage-6261.dng https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng 53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581 4000 3000 3 u16 7cbe612adc5575b4 0.9739 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -20.487 +Samsung Galaxy S23 Rear Wide Camera 6697 coverage-6697.dng https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng 1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067 8160 6120 3 u16 b3da1535e2e31e9f 0.9418 embedded JPEG 4:3; comparison Normal/crop-render; mean dL -6.585 +Samsung Galaxy S7 Edge 1297 coverage-1297.dng https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng 843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561 4032 2268 1 u16 7e99d7da67022e5a 0.8677 exiftool:PreviewTIFF 16bit (16:9); comparison Normal/crop-render; mean dL 5.772 +Samsung Galaxy S8 2645 coverage-2645.dng https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng 6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2 4032 3024 1 u16 b70ca9a743283754 0.9884 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL -4.112 +Samsung Galaxy S8+ 2310 coverage-2310.dng https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng 3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c 4032 3024 1 u16 c3a20523ad3b18cf 0.986 exiftool:PreviewTIFF 16bit (4:3); comparison Normal/crop-render; mean dL 2.437 +Sigma fp 7273 coverage-7273.dng https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG 0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9 6064 4042 1 u16 f7b92f1904d6cc31 0.9754 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 7.1 +Sigma fp L 6949 coverage-6949.dng https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG 8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70 9536 6344 1 u16 6896f73ec6b49231 0.9998 embedded JPEG 14bit (3:2); comparison Normal/crop-render; mean dL 1.597 diff --git a/docs/raw/coverage.py b/docs/raw/coverage.py index c635c993a..c4552feee 100644 --- a/docs/raw/coverage.py +++ b/docs/raw/coverage.py @@ -83,7 +83,11 @@ def classify(maker, camera, ext_field): return "preview", "SRW", "compressed SRW is not decoded" return "unverified", "SRW", "uncompressed / packed SRW" e = es[0] if es else "" - if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "ERF", "KDC", "MRW", "X3F", "RAW", "ORI"): + if e == "MEF": + return "unverified", "MEF", "MEF decoder (uncompressed 12-bit)" + if e == "ERF": + return "unverified", "ERF", "ERF decoder (12-bit 16-byte blocks)" + if e in ("CRW", "3FR", "FFF", "IIQ", "MOS", "KDC", "MRW", "X3F", "RAW", "ORI"): return "preview", e, f"{e} is opened from its embedded JPEG only" return "unsupported", e, f"{e or 'unknown'} is not imported" diff --git a/docs/raw/coverage.tsv b/docs/raw/coverage.tsv index 7d224dc6e..8bf7c67a8 100644 --- a/docs/raw/coverage.tsv +++ b/docs/raw/coverage.tsv @@ -97,16 +97,16 @@ Apple iPad Pro 12.9-inch (2018 model) DNG 2018 unverified DNG decoder (uncompres Apple iPad Pro 13-inch M4 (2024 version) Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad mini (6th Generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 6s DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 6s Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 6s Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone 7 Plus DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone 7 Plus DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone 8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone 8 Plus DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (2020 version) DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone X DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone XR DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Apple iPhone XS DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Apple iPhone XS DNG 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Apple iPhone XS Max DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPad Air (5th generation) Rear Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Apple iPhone SE (3rd generation) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -116,117 +116,117 @@ Apple iPad mini (A17 Pro) Rear Camera DNG 2024 unverified DNG decoder (uncompres Canon EOS-1D TIF 2003 unsupported TIF is not imported Canon EOS-1D C CR2 2012 unverified CR2 lossless JPEG decoder Canon EOS-1D Mark II CR2 2003 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark II N CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark III CR2 2007 unverified CR2 lossless JPEG decoder -Canon EOS-1D Mark IV CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS-1D Mark II N CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS-1Ds TIF 2003 unsupported TIF is not imported Canon EOS-1Ds Mark II CR2 2005 unverified CR2 lossless JPEG decoder Canon EOS-1Ds Mark III CR2 2007 unverified CR2 lossless JPEG decoder Canon EOS-1D X CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS-1D X Mark II CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS-1D X Mark III CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 5D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS-1D X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark III CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 5D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 5D Mark II CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS-5D Mark III CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS-5D Mark IV (** see note) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 5DS CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS-5D Mark IV (** see note) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 5DS R CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Canon EOS 6D CR2 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 6D Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 6D Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 7D CR2 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 7D Mark II CR2 2013 unverified CR2 lossless JPEG decoder +Canon EOS 7D Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 10D CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 20D CR2 2003 unverified CR2 lossless JPEG decoder +Canon EOS 20D CR2 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 20Da CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS 30D CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 30D CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 40D CR2 2007 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 50D CR2 2008 unverified CR2 lossless JPEG decoder -Canon EOS 60D CR2 2010 unverified CR2 lossless JPEG decoder +Canon EOS 50D CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 60Da CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 70D CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS 77D (EOS 9000D) CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS 70D CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 80D CR2 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 90D CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS 90D CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS D30 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS D60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon EOS 300D (EOS Digital Rebel, EOS Kiss Digital) CRW 2003 preview CRW is opened from its embedded JPEG only -Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 unverified CR2 lossless JPEG decoder +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS 550D (EOS Digital Rebel T2i, EOS Kiss X4 Digital) CR2 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 unverified CR2 lossless JPEG decoder -Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 unverified CR2 lossless JPEG decoder -Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 unverified CR2 lossless JPEG decoder -Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 unverified CR2 lossless JPEG decoder -Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 unverified CR2 lossless JPEG decoder -Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 unverified CR2 lossless JPEG decoder +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) CR2 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS C50 CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M CR2 2012 unverified CR2 lossless JPEG decoder -Canon EOS M2 CR2 2013 unverified CR2 lossless JPEG decoder -Canon EOS M200 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M3 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M5 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M50 Mark II CR3 2020 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M6 CR2 2015 unverified CR2 lossless JPEG decoder -Canon EOS M6 Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS M10 CR2 2015 unverified CR2 lossless JPEG decoder +Canon EOS M CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M200 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M3 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M50 Mark II CR3 2020 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M6 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M6 Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M10 CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon EOS M50 (EOS Kiss M) CR3 2017 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS M100 CR2 2017 unverified CR2 lossless JPEG decoder -Canon EOS R (** see note) CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS M100 CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS R (** see note) CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R1 CR3 2023 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R3 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R5 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R3 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R5 C CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview Canon EOS R5 Mark II CR3 2023 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files -Canon EOS R6 CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 CR3 2019 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R6 V CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R6 Mark II CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R6 Mark III CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R7 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R6 Mark II CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) +Canon EOS R6 Mark III CR3 2025 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) Canon EOS R8 Mark II CR3 2025 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R10 CR3 2021 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R10 CR3 2021 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS Ra CR3 2019 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS RP CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS RP CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R8 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) -Canon EOS R50 CR3 2022 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon EOS R50 V CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon EOS R50 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS R100 CR3 2022 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon PowerShot 600 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A5 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot A50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G1 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G1 X CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot G1 X Mark II CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot G1 X Mark III CR2 2017 unverified CR2 lossless JPEG decoder +Canon PowerShot G1 X CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III CR2 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G2 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot G3 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G3 X CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G3 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G5 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G5 X CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G5 X Mark II CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot G5 X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G5 X Mark II CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon PowerShot G6 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot G7 X CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot G7 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G7 X Mark III CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon PowerShot G9 CR2 2007 unverified CR2 lossless JPEG decoder -Canon PowerShot G9X CR2 2015 unverified CR2 lossless JPEG decoder -Canon PowerShot G9 X Mark II CR2 2015 unverified CR2 lossless JPEG decoder +Canon PowerShot G7 X CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark III CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G9 CR2 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II CR2 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot G10 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot G11 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot G12 CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot G15 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot G16 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot G11 CR2 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot Pro 1 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro70 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot Pro90 IS CRW 2003 preview CRW is opened from its embedded JPEG only @@ -236,17 +236,17 @@ Canon PowerShot S45 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S50 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S60 CRW 2003 preview CRW is opened from its embedded JPEG only Canon PowerShot S90 CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot S95 CR2 2010 unverified CR2 lossless JPEG decoder +Canon PowerShot S95 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot S70 CRW 2003 preview CRW is opened from its embedded JPEG only -Canon PowerShot S100 CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot S100V CR2 2010 unverified CR2 lossless JPEG decoder -Canon PowerShot S110 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot S120 CR2 2013 unverified CR2 lossless JPEG decoder +Canon PowerShot S100 CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V CR2 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Canon PowerShot SX1 IS CR2 2008 unverified CR2 lossless JPEG decoder -Canon PowerShot SX50 CR2 2012 unverified CR2 lossless JPEG decoder -Canon PowerShot SX60 HS CR2 2013 unverified CR2 lossless JPEG decoder -Canon PowerShot SX70 HS CR3 2018 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview -Canon PowerShot V1 CR3 2024 unverified CRX decoder: lossless and C-RAW 0x100 / 0x200 single-tile; other variants fall back to the preview +Canon PowerShot SX50 CR2 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS CR2 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX70 HS CR3 2018 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 CR3 2024 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Casio EXILIM EX-10 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-100F DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -274,9 +274,9 @@ Casio EXILIM EX-ZR4100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, loss Casio EXILIM EX-ZR5000 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Casio EXILIM EX-ZR5100 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Contax N Digital RAW 2003 preview RAW is opened from its embedded JPEG only -DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI AIR 2S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mini 2 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mini 2 DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Air 3 Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3 Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mini 3 Pro DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -287,48 +287,48 @@ DJI DJI Mavic 3 Cine (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncom DJI DJI Mavic 3 Pro Super Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro Telephoto Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 3 Pro (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 3 (Hasselblad L2D-20c) DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Osmo Pocket 3 D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Telephoto Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Air 3S Wide Camera DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Osmo Action 5 Pro DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DJI DJI Mavic 4 Pro (DJI FC9284) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +DJI DJI Mavic 4 Pro (DJI FC9287) DNG 2025 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) DJI DJI Mavic 4 Pro (Hasselblad L3D-100c) DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) DxO DxO ONE DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Epson R-D1 ERF 2003 preview ERF is opened from its embedded JPEG only -Epson R-D1s ERF 2005 preview ERF is opened from its embedded JPEG only -Epson R-D1x ERF 2008 preview ERF is opened from its embedded JPEG only -Fujifilm GFX 50R RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Epson R-D1 ERF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s ERF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x ERF 2008 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Fujifilm GFX 50R RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 50S RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 50S II RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 50S II RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 RAF 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100 II RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100 II RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 II IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 100 IR RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm GFX 100S RAF 2020 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm GFX 100S II RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm GFX 100S II RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100RF (Final Support) RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm FinePix E900 RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F505 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F550 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F550 EXR RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F600 EXR RAF 2010 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F605 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F700 RAF 2003 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix F770 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix F770 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix F775 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F800 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix F900 EXR RAF 2012 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix HS10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS20 EXR RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm FinePix HS22 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS30 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS33 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS30 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix HS35 EXR RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix HS50 EXR RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix HS50 EXR RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix IS-1 RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix IS Pro RAF 2008 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix S1 RAF 2013 preview pre-X FinePix RAF without a raw IFD +Fujifilm FinePix S1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm FinePix S100fs RAF 2007 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S2 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S20 Pro RAF 2003 preview pre-X FinePix RAF without a raw IFD @@ -342,59 +342,59 @@ Fujifilm FinePix S6000fd (FinePix S6500fd) RAF 2005 preview pre-X FinePix RAF wi Fujifilm FinePix S7000 Z RAF 2003 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9000 (FinePix S9500) RAF 2005 preview pre-X FinePix RAF without a raw IFD Fujifilm FinePix S9100 (FinePix S9600) RAF 2005 preview pre-X FinePix RAF without a raw IFD -Fujifilm FinePix SL1000 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X10 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X100 RAF 2010 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X-M1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm FinePix X-S1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100F RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100S RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100T RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100V** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X100VI RAF 2023 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X20 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X30 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X70 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-A1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm FinePix SL1000 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 RAF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI RAF 2023 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A3 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A3 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A5 RAF 2017 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-A7 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-A10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-A7 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-A20 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-E1 RAF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-E2 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E2S RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E3 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-E4 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-E2 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-E5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-H1 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-H1 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-H2 IR RAF 2024 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-H2S RAF 2021 verified docs/raf-compression.md (supplied files, exact sensor arrays) Fujifilm X-M5 RAF 2024 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-Pro1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-Pro2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-Pro3** RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-S10 RAF 2020 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-S20 RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-Pro1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 RAF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T1 IR RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) Fujifilm X-T2 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T3 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T3 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T4** RAF 2019 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm X-T5 RAF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T10 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T10 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T20 RAF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T30 RAF 2018 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T30 II RAF 2021 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T30 III RAF 2022 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T30 RAF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II RAF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III RAF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-T50 RAF 2023 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Fujifilm X-T100 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm X-T200 RAF 2019 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XF1 RAF 2012 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XF10 RAF 2017 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XQ1 RAF 2013 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) -Fujifilm XQ2 RAF 2015 unverified RAF decoder (uncompressed, lossless / lossy compressed, Bayer and X-Trans) +Fujifilm X-T100 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 RAF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 RAF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 RAF 2017 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 RAF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 RAF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm X-H2 RAF 2021 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Google Pixel DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel XL DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -407,7 +407,7 @@ Google Pixel 3 XL DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, De Google Pixel 3 XL Front Standard Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 3 XL Front Wide Camera DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4 XL DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4 XL DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5 Front Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5 Rear Ultra Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -415,7 +415,7 @@ Google Pixel 6 Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, Google Pixel 6 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 6 Pro Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 6 Pro Rear Main Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 6 Pro Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 6 Pro Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -426,7 +426,7 @@ Google Pixel 7 Pro Telephoto Camera DNG 2022 unverified DNG decoder (uncompresse Google Pixel 7 Pro Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 7 Pro Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 4a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 4a Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 4a Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 5a Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Main Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 5a Rear Ultra Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -442,7 +442,7 @@ Google Pixel 8 Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, Google Pixel 8 Pro Front Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Telephoto Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8 Pro Ultrawide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Google Pixel 8 Pro Wide Camera* DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Google Pixel 8 Pro Wide Camera* DNG 2023 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Google Pixel 8a Front Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Ultrawide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Google Pixel 8a Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -502,7 +502,7 @@ Hasselblad CF-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFH-39 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad CFV 3FR 2008 preview 3FR is opened from its embedded JPEG only -Hasselblad CFV-50c 3FR 2008 preview 3FR is opened from its embedded JPEG only +Hasselblad CFV-50c 3FR 2008 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H2D DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Hasselblad H2D-22 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H2D-39 3FR 2008 preview 3FR is opened from its embedded JPEG only @@ -515,18 +515,18 @@ Hasselblad H3DII-39MS 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H3DII-50 3FR 2008 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-40 3FR 2010 preview 3FR is opened from its embedded JPEG only Hasselblad H4D-60 3FR 2010 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-40 3FR 2012 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-40 3FR 2012 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-50 3FR 2012 preview 3FR is opened from its embedded JPEG only -Hasselblad H5D-50c 3FR 2013 preview 3FR is opened from its embedded JPEG only +Hasselblad H5D-50c 3FR 2013 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad H5D-60 3FR 2013 preview 3FR is opened from its embedded JPEG only Hasselblad H6D-100c 3FR 2015 preview 3FR is opened from its embedded JPEG only Hasselblad HV ARW 2013 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Hasselblad Lunar 3FR 2012 verified issue #1275: raw.pixls.us 4051 (a Sony NEX-7 ARW under Make HASSELBLAD) decodes from sensor data; black 512 and SR2SubIFD WB as on a NEX-7 Hasselblad Stellar II 3FR 2015 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D 3FR 2015 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D 3FR 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Hasselblad X2D II 100C (**) 3FR 2024 preview 3FR is opened from its embedded JPEG only -Hasselblad X1D II 50C 3FR 2018 preview 3FR is opened from its embedded JPEG only -Hasselblad X2D 100C 3FR 2021 preview 3FR is opened from its embedded JPEG only +Hasselblad X1D II 50C 3FR 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C 3FR 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration Huawei Honor 20 Pro Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Telephoto Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Huawei Honor 20 Pro Rear Wide Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -586,8 +586,8 @@ Leaf Credo 50 IIQ 2013 preview IIQ is opened from its embedded JPEG only Leaf Credo 60 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leaf Credo 80 IIQ 2012 preview IIQ is opened from its embedded JPEG only Leica C (Typ 112) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica CL DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica C-Lux DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica CL DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux DNG 2017 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica D-Lux 2 RAW 2005 unverified Panasonic decoder, every raw format Leica D-Lux 3 RAW 2005 verified PR #215: raw.pixls.us bulk check of every RW2 / RWL / RAW body Leica D-Lux 4 RWL 2008 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format @@ -601,26 +601,26 @@ Leica Digilux 2 RAW 2003 verified PR #215: raw.pixls.us bulk check of every RW2 Leica Digilux 3 RAW 2005 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica Digital-Modul-R DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Leitz Phone 3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M8 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M9 DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M10 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M8 DNG 2003 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M9 DNG 2008 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M10 DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M10-D DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M10-P DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M10-R DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M10-R DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica M11-D DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-D (Typ 262) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-E DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-EV1 DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M-P DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M Monochrom DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica M Monochrom (Typ 246) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica M Monochrom DNG 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M Monochrom (Typ 246) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) Leica M10 Monochrom DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11 MONOCHROM DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica M11-P DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q (Typ 116) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica Q2 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica Q (Typ 116) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 DNG 2018 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica Q2 Monochrom DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica Q3 DNG 2022 verified recorded check 2026-10-11: 38 private Q3 DNGs (landscape and portrait) decoded from sensor data and developed at full size without error, mean CIE76 dE to the camera JPEG 6.3-13.9 at 400 px (not committed; docs/raw-parity.md) Leica Q3 43 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -628,8 +628,8 @@ Leica Q3 Monochrom DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, D Leica S2 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S (Typ 007) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica S3 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica SL (Typ 601) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Leica SL2 DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Leica SL (Typ 601) DNG 2015 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 DNG 2019 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica SL2-S DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3 DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica SL3-P DNG 2025 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -643,7 +643,7 @@ Leica V-LUX 3 RAW 2010 unverified Panasonic decoder, every raw format Leica V-LUX 4 RWL 2012 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 RWL 2018 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX (Typ 114) RWL 2013 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format -Leica X2 RWL 2012 unverified Panasonic decoder, every raw format +Leica X2 RWL 2012 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Leica X (Typ 113) DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Leica X-U (Typ 113) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) LG G7 ThinQ DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -673,107 +673,107 @@ Mamiya Mamiya M18 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M22 TIF 2008 unsupported TIF is not imported Mamiya Mamiya M31 TIF 2008 unsupported TIF is not imported Mamiya Mamiya DM40 MOS 2010 preview MOS is opened from its embedded JPEG only -Mamiya ZD MEF 2005 unsupported MEF is not imported +Mamiya ZD MEF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Motorola Moto G Power (2021) Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Macro Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Motorola Moto G Power (2021) Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Nikon 1 J1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J3 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J4 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 J5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 S1 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 J1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon 1 S2 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V1 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V2 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon 1 V3 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon 1 V1 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX 5000 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX 5400 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 5700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 8400 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 5700 NEF 2003 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8400 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Nikon COOLPIX 8700 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX 8800 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX A NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX A1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX 8800 NEF 2005 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX A NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX B700 NRW 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon COOLPIX P330 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P340 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P1000 NRW 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P1100 NRW 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P1000 NRW 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 NRW 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX P6000 NRW 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7000 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7100 NRW 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7700 NRW 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon COOLPIX P7800 NRW 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon COOLPIX P950 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon COOLPIX P950 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D1H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D1H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D1X NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2H NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2Hs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2X NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D2Xs NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3X NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D4 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D4S NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D40 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D40x NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D50 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D60 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D6 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D70 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D70S NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D80 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D90 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D2H NEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D6 NEF 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon D70 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D100 NEF 2003 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D200 NEF 2005 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D300 NEF 2007 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D300s NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D600 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D610 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D700 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D750 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D780 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D800 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D800E NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D810 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D200 NEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 NEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D700 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D810A NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D850 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3100 NEF 2010 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3400 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D3500 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5000 NEF 2008 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D850 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 NEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 NEF 2018 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 NEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D5100 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D5200 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5300 NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5500 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D5600 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D5200 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D7000 NEF 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon D7100 NEF 2012 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon D7200 NEF 2015 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon D7100 NEF 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 NEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon D7500 NEF 2015 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Nikon Df NEF 2013 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 30 NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 50 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 50 II NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 5 NEF 2019 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Df NEF 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II NEF 2024 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 NEF 2019 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 5 2 NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nikon Z 5 2C NEF 2025 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 6 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 6 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 6 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z 6 III NEF 2023 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 NEF 2018 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 7 II NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 8 NEF 2022 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z 9 (*See note) NEF 2021 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only -Nikon Z fc NEF 2020 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only +Nikon Z 7 NEF 2018 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 II NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 8 NEF 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only +Nikon Z 9 (*See note) NEF 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc NEF 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon Z f NEF 2022 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon ZR NEF 2024 unverified NEF decoder (uncompressed, lossless, lossy, lossy after split); HE / HE* setting preview-only Nokia Lumia 1020 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -788,13 +788,13 @@ OM Digital Solutions OM-5 Mark II ORF 2024 preview compressed ORF is not decoded OM Digital Solutions OM-3 Astro ORF 2025 preview compressed ORF is not decoded Olympus AIR A01 ORF 2015 preview compressed ORF is not decoded Olympus Camedia C-5050 Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus Camedia C-5060 Zoom ORF 2003 unverified uncompressed / packed ORF -Olympus Camedia C-7070 Wide Zoom ORF 2005 unverified uncompressed / packed ORF +Olympus Camedia C-5060 Zoom ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Camedia C-8080 Wide Zoom ORF 2003 unverified uncompressed / packed ORF Olympus E-1 ORF 2003 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus E-3 ORF 2007 preview compressed ORF is not decoded Olympus E-5 ORF 2010 preview compressed ORF is not decoded -Olympus E-10 ORF 2003 unverified uncompressed / packed ORF +Olympus E-10 ORF 2003 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) Olympus E-20 ORF 2003 unverified uncompressed / packed ORF Olympus E-30 ORF 2008 preview compressed ORF is not decoded Olympus E-420 ORF 2008 preview compressed ORF is not decoded @@ -802,18 +802,18 @@ Olympus E-450 ORF 2008 preview compressed ORF is not decoded Olympus E-520 ORF 2008 preview compressed ORF is not decoded Olympus E-600 ORF 2010 preview compressed ORF is not decoded Olympus E-620 ORF 2008 preview compressed ORF is not decoded -Olympus EVOLT E-300 ORF 2005 unverified uncompressed / packed ORF -Olympus EVOLT E-330 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-300 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus EVOLT E-400 ORF 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-410 ORF 2007 preview compressed ORF is not decoded -Olympus EVOLT E-500 ORF 2005 unverified uncompressed / packed ORF +Olympus EVOLT E-500 ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus EVOLT E-510 ORF 2007 preview compressed ORF is not decoded Olympus OM-D E-M1 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M1X ORF 2018 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark II ORF 2015 preview compressed ORF is not decoded Olympus OM-D E-M1 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M5 ORF 2012 preview compressed ORF is not decoded -Olympus OM-D E-M5 Mark II ORF 2015 preview compressed ORF is not decoded +Olympus OM-D E-M5 Mark II ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus OM-D E-M5 Mark III ORF 2019 preview compressed ORF is not decoded Olympus OM-D E-M10 ORF 2013 preview compressed ORF is not decoded Olympus OM-D E-M10 Mark II ORF 2015 preview compressed ORF is not decoded @@ -837,25 +837,25 @@ Olympus PEN E-PL8 ORF 2015 preview compressed ORF is not decoded Olympus PEN E-PL9 ORF 2017 preview compressed ORF is not decoded Olympus PEN E-PM1 ORF 2010 preview compressed ORF is not decoded Olympus PEN E-PM2 ORF 2012 preview compressed ORF is not decoded -Olympus PEN-F ORF 2015 preview compressed ORF is not decoded +Olympus PEN-F ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-310 ORF 2005 preview compressed ORF is not decoded Olympus SP-320 ORF 2005 preview compressed ORF is not decoded Olympus SP-350 ORF 2005 preview compressed ORF is not decoded Olympus SP-500 UZ ORF 2005 preview compressed ORF is not decoded -Olympus SP-510 UZ ORF 2005 preview compressed ORF is not decoded -Olympus SP-550 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-510 UZ ORF 2005 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-550 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus SP-560 UZ ORF 2007 preview compressed ORF is not decoded -Olympus SP-565 UZ ORF 2008 preview compressed ORF is not decoded -Olympus SP-570 UZ ORF 2007 preview compressed ORF is not decoded +Olympus SP-565 UZ ORF 2008 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ ORF 2007 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus 1 ORF 2013 preview compressed ORF is not decoded Olympus Stylus 1s ORF 2013 preview compressed ORF is not decoded -Olympus Stylus SH-2 ORF 2015 preview compressed ORF is not decoded +Olympus Stylus SH-2 ORF 2015 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus Stylus SH-3 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-4 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-5 ORF 2015 preview compressed ORF is not decoded Olympus Stylus Tough TG-6 ORF 2018 preview compressed ORF is not decoded Olympus Stylus XZ-2 iHS ORF 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Olympus Stylus XZ-10 ORF 2012 preview compressed ORF is not decoded +Olympus Stylus XZ-10 ORF 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Olympus XZ-1 ORF 2010 preview compressed ORF is not decoded OnePlus 7T Front Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) OnePlus 7T Rear Main Camera DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1007,46 +1007,46 @@ Panasonic LUMIX DC-S1M2ES (Final Support) RW2 2024 verified PR #215: 178 raw.pix Panasonic LUMIX DC-S1M2 RW2 2024 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Panasonic LUMIX DC-ZS300 (DC-TZ300, DC-TX3) RW2 2025 unverified Panasonic decoder, every raw format Panasonic LUMIX DC-L10 RW2 2025 unverified Panasonic decoder, every raw format -Parrot Anafi DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Parrot Anafi DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Parrot ANAFI Ai DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax 645D DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax 645Z DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax *ist D PEF 2003 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax *ist DL PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax 645D DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D PEF 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DL2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax *ist DS PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax *ist DS PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax *ist DS2 PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-01 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-1 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-01 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-1 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K-1 II (* see note) DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax K-3 DNG 2013 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-3 II († see note) DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-5 II DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K-3 II († see note) DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K-5 IIs DNG 2012 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K-7 PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-3 Mark III DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-3 Mark III Monochrome DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-30 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-50 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-70 PEF 2015 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-500 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax KP DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-r PEF 2010 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K-S1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-S2 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K-x PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) +Pentax K-7 PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III DNG 2020 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome DNG 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax K-30 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-70 PEF 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KP DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r PEF 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax K10D DNG 2005 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Pentax K20D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K100D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K100D Super PEF 2007 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K110D PEF 2005 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax K200D DNG 2007 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax K2000 K-m PEF 2008 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax KF PEF 2022 unverified PEF decoder (uncompressed, Huffman, packed 12-bit) -Pentax MX-1 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax Q DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Pentax Q7 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Pentax K20D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super PEF 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D PEF 2005 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D DNG 2007 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m PEF 2008 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF PEF 2022 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 DNG 2012 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q DNG 2010 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Pentax Q10 DNG 2012 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pentax QS-1 DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Pixii PIXII Camera (A1571) DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1078,10 +1078,10 @@ Phase One P 40+ IIQ 2010 preview IIQ is opened from its embedded JPEG only Phase One P 45 TIF 2007 unsupported TIF is not imported Phase One P 45+ TIF 2007 unsupported TIF is not imported Phase One P 65+ IIQ 2010 preview IIQ is opened from its embedded JPEG only -Ricoh GR DNG 2013 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Ricoh GR II DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR DNG 2013 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II DNG 2015 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Ricoh GR III DNG 2018 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Ricoh GR IIIx DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GR IIIx DNG 2021 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Ricoh GR III HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IIIx HDF DNG 2023 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR IV DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1091,7 +1091,7 @@ Ricoh GR Digital II DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Ricoh GR Digital III DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GR Digital IV DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GX100 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Ricoh GX200 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Ricoh GX200 DNG 2003 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) Ricoh GXR with GR LENS A12 28mm F2.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A12 50mm F2.5 MACRO DNG 2008 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Ricoh GXR with GR LENS A16 24-85 F3.5-5.5 DNG 2010 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1105,14 +1105,14 @@ Samsung EK-KN120 SRW 2012 preview compressed SRW is not decoded Samsung EX2F SRW 2012 unverified uncompressed / packed SRW Samsung NX300 SRW 2012 preview compressed SRW is not decoded Samsung NX500 SRW 2015 preview compressed SRW is not decoded -Samsung NX1000 SRW 2012 unverified uncompressed / packed SRW -Samsung NX1100 SRW 2012 unverified uncompressed / packed SRW +Samsung NX1000 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 SRW 2012 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung NX2000 SRW 2012 preview compressed SRW is not decoded Samsung NX3000 SRW 2013 preview compressed SRW is not decoded Samsung NX3300 SRW 2015 preview compressed SRW is not decoded Samsung NX U SRW 2012 unverified uncompressed / packed SRW Samsung Pro 815 DNG 2003 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung TL350 (WB2000) SRW 2010 unverified uncompressed / packed SRW +Samsung TL350 (WB2000) SRW 2010 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung TL500 (EX1) SRW 2010 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Samsung Galaxy Note 9 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Note 20 Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1127,16 +1127,16 @@ Samsung Galaxy S21 Rear Main Camera DNG 2020 unverified DNG decoder (uncompresse Samsung Galaxy S21 Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21+ Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S21 Ultra Rear Main Camera DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S21 Ultra Rear Wide Camera DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S21 Ultra Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S6 Edge DNG 2018 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S7 DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S7 Edge DNG 2015 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S8+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S7 Edge DNG 2015 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ DNG 2017 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S9 DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S9+ DNG 2017 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy Z Fold3 Rear Main Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1154,11 +1154,11 @@ Samsung Galaxy S22 Ultra Front Camera DNG 2021 unverified DNG decoder (uncompres Samsung Galaxy S22 Ultra Rear Super Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Telephoto Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S22 Ultra Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S22 Ultra Rear Wide Camera DNG 2021 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23 Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23 Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Samsung Galaxy S23 Rear Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Samsung Galaxy S23 Rear Wide Camera DNG 2022 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Samsung Galaxy S23+ Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Telephoto Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Samsung Galaxy S23+ Rear Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) @@ -1230,8 +1230,8 @@ Sigma BF DNG 2024 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JP Sigma DP1 X3F 2007 preview X3F is opened from its embedded JPEG only Sigma DP1s X3F 2008 preview X3F is opened from its embedded JPEG only Sigma DP2 X3F 2008 preview X3F is opened from its embedded JPEG only -Sigma fp DNG 2019 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sigma fp L DNG 2020 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) +Sigma fp DNG 2019 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L DNG 2020 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) Sigma SD9 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD10 X3F 2003 preview X3F is opened from its embedded JPEG only Sigma SD14 X3F 2007 preview X3F is opened from its embedded JPEG only @@ -1243,12 +1243,12 @@ Sony Xperia 1 V Front Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92 Sony Xperia 1 V Ultrawide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Wide Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony Xperia 1 V Zoom Camera DNG 2022 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) -Sony A1 II (ILCE-1M2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A1 II (ILCE-1M2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7 (ILCE-7) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 II (ILCE-A7M2) ARW 2013 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A7 III (ILCE-7M3) ARW 2017 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7 IV (ILCE-7M4) ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony A7 V (ILCE-7M5) (** see note) ARW 2025 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony A7 V (ILCE-7M5) (** see note) ARW 2025 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony A7C (ILCE-7C) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7C II (ILCE-7CM2) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony A7CR (ILCE-7CR) ARW 2023 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1310,7 +1310,7 @@ Sony DSC-F828 SRF 2003 unsupported issue #702 (SRF refused); SRF not in the impo Sony DSC-HX95 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 ARW 2018 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 SRF 2005 unsupported SRF not in the import list -Sony DSC-RX100M7A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony DSC-RX100M7A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3 (ILME-FX3) ARW 2020 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony FX30 (ILME-FX30) ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony NEX-3 ARW 2010 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) @@ -1375,16 +1375,16 @@ Sony Xperia 5 V Wide Camera DNG 2023 unverified DNG decoder (uncompressed, LJ92, Sony Xperia Pro-I Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Sony ZV-E10 ARW 2021 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 ARW 2022 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony ZV-E10M2 ARW unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony ZV-1M2 ARW 2022 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ZV-E10M2 ARW verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-1M2 ARW 2022 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony ZV-1A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILX-LR1 ARW 2023 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 1M2 (Final Support) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) Sony ILCE - 6100A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony ILCE - 6400A ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony FX2 (ILMEFX2) (** see note) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony ILCE - 6400A ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Sony FX3A (ILMEFX3A) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) -Sony RX1R III (DSC-RX1RM3) ARW 2024 unverified ARW decoder (uncompressed, cRAW, packed 12-bit, lossless, downsized YCbCr) +Sony RX1R III (DSC-RX1RM3) ARW 2024 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Xiaomi Mi 11 Lite Front Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Ultrawide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) Xiaomi Mi 11 Lite Rear Wide Camera DNG 2021 unverified DNG decoder (uncompressed, LJ92, lossy, Deflate, JPEG XL, LinearRaw) diff --git a/docs/raw/evidence.tsv b/docs/raw/evidence.tsv index 4ce812d8c..6e60a6828 100644 --- a/docs/raw/evidence.tsv +++ b/docs/raw/evidence.tsv @@ -13,7 +13,90 @@ Canon EOS M50 (EOS Kiss M) verified corpus: crates/raw/tests/cr3_corpus.rs (exac Canon EOS R100 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) Canon EOS R5 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hashes, docs/cr3.md); bulk: QP maps of 33,665 C-RAW files Canon EOS R8 verified corpus: crates/raw/tests/cr3_corpus.rs (exact sensor hashes, docs/cr3.md) +Canon EOS-1D X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 90D verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 250D (EOS Rebel SL3, EOS 200D II, EOS Kiss X10) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS 850D (EOS Kiss X10i, EOS Rebel T8i) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M200 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M50 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS M6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R (** see note) verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R3 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R5 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R6 Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; C-RAW with adaptive QP above 168 still opens as the preview) +Canon EOS R6 Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R7 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md; lossless only, C-RAW unverified) +Canon EOS R10 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS RP verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon EOS R50 V verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G5 X Mark II verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot G7 X Mark III verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot SX70 HS verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) +Canon PowerShot V1 verified corpus: crates/raw/tests/cr3_corpus.rs (pinned sensor hash, camera-JPEG check, docs/cr3.md) Canon EOS-5D Mark III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Canon EOS 70D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 800D (EOS Digital Rebel T7i, EOS Kiss X9i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 3000D (EOS Rebel T100, EOS 4000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1000D (EOS Digital Rebel XS, EOS Kiss F) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 500D (EOS Digital Rebel T1i, EOS Kiss X3 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 400D (EOS Digital Rebel XTi, EOS Kiss Digital X) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S110 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1300D (EOS Digital Rebel T6, EOS Kiss X80) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 600D (EOS Digital Rebel T3i, EOS Kiss X5 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G16 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G3 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 750D (EOS Digital Rebel T6i, EOS Kiss X8i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100V verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 350D (EOS Digital Rebel XT, EOS Kiss Digital N) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1200D (EOS Digital Rebel T5, EOS Kiss X70) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G11 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 100D (EOS Digital Rebel SL1, EOS Kiss X7) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G15 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark IV verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 650D (EOS Digital Rebel T4i, EOS Kiss X4i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G12 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 6D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 700D (EOS Digital Rebel T5i, EOS Kiss X7i) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1100D (EOS Digital Rebel T3, EOS Kiss X50 Digital) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 77D (EOS 9000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 1500D (EOS Rebel T7, EOS Kiss X90, EOS 2000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-5D Mark IV (** see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 60D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS-1D Mark II N verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G5 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 5D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS M6 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 7D Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S120 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot S95 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G1 X Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 50D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G7 X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 450D (EOS Digital Rebel XSi, EOS Kiss Digital X2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot SX60 HS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 200D (EOS Kiss X9, EOS Rebel SL2) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon PowerShot G9 X Mark II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 760D (EOS Digital Rebel T6s, EOS Kiss 8000D) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Canon EOS 30D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Fujifilm GFX 100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100RF (Final Support) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Fujifilm GFX 100S verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -46,11 +129,144 @@ Leica V-LUX (Typ 114) verified PR #215: 178 raw.pixls.us files from 118 Panasoni Leica V-LUX 1 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 4 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format Leica V-LUX 5 verified PR #215: 178 raw.pixls.us files from 118 Panasonic / Leica bodies, every RW2 format +Leica M Monochrom verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica CL verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica C-Lux verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M Monochrom (Typ 246) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica M8 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica X2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica Q2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL (Typ 601) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M10-R verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica SL2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Leica M9 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash) +Leica Q (Typ 116) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) Nikon COOLPIX B700 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D5100 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7000 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon D7500 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Nikon Z f verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Nikon D700 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D610 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 6 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Xs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D780 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D850 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50R verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Df verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 V3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z fc verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D810 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D800E verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100V** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D4S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T10 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2X verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D80 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-H1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XQ1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS30 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P1100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-S20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D60 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix SL1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro3** verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-Pro2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100VI verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F550 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D5500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3300 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon 1 J3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 9 (*See note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A1000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100S verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D2Hs verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D90 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3000 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100T verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 50 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D600 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm XF1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D7100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E3 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-A1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS50 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 100 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X100F verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D40x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX A verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X20 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-E4 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm GFX 50S II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D1H verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix HS33 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon Z 7 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D300s verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D750 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3100 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon D3400 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix X-M1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm X-T30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX P950 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Fujifilm FinePix F770 EXR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Nikon COOLPIX 5700 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8400 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) +Nikon COOLPIX 8800 unsupported bulk: raw.pixls.us CC0 file decodes wrongly (two stacked half-height fields, CMYG mosaic read as RGB; green vs camera JPEG r 0.12-0.36) Olympus E-1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus EVOLT E-400 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Olympus Stylus XZ-2 iHS verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) @@ -151,8 +367,44 @@ Pentax K-3 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K-5 IIs verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Pentax K10D verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) Ricoh GR III verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Pentax K-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DL verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GX200 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax KP verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III Monochrome verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash) +Pentax K-70 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR IIIx verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K20D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist DS verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 Mark III verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-01 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K110D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax *ist D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-30 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K200D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-x verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-500 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K2000 K-m verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-50 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax 645Z verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-5 II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax Q verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax MX-1 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K100D Super verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Ricoh GR II verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-S2 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-r verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax KF verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-3 II († see note) verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) +Pentax K-7 verified corpus: crates/raw/tests/body_corpus.rs (pinned sensor hash, camera-JPEG check) Samsung TL500 (EX1) verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) -Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo +Sony A100 (DSLR-A100) preview issue #702 fixed (#774): opens from its embedded JPEG; its raw (no CFA image IFD) is not decoded Sony A200 (DSLR-A200) preview issue #535: preview-only on the raw.pixls.us sample Sony A230 (DSLR-A230) preview issue #535: preview-only on the raw.pixls.us sample Sony A290 (DSLR-A290) preview issue #535: preview-only on the raw.pixls.us sample @@ -213,7 +465,7 @@ Sony A900 (DSLR-A900) verified issue #535: raw.pixls.us bulk check of all 100 So Sony A99 (SLT-A99V) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies Sony A99 II (ILCA-99M2) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony Alpha 1 (ILCE-1)* verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) -Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Sony DSC-F828 unsupported SRF not in the import list (the probe accepts it again since #774, issue #702); raw not decoded Sony DSC-HX95 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-HX99 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony DSC-V3 unsupported SRF not in the import list @@ -250,3 +502,64 @@ Sony RX1R II (DSC-RX1RM2) verified issue #535: raw.pixls.us bulk check of all 10 Sony ZV-1 (DCZV1/B) verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E1 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) Sony ZV-E10 verified issue #535: raw.pixls.us bulk check of all 100 Sony bodies (decodes; BL/WL/crop/WB vs ExifTool) +Sony ZV-1M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ZV-E10M2 verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony RX1R III (DSC-RX1RM3) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A1 II (ILCE-1M2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony DSC-RX100M7A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A7 V (ILCE-7M5) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony FX2 (ILMEFX2) (** see note) verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony ILCE - 6400A verified corpus: crates/raw/tests/sony_leica_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-550 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus E-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash) +Olympus Camedia C-5060 Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-565 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus OM-D E-M5 Mark II verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-300 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus SH-2 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Camedia C-7070 Wide Zoom verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus Stylus XZ-10 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-330 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-570 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus SP-510 UZ verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus PEN-F verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Olympus EVOLT E-500 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung TL350 (WB2000) verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1000 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Samsung NX1100 verified corpus: crates/raw/tests/olympus_samsung_corpus.rs (pinned sensor hash, camera-JPEG check) +Sony A100 (DSLR-A100) unsupported issue #702: regression, refused as not a readable photo +Sony DSC-F828 unsupported issue #702 (SRF refused); SRF not in the import list +Epson R-D1 verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1s verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Epson R-D1x verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Mamiya ZD verified corpus: crates/raw/tests/corpus.rs (xtask RAW_SAMPLES) +Apple iPhone 6s Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone 7 Plus verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Apple iPhone XS verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 2 Pro (Hasselblad L1D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 3 (Hasselblad L2D-20c) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mavic 4 Pro (DJI FC9287) verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +DJI DJI Mini 2 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4 XL verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 4a Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 6 Pro Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Google Pixel 8 Pro Wide Camera* verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Hasselblad CFV-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-40 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad H5D-50c verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X1D II 50C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Hasselblad X2D 100C verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); decode verified, not colour calibration +Nikon D6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 6 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 7 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); checked compressed NEF only; uncompressed remains preview-only (#671) +Nikon Z 8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check); HE / HE* remain preview-only +Parrot Anafi verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S21 Ultra Rear Main Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S22 Ultra Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S23 Rear Wide Camera verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S7 Edge verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8 verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Samsung Galaxy S8+ verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) +Sigma fp L verified corpus: crates/raw/tests/coverage_sweep_corpus.rs (pinned sensor hashes); bulk: docs/raw/coverage-sweep.tsv (CC0, SHA-256, camera-image check) diff --git a/docs/releasing.md b/docs/releasing.md index feff91d87..914a7ad84 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -64,6 +64,16 @@ whose glyph-coverage test checks the characters in available faces. `CRAFT_FONTS the native build fail if the pinned manifest lacks Noto Sans CJK SC Regular (`Hans`) or BIZ UDPGothic Regular; the web build requires BIZ UDPGothic Regular and packages Noto Sans CJK SC as a separate asset. +Local macOS, Linux and Windows packaging scripts automatically use the sibling `../craft-fonts` +checkout. If it is absent, they clone it at revision +`3d9ab3a132ed0eff457e424226a435a901da30d3` (keep the pins in `packaging/fonts.sh` and +`packaging/windows/fonts.ps1` in sync with the release workflow). Existing checkouts are left untouched. +Set `CRAFT_FONTS_DIR` to use another +checkout; relative paths resolve from the workspace root. The script requires the Chinese and Japanese +UI faces and includes their OFL licences in the package. Download failures or incomplete checkouts +stop packaging with an error. `--skip-build` (`-SkipBuild` on Windows) packages existing binaries without downloading fonts or +changing what they embed; pass the same `CRAFT_FONTS_DIR` used to compile them so their licences ship. + ## Signing credentials All credentials are stored as secrets in the GitHub `release` environment. Signing is optional: diff --git a/docs/roadmap.md b/docs/roadmap.md index f50d36886..79d811da3 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -72,12 +72,12 @@ calibration: M0–M13 took ≈ 25 active hours against an estimate of ≈ 110– | 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, 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 | 🚧 (RAF all, RW2 every format, PEF, ORF uncompressed and 12.8 bpp, SRW uncompressed, HEIC ✅; CR3 🟡 (3 bodies verified); **camera colour calibration** 🚧 (per-file JPEG fits, 4 bundled profiles, 52 spectral matrices; measured database missing); compressed ORF / SRW, HE NEF, IIQ, X3F ⬜) | +| M11 | RAW II | CR3, RAF (X-Trans), ORF, RW2, PEF, SRW, 3FR, IIQ + long tail; camera calibration DB; HEIC/AVIF/JXL import | 20–35 | 🚧 (RAF all, RW2 every format, PEF, ORF uncompressed and 12.8 bpp, SRW uncompressed, HEIC ✅; CR3 🟡 (3 bodies verified); **camera colour calibration** 🚧 (per-file JPEG fits, 5 bundled profiles, 52 spectral matrices; measured database missing); compressed ORF / SRW, HE NEF, IIQ, X3F ⬜) | | M12 | AI & smart features | subject/sky/background/people/object masks, semantic search, faces/People (permissively licensed models, pure-Rust inference) | 20–40 | 🚧 (faces: detection, recognition, People view ✅; Object / Describe masks via SAM 3 🟡; Subject / Sky / People masks, semantic search ⬜) | | 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 ✅; contact-sheet PDF 🟡; 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/Spanish/German/Russian/Ukrainian 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/Georgian localisation 🟡; accessibility and further locales ⬜) | ## Risks that coding hours alone don't retire diff --git a/docs/slideshow.md b/docs/slideshow.md new file mode 100644 index 000000000..79cf5f04e --- /dev/null +++ b/docs/slideshow.md @@ -0,0 +1,64 @@ +# Slideshow + +LightCraft's slideshow follows Lightroom Classic's Slideshow module (no Adobe assets, templates or designs): the photos +in view played full screen with a layout, overlays, intro and ending screens and fades, and exported as PDF, JPEG or +video. There is no Slideshow module view yet: the settings live in Settings ▸ Slideshow (View ▸ Slideshow Options…) +next to a live preview of the selected photo's slide, and View ▸ Slideshow (⌥⌘↩) plays. + +| Part | Commands | Code | +|---|---|---| +| Playback: slide and fade durations, fade colour, random order, repeat, preparing slides ahead | `slideshow.playback`, `view.slideshow` | `crates/engine/src/slideshow.rs` (the timeline), `crates/ui-egui/src/panels/slideshow.rs` | +| Layout, options, backdrop: margins, aspect preview, zoom to fill, stroke, shadow, colour, colour wash, background photo | `slideshow.layout` | `crates/slideshow` (the composer, no UI), `crates/engine/src/slide.rs` | +| Overlays: identity plate, rating stars, text with tokens, watermark | `slideshow.overlays` | `crates/engine/src/slide_overlay.rs` | +| Intro and ending screens | `slideshow.titles` | `crates/engine/src/slide_title.rs` | +| Templates: built-ins of our own and the user's (`<settings>/slideshow-templates/*.json`) | `slideshow.templates.*` | `crates/engine/src/slide_template.rs` | +| One slide or screen as a PNG | `slideshow.preview` | `crates/engine/src/cmd/slideshow.rs` | +| Export: PDF, JPEG sequence, AV1 video | `slideshow.export`, `slideshow.exportStatus` | `crates/engine/src/slideshow_export.rs`, `crates/codecs/src/video.rs` | + +All settings are library preferences (`prefs.json`); sizes and offsets are fractions of the slide's shorter side, so a +slide looks the same on any screen and at any export size. Playback, the preview, `slideshow.preview` and export draw +the same pixels through the same composer. + +## Export + +View ▸ Export Slideshow… (or Export PDF / JPEG / Video at the end of Settings ▸ Slideshow) chooses a format and size, +then asks where to write. The slides are the photos in view, in view order, between the intro and ending screens that +are on; random order and repeat are playback choices and don't apply. The slide aspect is the Layout's aspect preview +(Screen exports 16:9). + +- **PDF** — one page per slide, its picture a JPEG at the chosen quality, the page as many points as the slide has + pixels (1024 / 1920 / 3840 on the long side; 64–8192 by command). Each page has the slide duration (`/Dur`) and, + after the first, a fade transition (`/Trans`), which PDF viewers use in presentation mode; "Automatically show full + screen" sets `/PageMode /FullScreen`. Written by `crates/engine/src/pdf.rs` (shared with the contact sheet), streamed + to disk. +- **JPEG** — `<name>-001.jpg`, `-002.jpg`… in the chosen folder (created; `name` defaults to the folder's name). +- **Video** — AV1 in MP4 (default) or WebM, 720p or 1080p (64–2160 by command), 24 or 30 fps (25 / 60 by command), no + audio (music is a later item, LRC-SS-MUSIC). The timeline is sampled at the frame rate: a slide's fade-in is rendered + frame by frame (a crossfade, or out to the fade colour and in from it), then the slide is held for the rest of its + duration as **one** sample lasting that long (variable frame rate), so a still slide costs one encoded frame. That + frame is a key frame: seeking lands on slides. Encoded by `rav1e` (pure Rust, BSD-2-Clause, already used for AVIF) at + speed 10, 8-bit 4:2:0 BT.709 limited range, in order (low latency, no frame reordering); at most 2 hours. + + The muxers are our own (`crates/codecs/src/video.rs`, from the public ISO/IEC 14496-12 + AV1-ISOBMFF and + Matroska/WebM specifications): MP4 is `ftyp`, one `mdat` (64-bit size patched at the end) and `moov` with an `av01` + sample entry (`av1C` holding the sequence header OBU, `colr nclx`, `pasp`), `stts` durations, `stss` key frames and a + single chunk; WebM is the EBML header and a Segment with SeekHead, Info (duration), Tracks (`V_AV1`, `CodecPrivate` = + `av1C`), Clusters of SimpleBlocks (the last frame a BlockGroup with its duration) and Cues. Samples are temporal units + without temporal delimiters. No AV1 decoder is in the product, so the tests check the container structure, sample + counts and durations; ffmpeg / browsers play the files (checked by hand when the muxers change). + +The export runs on a worker thread with a row in the activity stack ("Exporting slideshow": slides done, or frames for +video, and the slide being rendered; ✕ or `activity.cancel {id: taskId}` stops it). Every output is written as +`<file>.part` and renamed when complete, so a cancelled, failed or interrupted export leaves nothing behind (for a JPEG +sequence, all files are renamed at the end). A photo that can't be rendered (its file gone or unreadable) is skipped +and named in the result's `warnings`; a full disk or an unwritable folder is an error. An original in the library is +never overwritten (`check_write_target` before and after). + +Encoding speed (rav1e speed 10, measured 2026-10-11 on a 32-thread Linux machine shared with other work): one minute of +slideshow (15 slides of 4 s with 1 s fades, 30 fps: 435 encoded frames) took 35 s at 720p and 74 s at 1080p, slide +rendering included. The cost is in the fades (one encoded frame per frame of fade); held slides cost one frame each. + +## Not yet + +The Slideshow module view (left-panel template browser, slide editor with draggable guides and overlays), music and +sync to music, pan & zoom, H.264 video, saving a slideshow with an album, export presets. diff --git a/docs/sony-ilce7rm6-lens-corrections.md b/docs/sony-ilce7rm6-lens-corrections.md new file mode 100644 index 000000000..d59723ac4 --- /dev/null +++ b/docs/sony-ilce7rm6-lens-corrections.md @@ -0,0 +1,314 @@ +# Sony ARW lens corrections + +Sony cameras record three lens-correction tables in the raw IFD of every ARW: distortion, vignetting and lateral +chromatic aberration (CA). LightCraft reads them and turns distortion into a DNG `WarpRectilinear` opcode, so it +reaches the same **Optics ▸ Enable Lens Corrections** (`optics.lens_profile`) as a DNG's embedded warp and Panasonic's +RW2 distortion data. Lateral CA is parsed and converts to the warp's red and blue planes, but it is **off by default**: +applied after demosaic it made the render's CA worse (Verification). Vignetting is read but not applied (below). The +conversion is verified on the **ILCE-7RM6** only; the ILCE-7RM4A has its own conversion, with other conventions +([Two conventions](#two-conventions)), and other Sony bodies' tables give no correction until they are verified. + +The correction is on by default for new imports, as for any file with embedded lens data (`Photo::camera_defaults`). +Photos imported before this change keep their settings: **Photo → Reload from Disk** adds the lens data and, when the +photo is still at its import settings, turns the correction on as one undoable step; the automatic reload after an +external editor saves the file does the same. Edited photos keep their settings. The Profile Distortion slider +scales the warp as for DNG and RW2. + +## The tables + +ExifTool's names (EXIF table, `SubIFD` group); each table is a count-prefixed array of signed 16-bit values +(`SSHORT`, 17 / 17 / 33 values), and each has an in-camera on/off tag: + +| Table | Tag | Flag tag | Count (full frame / APS-C crop mode) | Flag values seen | +|---|---|---|---|---| +| Distortion (`DistortionCorrParams`) | `0x7037` | `0x7036` | 16 / 11 | 0 (off) or 1 (on) | +| Vignetting (`VignettingCorrParams`) | `0x7032` | `0x7031` | 16 / 11 | 2 on all 54 photos | +| Lateral CA (`ChromaticAberrationCorrParams`) | `0x7035` | `0x7034` | 32 / 22 (red, then blue) | 1 on all 54 photos | + +The first value is the knot count; only the first `count` values are used. Full-frame files carry 16 / 16 / 32 knots; +APS-C crop-mode files 11 / 11 / 22, padded to the same array length (distortion with its last value repeated, +vignetting and CA with zeros). The CA table holds red's `count / 2` knots, then blue's. The corrections are built +whatever the flags say: the flag only records what the camera did to its own JPEG. Tables with a count outside +2–16 (CA: an odd count or outside 4–32), a count that doesn't fit the array, or non-integer values are ignored. + +## Measured conventions + +None of this is documented; it was measured against the camera's own JPEGs (method below). Residuals are in px at a +1440 px long edge. + +- **Knots and centre.** Knot `i` sits at sensor radius `i · s` from the default-crop centre, with + `s = hypot(9984, 6656) / 30` = 399.98 px: the full-frame default crop's half diagonal divided by 15, so knot 15 is + the full-frame crop corner. The spacing is fixed on the sensor: APS-C files use the same pixel spacing (verified on + CA, rms 0.004–0.010 px against 0.011–0.014 px for `i/15` of the APS-C half diagonal; `i/10` of it, 392 px, fits as + well). Values are interpolated linearly between + knots; beyond the last knot the last segment is extended (it matters only in the outermost ~0.4 knot spacing at + the corners of a zoomed-out barrel correction). The centre is the default-crop centre: the measured translation + between our render and the camera JPEG has median 0.013 px, max 0.216 px. +- **Distortion** is relative and indexed at the corrected radius, in units of 1/15000: a corrected radius `r` + reads the source radius `S(r) = r · (1 + v(r) / 15000)` (positive values: pincushion; negative: barrel). The camera + then zooms so the output fills the default crop: output radius = `z` · corrected radius, with `z` chosen so that + the largest `S(ρ / z) / ρ` over the crop's border radii `ρ` is 1 (binding at the corner for pincushion, at the + short-edge midpoint for barrel). `z` was 0.971–1.035 on these photos. Without the zoom every hypothesis fails + (24.6 px); with it, the worst residual over the 34 measurable corrected photos was 0.596 px (rms 0.143; corners and + edge midpoints 0.570 px), within the 0.75 px budget for a unit between about 14700 and 15350. A global + least-squares fit gives 15000.7. +- **Lateral CA** is relative to the green source radius, at that radius: red's source radius is + `g · (1 + c_R(g) / 3.36·10⁶)`, with `g` the green source radius (after the distortion correction above) and `c_R` + the red half on the same knots; blue likewise with the second half. Positive values: that colour's image is larger + than green's. **The unit is provisional.** The measurement is borderline: on the 15 photos with CA above 0.15 px + that a single unit explains, the best (minimax) unit 2.26·10⁶ leaves 0.305 px against a 0.3 px budget; per-photo + best units scatter from 1.5·10⁶ to 7.2·10⁶ (about ±30 % scene bias: the RawDB landscape and portrait shots of one + scene, with nearly identical tables, measure 3.4–3.5·10⁶ and 4.5·10⁶). The chosen 3.36·10⁶ is the median of the 20 + cleanest photos: worst 0.399 px on the 15, and the rms raw CA over all 46 measured photos falls from 0.079 to + 0.052 px. Swapping the halves or an absolute form (`v · norm / U`) is clearly worse. **Not applied by default** + (Verification). +- **Vignetting: not applied.** The camera JPEGs show no vignetting compensation relative to our uncorrected render: + over the 39 photos whose measurement reaches the corners, the corner/centre ratio JPEG / render has median 0.996 + (p10 0.907, p90 1.087), while the tables would predict a corner gain with median +32 % for `1 + v / 2¹⁴` (+16 % + for `v / 2¹⁵`, +63 % for `v / 2¹³`). Regressing the measured corner EV on the predicted one gives slope 0.009 ± + 0.063. No model (`1 + v/U`, `U/(U − v)`, `2^(v/U)`) at any unit from 2¹³ to 2¹⁸ reaches 3 %; the identity is the + best of all. The raw data isn't pre-compensated either: the read noise of three dark high-ISO frames is flat from + centre to corner, where a digital gain from these tables would raise it by 60–80 %. So the table probably describes + the lens's natural falloff, but this camera's JPEGs don't apply it, and nothing shows its unit or model. Options: + (a) skip it, so renders match the camera JPEG (the current ruling); (b) apply it with an unverified model for a + Lightroom-like look; (c) shoot flat fields (a white wall at several apertures, one lens) and measure the model. + +## Conversion to `WarpRectilinear` + +`crates/raw/src/vendor/arw_lens_rm6.rs` builds one `WarpRectilinear` in `OpcodeList3`, centred on the default-crop centre +(DNG pixel-index units relative to the active area; radius 1 = the farthest active-area corner, `m`): + +1. `S` from the distortion table and the zoom `z` (bisection on [0.5, 2], 4097 border radii); the green map is + `G(r) = S(r / z)`. Red and blue, when the CA planes are on: `G(r) · (1 + c(G(r)) / 3.36·10⁶)`. +2. Each map is sampled at `u = i / 64`, i = 0…64 (65 samples of `G(u · m) / m`) and fitted by least squares with + DNG's 4-term odd polynomial `u · (k0 + k1 u² + k2 u⁴ + k3 u⁶)` (`vendor::fit_radial_warp`, shared with RW2). +3. **Fit gate:** a plane is refused when its fit is worse than 0.25 px at a 1440 px long edge at a sample the crop + shows: the samples up to the first one at or beyond the crop corner. The lossless files' padded active area + (10240 × 7168 for a 9984 × 6656 crop) reaches radii that are never shown (`u` up to 0.925 is shown there). + Over all 54 photos the fit's worst error at all 65 samples was 0.220 px (a lossless file; ARW6 files at most + 0.175 px). Between samples the error is somewhat larger: 0.071 against 0.049 px for the full-frame 16–35 mm + example in the tests. +4. **By default one plane, G**, applied to all three channels: `lens_opcodes` calls `warp_opcode(…, lateral_ca: + false)`. With `lateral_ca: true` (a code change) the planes are `[R, G, B]`, unless the CA table is absent or + malformed, a CA value is implausible (|c| above 2·10⁻³ of the radius, i.e. 6720; the largest value in the 54 + photos' tables is 1724) or a colour plane fails the gate: then G alone again. The G plane is the same either way. + No opcode when the distortion table is absent or all zero (CA alone is never applied: it is relative to green), + the zoom has no solution in [0.5, 2] or the G fit fails. A refused table leaves the photo uncorrected rather than + wrongly warped. + +The opcode then goes through the same pipeline stage as a DNG's (`crates/pipeline/src/optics.rs`): it rotates with +the photo (portrait files are covered) and is carried by DNG export, as RW2's is. + +## Which bodies get it + +Only bodies listed in `FULL_FRAME_CROP` (`arw_lens_rm6.rs`) get an opcode: today `ILCE-7RM6` (9984 × 6656). The unit and +the knot spacing are verified on that body alone; the spacing is defined by its full-frame crop, and a body with +another sensor size or another unit would be warped wrongly. Other bodies get no correction from this conversion; the +ILCE-7RM4A gets its own, with other conventions (below). To add a body: collect photos of it shot with distortion +correction on (several lenses and focal lengths), repeat the camera-JPEG registration below against the uncorrected +render, confirm the unit, the knot spacing and the zoom rule (or measure the body's own), then add its full-frame +default crop to the table with a test. + +## Two conventions + +A second Sony decoder, `crates/raw/src/vendor/arw_lens.rs` +([Sony embedded distortion corrections](sony-lens-corrections.md), PR #498), reads the same `0x7037` table on the +**ILCE-7RM4A**, validated against Sony Imaging Edge exports (distortion On/Off pairs at 200 and 600 mm with the +FE 200–600 mm; corrected TIFFs at 24 mm with the FE 24–105 mm). Its conventions differ from the ones above +(`r_o` = output radius about the crop centre, `v` = the table, linear between knots): + +| | ILCE-7RM6 (this page, `arw_lens_rm6.rs`) | ILCE-7RM4A (`arw_lens.rs`) | +|---|---|---| +| Knots | `i · 399.98` px, fixed on the sensor; last segment extended | `i / 15` of the crop's half diagonal; radius clamped to 1 | +| Table indexed at | the corrected radius `r_o / z` | the output radius `r_o` | +| Unit | 15000 | 16384 | +| Zoom | fill: the largest `S(ρ / z) / ρ` over the border is 1 | `1 / max f` over the border radii | +| Aspect crops | applied; the zoom for the recorded crop is unverified | refused (native 3:2 only) | +| Tables | distortion (16 knots; 11 in APS-C); lateral CA parsed, off by default; vignetting read, not applied | distortion, 16 entries only | + +`arw::decode` passes both the same active area and crop (the DNG-style default crop, on the ILCE-7RM6 the same as +Sony's crop tags); their model lists don't overlap, so a photo gets at most one of them: an ILCE-7RM6 this page's +conversion, an ILCE-7RM4A the other, every other body neither. + +**Cross-check.** The ILCE-7RM4A formula, scored on the 34 measurable corrected ILCE-7RM6 photos exactly as in +Measured conventions (radial bins, px at 1440, max / rms; "c" = the worst corner or edge-midpoint point): + +| Variant | Unit | All photos | Corners max / median | 24–70 GM II (23) | 100 mm Macro (8) | 200–600 G (3) | +|---|---|---|---|---|---|---| +| **A**: this page | 15000 | **0.596 / 0.143** | 0.570 / 0.099 | 0.596 / 0.100, c 0.474 | 0.361 / 0.135, c 0.336 | 0.588 / 0.325, c 0.570 | +| **B1**: the ILCE-7RM4A formula | 16384 | **1.429 / 0.319** | 1.913 / 0.204 | 1.429 / 0.385, c 1.913 | 0.289 / 0.049, c 0.234 | 0.264 / 0.115, c 0.320 | +| **B2**: B1 at its best single unit | 15680 | 0.940 / 0.223 | 1.202 / 0.098 | 0.940 / 0.215, c 1.202 | 0.327 / 0.100, c 0.278 | 0.683 / 0.428, c 0.637 | +| **B3**: A's indexing and zoom at 16384 | 16384 | 1.450 / 0.455 | 1.845 / 0.350 | 1.450 / 0.539, c 1.845 | 0.284 / 0.062, c 0.238 | 0.654 / 0.329, c 0.586 | + +- B1 misses the 0.75 px budget on these photos, but only on the 24–70 GM II: at 24–28 mm it under-corrects the barrel + in the outer half (up to 1.43 px at 0.7–0.85 of the half diagonal), at 45–70 mm the pincushion by a nearly uniform + 0.2–0.4 px. On the 100 mm and the 200–600 it fits better than A, and on the 200–600, the one lens both validations + share, its unit agrees with this body's (best unit under its indexing 16524–16602). So the data does not show the + two bodies differing. +- The zoom rules agree (within 0.0035 on every photo); the unit and the table index make the difference. Under A's + indexing the 24–70 GM II's barrel and pincushion ends want the same unit (14749 / 14860, 0.7 % apart); under B's + they need 14792 / 15705 (6 % apart). +- APS-C: B refuses 11-entry tables. Its knots read literally on the APS-C crop (`i / 15` of its half diagonal, + 261.5 px) fit the APS-C RawDB files' lateral CA 4 × worse on 3 of 6 photos than the sensor-fixed 400 px; `i / 10` + (392 px) can't be told from 400 px. + +**Open question: no single unit fits every lens under either convention.** The 200–600 wants a 5–7 % larger unit (a +weaker correction) than the 24–70 GM II's pincushion end with similar table values (+409 … +561 against +406 … +454 +at the corner), under both indexings and every knot spacing tried (0.94–1.12 ×: the ratio of the two lenses' best +units stays 1.053–1.073). A's 15000 is the compromise that keeps every photo within 0.6 px; 16384 is the 200–600's +value. That rests on 3 measurable 200–600 photos (594–600 mm). The ILCE-7RM4A's own wide-angle evidence is its +weakest: its On/Off pairs are all 200–600, and its 24 mm check (LightCraft export against Sony's TIFF) is median 0.35 / +95th percentile 1.04 px at 2376 px. On its `WIDE` unit-test table the two formulas differ by up to 2.49 px at 1440 at +the corner (computed from the formulas, not measured). + +**What would settle it:** Sony Imaging Edge On/Off exports of the 24–70 GM II (or any wide zoom's wide end) on either +body, which would show whether the wide-end disagreement is the body or the formula; and more lenses, and more +200–600 photos, on the ILCE-7RM6, to tell a per-lens unit from a convention. Until then each body keeps the +conventions it was validated with. + +## Method + +Photos: 54 ILCE-7RM6 ARWs: 42 private, and the 12 CC0 samples of the +[dnglab RawDB ILCE-7RM6 set](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6) (`ILCE-7RM6_{FF,APSC}_{land,port}_RAW{,-C,-C-HD}.ARW`: +full frame and APS-C, landscape and portrait, lossless, Compressed and Compressed HQ). `cargo xtask corpus --download` +fetches three of them: the APS-C lossless file as `arw-sony-ilce7rm6-apsc-land-lossless.arw`, and the ARW 6.0 +decoder's full-frame Compressed and APS-C Compressed HQ files (`arw6-sony-ilce7rm6-ff-land-c.arw`, +`arw6-sony-ilce7rm6-apsc-land-hq.arw`). The corpus test `corpus_sony_lens_tables_become_opcodes` checks all three, and +the full-frame lossless file where it is present. +Four lenses: FE 24–70 mm F2.8 GM II (27 photos), FE 16–35 mm F2.8 GM II (12, all RawDB), FE 100 mm F2.8 Macro GM OSS +(9), FE 200–600 mm F5.6–6.3 G OSS (6). 41 photos have in-camera distortion correction on (all private); 13 have it +off (the 12 RawDB files and one private photo). Only aggregate numbers per lens are reported. The scripts are local +(the gitignored `plan/arw6/lens/`), not in the repository; the steps are described so they can be redone. + +- **Oracle:** the camera JPEG inside each ARW (`JpgFromRaw`, 9984 × 6656 / 6528 × 4352, stored unrotated, turned to + the photo's orientation). Tag values were read with ExifTool used as a tool. Clean room: no lens-correction code of + any other project and no Adobe data was read. +- **Distortion (camera-JPEG block registration).** Our render at half the JPEG size and the JPEG (2 × 2 box-reduced) + are both reduced to a quarter of the JPEG size; on the green channel, every 40 px a 64 px JPEG block is located in + the render by normalised cross-correlation with parabolic sub-pixel refinement. Pass 1 searches coarse-to-fine + (±28 px at 1/16 size, then ±6 px); pass 2 (used for every number) searches ±6 px around pass 1's smooth radial fit + (data-derived, needed for a macro of a periodic screen). Blocks with low contrast (std < 2), NCC < 0.8 or a + tangential displacement above 4 px are dropped. A robust fit of `p_render = g(r) · p_jpeg + t` (`g` an even + 4-term polynomial) gives the translation `t`; the measured radius ratio is the median of 32 radial bins (≥ 10 + blocks each). A photo is measurable with ≥ 300 blocks and ≥ 15 bins reaching 0.7 of the half diagonal: 34 of the + 41 corrected photos (two black frames, a near-black night frame, two low-detail frames and two whose blocks reach + only 0.54 of the half diagonal are not). 108 hypotheses (radius norm, knot placement, relative or absolute, table + indexed at the corrected or the source radius, zoom rule), each with its best unit, were compared. +- **Lateral CA (raw CFA).** The demosaiced render shows only 25–60 % of the lens's CA and the JPEG's 4:2:2 chroma + and noise reduction hide it, so CA was measured on the raw: from LightCraft's uncompressed CFA DNG export, the + red (blue) plane against green interpolated at the same sites (4-neighbour mean), square-root encoded, 2 × 2 + binned; 40 px blocks every 24 px, ±3 px NCC search, radial component, median per 16 radial bins. +- **Vignetting (exposure brackets).** The JPEG is resampled into the render's geometry; per pixel and channel, the + exposure at which eight renders bracketed −0.3 … +1.8 EV equal the JPEG value is interpolated (smooth areas, JPEG + values 12–240, monotone brackets). That cancels the fitted tone curve, since exposure is a scene-linear gain before + it. Medians in 16 annuli give the JPEG's falloff relative to our uncorrected render. + +## Verification + +Every photo rendered with Enable Lens Corrections on and off at half the JPEG size (50 of the 54 are Compressed or +Compressed HQ ARW 6.0 files, so the renders used a local build of this code merged with the ARW6 decoder), then the same block +registration (passes 1 and 2) against the camera JPEG, and R and B against G inside each render (80 px blocks every +128 full-resolution px, ±4 px search; per corner the median radial shift of the 12 nearest blocks). px at a 1440 px +long edge; aggregates only. + +**Distortion, photos corrected in camera** (our render with the correction on vs the camera JPEG; budget 0.75 px). +"Corners + midpoints" is the 2-D displacement at the four corners and four edge midpoints, global translation +included; "bins" the radial residual over 32 radial bins. A photo counts as measurable as in the method above. + +| Lens | Photos (measurable) | Focal lengths | Corners + midpoints max / rms | Bins max / rms | Uncorrected, corners (per-photo max) | +|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 26 (23) | 24–70 mm | 0.553 / 0.125 | 0.503 / 0.066 | 1.1–17.3 | +| FE 100 mm F2.8 Macro GM OSS | 9 (7) | 100 mm | 0.409 / 0.111 | 0.317 / 0.097 | 0.6–4.8 | +| FE 200–600 mm F5.6–6.3 G OSS | 6 (3) | 594–600 mm | 0.679 / 0.321 | 0.498 / 0.274 | 7.5–10.8 | +| **All** | **41 (33)** | | **0.679** / 0.150 (206 points) | 0.503 / 0.109 | 0.6–17.3 | + +No photo is over budget; the global translation is at most 0.056 px. The 8 not measurable are black, near-black or +low-detail frames, or frames whose blocks don't reach 0.7 of the half diagonal. That is one more than in the +measurement (33 measurable against 34): one 100 mm photo keeps only 255 of the 300 blocks needed when it is matched on +the corrected render, as block coverage differs slightly between the two renders. The 200–600 mm keeps the systematic +residual the measurement found for it (its own best unit is 15659, see Limits). + +**Controls, correction off in camera** (13 photos: the 12 RawDB files, full frame and APS-C, and one private 24–70 mm +at 58 mm). Their JPEG keeps the lens's distortion, so our corrected render must differ from it by the model's own shift: +it does, by up to 10.5 px (full frame) and 11.4 px (APS-C), and matches that shift within 0.639 px at the corners and +midpoints (bins 0.259 px; full-frame RawDB files ≤ 0.218 px, APS-C ≤ 0.639 px). This checks that the pipeline renders +the model (also for APS-C, barrel, zoom 0.984), not that the APS-C model is right. + +**Portrait photos:** 8 corrected (orientation 6 and 8) within 0.232 px, 6 controls within 0.369 px of the model's +shift: the warp turns with the photo. + +**Lateral CA in the render** (radial R-vs-G and B-vs-G shift, median of the 12 blocks nearest each corner; 43 photos +with enough detail at all four corners; budget 0.3 px): + +| Lens | Photos | Off: max R / B | Off: rms | On: max R / B | On: rms | Camera JPEG: max / rms | +|---|---|---|---|---|---|---| +| FE 24–70 mm F2.8 GM II | 23 | 0.089 / 0.071 | 0.028 | 0.219 / 0.148 | 0.069 | 0.150 / 0.036 | +| FE 16–35 mm F2.8 GM II | 12 | 0.125 / 0.054 | 0.041 | 0.030 / 0.051 | 0.020 | 0.058 / 0.016 | +| FE 100 mm F2.8 Macro GM OSS | 6 | 0.020 / 0.050 | 0.013 | 0.061 / 0.157 | 0.044 | 0.021 / 0.004 | +| FE 200–600 mm F5.6–6.3 G OSS | 2 | 0.033 / 0.101 | 0.032 | 0.146 / 0.075 | 0.070 | 0.113 / 0.039 | +| **All** | **43** | 0.125 / 0.101 | 0.032 | **0.219** / 0.157 | 0.052 | 0.150 / 0.028 | + +Within budget everywhere, but **the correction makes the render's own CA larger on average** (rms 0.032 → 0.052 px; +smaller only for the 16–35 mm). The reason is where it runs: over the radial bins beyond 0.3 of the half diagonal +(418 bins), our demosaiced render shows only 0.29 × the lens CA measured on the raw CFA (correlation 0.85), while the +warp after demosaic moves R and B by 0.84 × the table (correlation 0.96). On the raw data the table halves the CA (rms +0.062 → 0.035 px); applied after a demosaic that already hid most of it, it overshoots (rms 0.021 → 0.035 px over the +same bins). All of this is far below one pixel at 1440 px. + +**Ruling: the lateral CA planes are off by default.** `lens_opcodes` emits the G plane alone; the conversion stays in +the code and its unit tests (`warp_opcode(…, lateral_ca: true)`), so it can be turned on once CA is corrected where +the table applies. **Follow-up:** warp the raw CFA's red and blue sites before demosaic (the table halves the raw CA, +rms 0.062 → 0.035 px), then turn the planes on and repeat this check. Optics ▸ Remove Chromatic Aberration +(`optics.remove_ca`, an automatic estimate from the image) remains available for visible fringes. The distortion +numbers above were measured with the three-plane warp, whose G plane is the one the default emits. + +**Fit gate margins** (worst of R, G and B at the gated samples; gate 0.25 px): the 4 lossless files 0.160 px (margin +0.090; 0.220 px at all 65 samples, the padded radii never shown), the 46 full-frame ARW6 files 0.175 px (margin 0.075), +the 4 APS-C ARW6 files 0.035 px. The largest CA value in the 54 tables is 1724 (FE 24–70 mm), a quarter of the 6720 +bound. So every photo passes: with the CA planes on (the verification renders) each got the three-plane warp, and the +corrected and uncorrected renders differ on all 54; by default each gets the one-plane distortion warp. + +## Performance + +`LIGHTCRAFT_PROFILE=1` renders of the full-frame lossless file and the full-frame Compressed HQ file (corpus +`arw-sony-ilce7rm6-ff-land-lossless.arw`, `arw6-sony-ilce7rm6-ff-land-hq.arw`), on a 4-core laptop shared with other +builds (so absolute times are high); the fastest of 3–5 runs. The warp runs in the `sample` stage: + +| Render | Off | One plane (the default; measured with manual Distortion 1) | Three planes (CA planes on) | +|---|---|---|---| +| Full size 9984 × 6656, lossless | 267 ms | — | 3098 ms | +| Full size 9984 × 6656, Compressed HQ | 403 ms | 1187 ms | 4014 ms | +| 1920 px long edge (2496 × 1664 source), Compressed HQ | 23.7 ms | 45.4 ms | 112.0 ms | + +The default one-plane warp takes the sample stage from 24 to 45 ms at 1920 px (inferred from the manual Distortion +run: the same single-plane path, one plane for all three channels). The three colour planes cost about 2.5–3× that, +as for an RW2 or DNG with three planes (the same path): the price of turning the CA planes on. The stage cache keys +the resampled source by the source, framing and output size, so tone, colour and exposure drags don't re-run it. + +## Limits + +- **ILCE-7RM6 only.** Other Sony bodies record the same tags; their knot spacing (defined here by the full-frame crop), + unit and zoom rule are unverified, so they get no correction from this conversion (the ILCE-7RM4A's distortion has + its own: [Two conventions](#two-conventions)). +- **APS-C distortion is unverified in camera:** no APS-C photo with distortion correction on was available; the APS-C + knot spacing is verified on CA only, and the APS-C zoom follows the full-frame rule. +- **Aspect-ratio crops** (in-camera 4:3, 16:9, 1:1) were not measured; the correction uses the crop the file records. +- **One distortion unit for every lens.** Fitted per lens the unit is 14826 (24–70 mm, 23 photos, very consistent), + 15659 (200–600 mm, 3 photos) and 16017 (100 mm, tiny tables); with the single 15000 the 200–600 mm keeps a systematic + residual of up to 0.59 px (within budget, above the method's 0.03 px noise floor). Something lens-dependent is not + captured; per-lens units would need per-lens verification. +- **Lateral CA off by default.** Its unit is provisional (above), and a warp after demosaic, as for RW2 and DNG, + overshoots: our demosaic already hides about 70 % of the lens CA, and with the planes on the rms corner CA rose + from 0.032 to 0.052 px (Verification). Correcting CA before demosaic is the follow-up. +- **Vignetting not applied** (above). +- **DNG export** carries the warp in `OpcodeList3`, as for RW2: a DNG reader applies it like any embedded lens + correction. +- **Existing imports** need Photo → Reload from Disk to pick up the lens data. +- The camera-JPEG colour fit still matches its pairs without the correction (docs/camera-preview-colour.md). + +## Sources + +- [ExifTool EXIF tag names](https://exiftool.org/TagNames/EXIF.html): the names and types of tags `0x7031`–`0x7037` + (ExifTool 13.59 was used to read the tag values). Their meaning, units and conventions were measured as above. +- [dnglab RawDB, Sony ILCE-7RM6](https://rawdb.dnglab.org/sets/Sony/ILCE-7RM6): the 12 CC0 samples (data only; + dnglab's code was not consulted). diff --git a/docs/sony-lens-corrections.md b/docs/sony-lens-corrections.md index 375f773ab..ed32fc85d 100644 --- a/docs/sony-lens-corrections.md +++ b/docs/sony-lens-corrections.md @@ -1,25 +1,143 @@ -# Sony embedded distortion corrections +# Sony embedded lens corrections -LightCraft reads the signed 16-sample distortion table in the raw image IFD (`0x7037`) of -Sony **ILCE-7RM4A (A7R IVA)** Bayer ARWs. Other camera models are deliberately left uncorrected, -even when they contain a table with the same layout. -It converts that table into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction control, -CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. +The **ILCE-7RM6** (A7R VI) has its own conversion, with other conventions measured against its camera JPEGs: +[Sony ARW lens corrections](sony-ilce7rm6-lens-corrections.md). On ILCE-7RM6 photos the formula below misses the 24–70 GM II by +up to 1.43 px at 1440 px while fitting the 100 mm and 200–600 better, and no single unit fits every lens under either +convention; that page's *Two conventions* section has the comparison and what would settle it. -This adds **distortion only**. Sony vignetting and lateral chromatic-aberration tables are not decoded. -The enabled and independently validated model is ILCE-7RM4A with the FE 24–105mm F4 G OSS and -FE 200–600mm F5.6–6.3 G OSS. Linear YCbCr ARWs and non-3:2 camera crops are excluded pending validation. An aspect crop may -retain the full-frame radial normalization; the decoder does not guess that relationship. Older files that carry -only encrypted correction metadata, different table lengths, and rejected tables remain uncorrected. -The camera's distortion Off setting does not erase its table. Newly imported photos use LightCraft's existing -embedded-profile default (enabled); the profile correction control can disable or adjust it. +Sony ARWs record three lens corrections per shot in the raw image IFD: shading (vignetting), lateral chromatic +aberration (CA) and distortion, each as a camera setting and a radial table, with copies in the encrypted +`SR2SubIFD`. LightCraft **decodes all three** wherever a file carries them and **applies one**: the distortion table +of the **ILCE-7RM4A (A7R IVA)** in Bayer ARWs with native 3:2 framing, independently validated with the +FE 24–105mm F4 G OSS and FE 200–600mm F5.6–6.3 G OSS (the ILCE-7RM6's distortion is applied too, by its own +conversion above). Every other model, +and shading and CA on every model, are decoded only: no warp, vignetting or CA correction is added for them. The decoded record stays with the image +(`RawInfo::lens_metadata` / `RawImage::lens_metadata`, types in `crates/raw/src/lens_metadata.rs`, decoder in +`crates/raw/src/vendor/arw_lens.rs`) for diagnostics and for the validation work described below. + +The applied distortion is converted into `OpcodeList3` / `WarpRectilinear`, so the existing profile correction +control, CPU/GPU optics path, EXIF orientation handling, and DNG export use the same correction. Linear YCbCr ARWs +and non-3:2 camera crops are excluded pending validation. An aspect crop may retain the full-frame radial +normalization; the decoder does not guess that relationship. Tables of other lengths and rejected tables remain +uncorrected, and a table found only in the encrypted `SR2SubIFD` is never applied. The camera's distortion Off +setting does not erase its table. Newly imported photos use LightCraft's existing embedded-profile default +(enabled); the profile correction control can disable or adjust it. + +## What is decoded + +| Component | Raw-IFD setting / table | `SR2SubIFD` setting / table | Accepted layout | +|---|---|---|---| +| Shading | `0x7031` / `0x7032` | `0x797c` / `0x797d` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | +| Lateral CA | `0x7034` / `0x7035` | `0x797f` / `0x7980` | SHORT\[1\]; SSHORT\[33\]: 32, then two 16-sample curves | +| Distortion | `0x7036` / `0x7037` | `0x7981` / `0x7982` | SHORT\[1\]; SSHORT\[17\]: 16, then 16 samples | + +- **Bounded, exact reading.** A raw-IFD field is the one the TIFF reader parsed, the copy the image itself uses: + its first occurrence, within the reader's limits (8,192 entries per directory, a total value budget). The directory + is also scanned, in either byte order and in BigTIFF, for diagnostics only: a tag that occurs more than once is + flagged (`raw_ifd_repeated`, `sr2_repeated`), and an entry the reader left out is reported with its reason (values + outside the file, an unknown type, or past the reader's limits) rather than treated as absent. `SR2SubIFD` fields + are read straight from their first entry. A setting may also be a nonnegative SSHORT (the documented editor type + differs from the type the cameras store). Anything else is refused with its reason (`SonyFieldError`: field type, + count, leading sample count, values outside the file or block, past the reader's limits, a negative setting). + Nothing is allocated from a count in the file. +- **Raw IFD first, `SR2SubIFD` as a checked fallback.** The raw IFD's copy wins. The `SR2SubIFD`'s copy is used only + when the raw IFD lacks the tag; a malformed raw-IFD copy is reported, never silently replaced. When both copies are + well-formed their agreement is recorded (`copies_agree`), and on disagreement the raw IFD's copy is used. The block + is decrypted (by the existing `vendor/sr2.rs`) for every ARW that has one, so the check always runs; a block that + can't be found or decrypted gives no copies. Locating it selects the `SR2Private` directory's offset, length and + key exactly as the TIFF reader would (the reader's own directory layout, 8,192-entry limit, readable-entry rules + and value budget, then the first readable occurrence of each), but decodes only each field's first value, so the + directory's other entries cost no memory however large they claim to be; the block itself is limited to 1 MiB. The + plain tag numbers are not looked up inside the `SR2SubIFD`. +- **Settings keep their number and a documented meaning:** shading 256 Off, 257 and 272 Auto, 511 unavailable; CA + 0 Off, 1 Auto, 255 unavailable; distortion 0 Off, 1 Auto, 17 Auto fixed by the lens, 255 unavailable. Any other value + is `Unknown(value)`, never Off (the A7CR's Low shading setting has no documented number). Values are not read as + bit masks. Off does not mean absent: the table is still recorded. +- **Only the 16-sample (CA: 2 × 16) layout is interpreted.** Many bodies store 11 (CA: 22) samples padded into the + same 17 (33) values: older compacts and bodies such as the DSC-RX100 III and ILCE-3500, and the ILCE-7M4's APS-C + captures (its full-frame captures carry 16). Those tables are refused as `CountPrefix(11)` and stay + uninterpreted. An all-zero table is kept: it is a valid no-op, not a sign of an unsupported lens. +- **Framing evidence travels with the tables:** the stored frame (padding included), Sony's crop tags and the DNG + default crop as recorded, the image crop, the reference rectangle the distortion conversion is centred on and + normalized to (the default crop when present, else the image crop), compression, whether the data is linear + YCbCr (lossless M/S), and whether both crops are native 3:2. +- **Per-component status.** `Applied` (converted into an opcode), `NoTable`, `NotValidatedComponent` (shading and CA + on every body), `NotValidatedModel`, `NotValidatedMode` (linear YCbCr, non-3:2 framing, or a distortion table found + only in the `SR2SubIFD`), `Neutral` (all-zero table in the validated scope) and `Rejected` (the validated + conversion refused the table). A readable table never implies a usable correction; only `Applied` components + become opcodes. + +The applied path is unchanged: the validated conversion still receives the raw IFD's `0x7037` table exactly as the +TIFF reader parsed it, with the same model, framing and fit checks, and produces the same warp. A repeated tag keeps +the reader's choice and is only flagged, and a table the reader never reads (past its entry limit) is reported but +never applied, so already-catalogued photos, their previews and their stored lens data stay consistent with a fresh +decode. + +## What each component still needs + +The following summarizes an investigation on the ILCE-7CR (A7CR): an owner's collection of 131 raws with four +lenses (Voigtländer APO-LANTHAR 28mm F2 and 50mm F2, both manual focus; Sony FE 24–50mm F2.8 G; Tamron 35–150mm +F2–2.8), plus the three public raw.pixls.us A7CR samples. No private photographs, names or derived images are +part of this repository. + +- **Inventory.** All 131 raws carry all three tables in both the raw IFD and the `SR2SubIFD`, and the copies agree + in every file; all four lenses provide tables, the manual-focus ones included. Shading is 272 (Auto) and CA 1 + (Auto) everywhere; distortion is 17 (Auto, fixed by the lens) on the nine FE 24–50 files and 0 (Off) on the rest. + Every image crop is 9504 × 6336 at (32, 20), in uncompressed, compressed and lossless-L (padded to 9728 × 6656) + storage. The decoder reproduces this inventory exactly (see *Local inventory check*). +- **Distortion.** The table layout and crop tags match the A7R IVA's, but its interpretation applied unchanged does + not reproduce the A7CR camera JPEG at 24 mm: at 2376 px wide, median / 95th-percentile feature residuals were + 1.61 / 6.17 px (12.98 / 51.19 px uncorrected, 6,539 matches), with a signed radial pattern (about +1.7 px at + radii 0.25–0.5, −7.4 px at 0.8–1) that no translation removes. At 50 mm it agrees (0.23 / 1.05 px, from + 2.21 / 12.25 px). Scaling the raw table by about 10/9, or using the MakerNote's distortion array instead, brings + 24 mm to about 0.37 / 1.10 and 0.33 / 0.97 px. Those are exploratory comparisons on two files against the camera + JPEG, not a model: the MakerNote values are roughly 1.11 times the raw table without one exact ratio, and the + A7R IVA was validated against a different reference renderer (Imaging Edge exports). Needed: A7CR Imaging Edge + On/Off exports at 24 and 50 mm and with another lens, compared with the raw table at its fixed scale; a decision on + the reference (camera JPEG or Imaging Edge); then ILCE-7CR added to the validated models for the full-frame Bayer + 3:2 mode only. Neither a 10/9 multiplier nor the MakerNote table is used. +- **Shading.** The layout decodes, but no public specification gives the sample-to-gain function, its radius + convention on the A7CR, or whether the raw samples already contain part of the correction. Brightness ratios on six + natural scenes show no consistent missing peripheral brightening. Needed: evenly lit flat fields with shading + Off / Auto / Low at several apertures and focal lengths, raw CFA ratios between them, and Imaging Edge On/Off + exports, to fix the gain function and processing stage and rule out double correction. +- **Lateral CA.** A public analysis reads the table as a red, then a blue curve relative to green, at a 2⁻²¹ + relative radial scale. On three distortion-Off files that candidate reduced red and blue feature residuals + modestly (for example 0.35 → 0.29 px median for red with the 28 mm), which supports investigating it but certifies + neither scale, interpolation nor processing stage. Two product changes come before it: embedded CA must be + independent of the distortion amount (today `pipeline::optics` scales the whole embedded per-plane warp with the + profile distortion control), and the red and blue planes must be resampled in camera RGB, before the camera colour + model mixes them (`engine::files::load_bytes_now` applies the colour model before the optics stage). Then + validation on full-resolution achromatic edges. +- **Other modes.** APS-C, aspect crops and lossless M/S (linear YCbCr) captures were not measured and stay + excluded. + +## Local inventory check + +`crates/raw/tests/sony_lens_inventory.rs` is opt-in and does nothing in CI. With `LIGHTCRAFT_SONY_LENS_DIR` naming a +directory of ARWs it decodes each from its headers and prints its record (settings, sources, copies' agreement, +sample ranges, statuses); `LIGHTCRAFT_SONY_LENS_FULL=1` also compares header and full decode. With +`LIGHTCRAFT_SONY_LENS_INVENTORY` naming a CSV inventory it checks every file's table counts, sizes, sample counts, +minima and maxima, raw-IFD versus `SR2SubIFD` agreement, settings (numbers and labels), stored size, compression and +crop tags. A file with a row must decode, and every file must have a row, whether it decodes or not. Any decode +error fails the check except an unsupported variant, which is counted and must equal `LIGHTCRAFT_SONY_LENS_UNSUPPORTED` +(default 0). On the 131 A7CR raws above, all 131 inventory rows reproduce and every file decodes. On 133 public Sony +ARWs (127 raw.pixls.us samples from 100 bodies, four corpus copies and two files attached to a public issue), 110 carry +a record, 14 decode without one, and 9 are unsupported variants (eight packed ARWs of older bodies and one ARW +without a CFA image); header and full decode agree on the 124 that decode, and no record has disagreeing copies. ## Clean-room evidence -The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) identifies -`0x7037` as 17 signed 16-bit integers. The first value is the count, 16, followed by radial samples. -No external photo decoder implementation or Adobe lens profile was consulted. -Sony Imaging Edge Edit 4.1 was used only as a black-box reference on local scratch copies. +The [ExifTool EXIF tag-name documentation](https://exiftool.org/TagNames/EXIF.html) names the raw-IFD tags +`0x7031` to `0x7037` (VignettingCorrection / VignettingCorrParams, ChromaticAberrationCorrection / +ChromaticAberrationCorrParams, DistortionCorrection / DistortionCorrParams) with their setting values, and gives the +tables as 17 or 33 signed 16-bit integers whose first value is the count. The +[Sony SR2SubIFD documentation](https://exiftool.org/TagNames/Sony.html#SR2SubIFD) names the encrypted copies +`0x797d`, `0x7980` and `0x7982`; the setting tags next to them (`0x797c`, `0x797f`, `0x7981`) are unnamed there and +were identified by inspecting files, where they equal the raw-IFD settings. The corpus test's expected tables are the +values ExifTool, run as a black-box tool, prints for the public samples. No external photo decoder implementation, +lens database or Adobe lens profile was consulted. Sony Imaging Edge Edit 4.1 was used only as a black-box reference +on local scratch copies. The observed inverse mapping, with radius `r` normalized to the default crop's half diagonal, is: @@ -45,7 +163,8 @@ so the warp stays on the optical centre either way. On the A7R IVA that framing pixels left of Sony's exports (eight pixels in a 2376-pixel-wide export). The main raw-IFD table is important: the similarly named MakerNote table has different values and -must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files. +must not be substituted into this formula. Main raw-IFD and SR2SubIFD values agreed in the test files, and the +`SR2SubIFD` copy is never converted. ## Validation @@ -90,7 +209,18 @@ The distortion geometry remains aligned while every body's image framing follows Tests cover signedness/length rejection, zero and malformed tables, independent 600 mm reference geometry, barrel framing, offset crop normalization, both TIFF byte orders, header/full consistency, -and preservation through DNG export. No private photographs or reference TIFFs are committed. +and preservation through DNG export. The decoder's synthetic tests (`crates/raw/src/vendor/arw_lens.rs`) add both +byte orders and BigTIFF, exact SHORT / SSHORT types, negative samples, missing, short, long, huge and zero counts, +wrong leading counts, values outside the file, a table past the TIFF reader's entry limit (not applied), repeated +tags (the reader's copy kept, as before), unknown and negative settings, raw-IFD versus +`SR2SubIFD` precedence and agreement, the correct `SR2SubIFD` tag numbers, a wrong decryption key, hostile entries +inside the decrypted block, linear YCbCr, framing evidence (Sony's crop at x = 0 against the default crop, agreeing +crops, padded storage, aspect crops, crop tags outside the frame), all eight EXIF orientations leaving the record +unchanged, and neutral, out-of-range and folding tables. On a small classic-TIFF fixture in both byte orders, every +truncation and four mutations of each byte outside the pixel data (set to 0x00 and 0xff, top and low bit flipped) +decode or fail without a panic. `tests/sr2_heap.rs` measures the peak heap of decoding a 16 MiB file whose +`SR2Private` directory holds eight fields aliasing the whole file: 37 kB, against 134 MB when those fields were +decoded. No private photographs or reference TIFFs are committed. ## Camera coverage and regression boundary @@ -99,27 +229,35 @@ model names, and every other model remain unsupported by this distortion decoder numbers and table lengths do not establish the scale, interpolation or crop normalization on another body. Support can expand after independent geometry validation; do not add model-name prefix matching. -The current `cargo xtask corpus --download` manifest contains 23 Sony CC0 samples across 17 bodies, -including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm). All 23 files have -pinned SHA-256 hashes and are checked with `sony_embedded_distortion_is_limited_to_validated_models`: +The current `cargo xtask corpus --download` manifest contains 25 Sony CC0 samples across 18 bodies, +including samples 4822 (A7R IVA / 24–105 mm) and 3989 (A9 II / 200–600 mm), each with a pinned SHA-256 that the +download checks. `sony_embedded_distortion_is_limited_to_validated_models` checks 23 of them (the two ILCE-7CM2 +codings have their own test), and `sony_lens_records_of_the_public_samples` verifies all 25 against their pins +(`SONY_SAMPLES` in `crates/raw/tests/corpus.rs`, itself anchored by a pinned digest and compared with the manifest) +before decoding. **No CI job fetches the corpus**: these fixture tests run only where it has been downloaded, and +otherwise skip each absent file. `LIGHTCRAFT_CORPUS_REQUIRED=1` makes `sony_lens_records_of_the_public_samples` +fail on any absent pinned file instead. -| Camera | Corpus samples | Raw-IFD table observed | Expected correction | +| Camera | Corpus samples | Decoded record: tables, settings (shading / CA / distortion) | Applied | |---|---|---|---| -| ILCE-7RM4A | Compressed (4822) | 16 samples | One distortion warp | -| ILCE-9M2 | Compressed (3989) | 16 samples | None | -| ILCE-7M3 | Compressed, uncompressed | 16 samples | None | -| ILCE-7M4 | 14-bit, lossless L/M/S | 11 samples in the 14-bit file; 16 in L/M/S | None | -| ILCE-7RM2 | 12-bit uncompressed | 16 samples | None | -| DSC-RX100 | One | No raw-IFD table | None | -| DSC-RX100M3 | One | 11 samples | None | -| ILCE-7CR | Compressed, lossless L/M | Not used; model excluded | None | -| ILCE-7RM4 | 14-bit compressed | Not used; model excluded | None | -| DSLR-A500, DSLR-A700, SLT-A33 | One each | Not used; model excluded | None | -| ILCE-3500, ILCE-7S | 5600 K, Shade respectively | Not used; model excluded | None | -| ILCE-7SM2, DSLR-A580 | 16:9 framing, one each | Not used; model excluded | None | -| DSLR-A900 | Packed 12-bit | Not used; model excluded | None | +| ILCE-7RM4A | Compressed (4822) | All three tables; 257 / 1 / 17 | One distortion warp | +| ILCE-9M2 | Compressed (3989) | All three; 257 / 1 / 0 | None | +| ILCE-7M3 | Compressed, uncompressed | All three; 257 / 1 / 0 | None | +| ILCE-7M4 | 14-bit, lossless L/M/S | L/M/S: all three, 272 / 1 / 17; the 14-bit file (APS-C framing, 4608 × 3072): settings only, its 11-sample tables are not interpreted | None | +| ILCE-7RM2 | 12-bit uncompressed | All three; 256 / 0 / 1 | None | +| DSC-RX100 | One | Shading, from the `SR2SubIFD` (the raw IFD has no tables); CA and distortion in the 11-sample layout | None | +| DSC-RX100M3 | One | Shading; CA and distortion in the 11-sample layout | None | +| ILCE-7CR | Compressed, lossless L/M | All three; 272 / 1 / 0 | None | +| ILCE-7RM4 | 14-bit compressed | All three; 257 / 1 / 17 | None | +| ILCE-3500 | 5600 K | Settings only (unavailable) | None | +| ILCE-7S | Shade | An all-zero CA table; settings unavailable | None | +| ILCE-7SM2 | 16:9 framing | All-zero shading and CA tables; settings unavailable | None | +| DSLR-A500, DSLR-A700, SLT-A33, DSLR-A580, DSLR-A900 | One each | No record | None | -These are **model-boundary regression checks, not new geometric validation of those models**. In particular, +These are **model-boundary regression checks, not new geometric validation of those models**. +`sony_lens_records_of_the_public_samples` also checks the exact tables, settings, provenance and framing evidence of +the three A7CR codings and the A7R IVA sample, and that across the Sony corpus only the A7R IVA's distortion is ever +applied. In particular, refusing the A7 III tables resolves the unverified corner warp identified in review. The lossless M/S files are linear YCbCr and remain excluded independently of the model check. Header and full decode must agree for every sample. Synthetic tests also verify both TIFF byte orders, exact model matching, diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cb400c476..557f37a6a 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -1,6 +1,6 @@ # Target-app parity: LightCraft vs Adobe Lightroom -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (gap 4 closed: full-resolution zoom; headline numbers unchanged) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-15 · **Change:** minor (RAW: 83% of Adobe's models decode, 38% verified after the camera-coverage sweep #1327) · **Target:** Adobe Lightroom Classic 15.5 (August 2026) and Lightroom 9.6 (desktop) The authoritative assessment of how close LightCraft is to Lightroom. The row-by-row checklist is [parity-checklist.md](parity-checklist.md); the ranked work list is [gaps.md](gaps.md); deep areas @@ -12,11 +12,11 @@ have their own documents: [raw-parity.md](raw-parity.md), [file-format-parity.md | Number | Value | Kind | |---|---:|---| -| **Feature breadth**, checklist | **81.1%** of 517 in-scope rows (P0 98.0%, P1 95.1%, P2 46.9%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | +| **Feature breadth**, checklist | **81.6%** of 532 in-scope rows (P0 98.0%, P1 95.2%, P2 50.0%) | **measured**: recount of [parity-checklist.md](parity-checklist.md) rows (✅ 1, 🟡 ½, 🚫 left out), the same rule `cargo xtask parity` uses | | **Feature breadth**, weighted by use | **~77%** | estimated: feature-area table below | | **Ready for real work** (full target) | **~61%** (range 56–66%) | estimated: weighted sum over the dimensions with written weights, see [below](#ready-for-real-work-mainstream-practitioner-and-essentials-user) | -| **Mainstream practitioner** | **~49%** (range 44–54%) | estimated: weekly areas of a working photographer, depth 71.8% × 0.804 × colour 0.85 | -| **Essentials user** | **~64%** (range 59–69%) | estimated: core features at default settings, depth 77.4% × 0.830 | +| **Mainstream practitioner** | **~51%** (range 46–56%) | estimated: weekly areas of a working photographer, depth 74.0% × 0.804 × colour 0.85 | +| **Essentials user** | **~65%** (range 60–70%) | estimated: core features at default settings, depth 78.0% × 0.830 | | Remaining to **beta** | **~340–600 h** | estimated; ~60% parallelizes | | Remaining to **full parity** | **~1,000–1,800 h** | estimated; ~70% parallelizes | | **Stage** | **alpha** | see [Stage](#stage) | @@ -47,11 +47,11 @@ phase #85, duplicate Local entries #22, black GPU exports on an Intel iGPU #78). | Dimension | Breadth | Ready for real work | Remaining | Doc | |---|---:|---:|---:|---| | Features (weighted areas below) | ~77% (checklist 81.1%, measured) | ~61% | 600–1,100 h | [Features](#features) | -| ↳ RAW cameras, colour and lens profiles (part of features) | 81% of Adobe's 1,446 models decode (16% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | -| UI/UX fidelity | ~85% | ~70% | 34–58 h | [ui-parity.md](ui-parity.md) | +| ↳ RAW cameras, colour and lens profiles (part of features) | 83% of Adobe's 1,446 models decode (38% verified), measured | ~56% | 255–480 h | [raw-parity.md](raw-parity.md) | +| UI/UX fidelity | ~85% | ~70% | 32–54 h | [ui-parity.md](ui-parity.md) | | File formats (non-raw: images, sidecars, presets, catalogs) | ~85% | ~70% | 30–55 h | [file-format-parity.md](file-format-parity.md) | | Hardware | ~50% | ~40% | 40–80 h (+ tethering and printing, counted under Classic modules) | [hardware-parity.md](hardware-parity.md) | -| Localization (12 key languages) | 7 of 12 present | ~52% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | +| Localization (12 key languages) | 7 of 12 present | ~55% (mean of the 12, measured) | 130–210 h for the target's set; +145–255 h for Hindi, Arabic, Indonesian, Vietnamese, Korean | [localization-parity.md](localization-parity.md) | | Performance | ~80% | ~70% | 30–50 h | 24 MP GPU slider 3.9 ms, export 0.3 s (measured, [gpu-pipeline.md](gpu-pipeline.md)); 85k library opens in 1.7–4.7 s; 100k test pending | | Stability | — | ~60% | 30–60 h | never-crash lints and guards workspace-wide, crash-tested journal, 2,561 tests; but CI runs tests only on FreeBSD, user reports keep finding bugs in ✅ areas | | Platforms | macOS, Windows (x64 / x86 / ARM64), Linux (AppImage, deb, rpm, Flatpak, Nix), FreeBSD, web | ~60% | 25–45 h | macOS is where it is tested; Windows installer UI unverified on Windows (#79); web experimental (CPU only) | @@ -68,18 +68,18 @@ from the checklist sections and the deep-dive documents. |---|---:|---:|---:|---:|---| | Import and library (import, folders, albums, smart albums, keywords, metadata, search, faces) | 15% | 90% | 80% | 25–45 h | checklist A–E 83–92%; robust journal; 85k-photo libraries | | RAW decoding and camera coverage | 15% | 81% | 77% | 120–220 h | [raw-parity.md](raw-parity.md), measured model by model: 85% of photographers' cameras decode (use-weighted), × 0.9 for per-body bugs; CR3 variants, compressed ORF, HE NEF | -| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 4 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | +| Colour, camera profiles, render fidelity | 10% | 65% | 35% | 80–150 h | 5 bundled profiles vs Adobe's 1,500; per-file fits median ΔE ~5 vs the camera JPEG, ~10% far off; no fidelity suite ([raw-parity.md](raw-parity.md#colour)) | | Global develop adjustments (light, colour, curves, grading, effects, detail) | 15% | 93% | 70% | 30–60 h | checklist F 91.7%; character tuned by eye | | Optics, lens profiles, crop and geometry | 6% | 80% | 50% | 40–80 h | no lens database; crop / Upright complete | | Masking | 8% | 67% | 45% | 60–110 h | checklist K 67.4%; AI masks heuristic or SAM 3 without mirror | | Remove, heal, Enhance (denoise, Raw Details, Super Resolution, Lens Blur) | 7% | 45% | 30% | 70–130 h | checklist I 75%, P 0% | | Presets, profiles, versions, history, sync | 5% | 95% | 85% | 8–15 h | | | Export | 6% | 89% | 75% | 15–30 h | checklist S 88.9% | -| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 100%, HDR 70% (no HDR display) | +| Merge and HDR | 3% | 85% | 60% | 20–35 h | merge 88% (HDR merge 🟡: AI Denoise on merged DNGs, JPEG response, memory; gap 29), HDR 70% (no HDR display) | | Views, compare, zoom, culling | 5% | 90% | 70% | 15–30 h | zoom detail P0 gap | | Classic output modules (Map, Book, Slideshow, Print, Web, publish, tethering) | 4% | 15% | 10% | 90–150 h | Classic extras 48% counts many small keys; the modules themselves are mostly ⬜ | | Video | 1% | 0% | 0% | 25–45 h | catalogued only | -| **Weighted** | 100% | **~77%** | **~61%** (60.6%) | **600–1,100 h** | | +| **Weighted** | 100% | **~77%** | **~61%** (61.5%) | **600–1,100 h** | | ## AI @@ -90,6 +90,8 @@ search embeddings; generative remove runs in Adobe's cloud. | Feature | LightCraft | Status | |---|---|---| +| 2026-10-12 | minor | Mamiya MEF decoded (raw-parity.md): 241 verified, 35 unsupported; rounded numbers unchanged | +| 2026-10-12 | minor | Epson ERF decoded (raw-parity.md): 240 models verified, 229 preview-only; shares and rounded numbers unchanged | | Subject / Sky / Background masks | classical heuristics; the file's own DNG semantic mattes | 🟡 | | People (faces, body, hair, skin…) and Landscape masks | — | ⬜ | | Object / Describe masks | SAM 3 in pure Rust (Metal / CPU); opt-in 3.4 GB download, no mirror configured | 🟡 | @@ -117,8 +119,8 @@ work. Each has its own hours to ~95%, calibrated as in [How hours are calibrated | Audience | Ready | Opus 5.5 agent wall-clock hours to ~95% | Work that dominates | |---|---:|---:|---| | Full target (ready for real work) | **~61%** | **1,000–1,800 h** (~70% parallelizes) | raw colour, lens and camera data; AI models; Classic output modules; localization; plug-in ecosystem | -| Mainstream practitioner | **~49%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | -| Essentials user | **~64%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | +| Mainstream practitioner | **~51%** | **450–840 h** (~60% parallelizes) | camera colour and render fidelity; CR3 / ORF / HE NEF; lens database; subject / sky masks; Lightroom XMP exchange; stability | +| Essentials user | **~65%** | **160–300 h** (~50% parallelizes) | default raw look for popular cameras; launch stability; basic-slider feel; opening CR3 / HEIC files people send | ### Full target (ready for real work): ~61% @@ -129,17 +131,17 @@ valued from the model-by-model measurement in [raw-parity.md](raw-parity.md): | Dimension | Weight | Ready | |---|---:|---:| | Features: the other 11 areas, weighted depth from [Features](#features) | 45% | 60.9% | -| RAW decoding and camera coverage | 9% | 77% (85% use-weighted × 0.9) | +| RAW decoding and camera coverage | 9% | 83% (92% use-weighted × 0.9) | | Camera colour and default rendering | 6% | 35% | | UI/UX fidelity | 8% | 70% | | File formats (non-raw) | 6% | 70% | | Hardware | 3% | 40% | -| Localization | 3% | 52% | +| Localization | 3% | 55% | | Performance | 6% | 70% | | Stability | 8% | 60% | | Platforms | 4% | 60% | | Ecosystem / plug-ins | 2% | 15% | -| **Weighted sum** | 100% | **60.7% ≈ 61%** (range 56–66%) | +| **Weighted sum** | 100% | **61.2% ≈ 61%** (range 56–66%) | Mainstream comes out lower than full: its scope is the raw-develop core, where LightCraft is weakest (camera colour, CR3 coverage), and its discounts are multiplicative, while the full sum is lifted by @@ -160,7 +162,7 @@ essentials replaces its guessed 45% raw look with 84% × 0.75 (+1 point). | File exchange with Lightroom users (mainstream) / opening files people send (essentials) | ×0.92 | ×0.95 | Adobe XMP sidecars change colours (#290), XMP CameraProfile and Upright ignored (#204, #205), catalog import fails (#599), same Light values render differently (#523, #209), no write-back to `.lrcat`; for casual users: CR3 beyond verified bodies, compressed ORF, HE NEF open as previews (#473, #485, #556), HEIC trouble (#693) | | **Product** | **×0.804** | **×0.830** | | -### Mainstream practitioner: ~49% +### Mainstream practitioner: ~51% A working photographer (wedding, portrait, events, landscape) shooting raw on one current camera, weekly: import, cull, develop, a few masks, sync a look, export. Excluded: Classic output modules, @@ -171,20 +173,20 @@ than the user's own. |---|---:|---:| | Import and library | 18% | 80% | | Cull: views, compare, zoom, ratings | 12% | 70% | -| Raw decoding for their camera | 27% | 85% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | +| Raw decoding for their camera | 27% | 92% (measured use-weighted share whose camera fully works, [raw-parity.md](raw-parity.md#by-use-estimated)) | | Global develop adjustments | 15% | 70% | | Crop, geometry, lens corrections | 6% | 50% | | Masking (manual, subject / sky; no generative) | 8% | 45% | | Heal / remove (no generative), denoise | 5% | 40% | | Presets, copy / paste / sync | 5% | 85% | | Export | 4% | 75% | -| **Weighted depth** | 100% | **72.1%** | +| **Weighted depth** | 100% | **74.0%** | Colour fidelity is a written discount, **×0.85**: most raws start near the camera's own look (median ΔE ~5), about 10% far off (ΔE ~18), none calibrated to Adobe Standard, so a working photographer -corrects colour by hand (33 open colour issues). 72.1% × 0.804 × 0.85 = **~49%** (range 44–54%). +corrects colour by hand (33 open colour issues). 74.0% × 0.804 × 0.85 = **~51%** (range 46–56%). -### Essentials user: ~64% +### Essentials user: ~65% A casual photographer with default settings: import, look through, rate, fix a few photos with the basic sliders, crop, apply a preset, undo, export JPEGs. @@ -199,11 +201,11 @@ basic sliders, crop, apply a preset, undo, export JPEGs. | Crop and straighten | 10% | 80% | | Presets and profiles | 8% | 80% | | Undo and history | 5% | 90% | -| Their camera's raws: decode × default look | 10% | 64% (85% whose camera fully works × colour factor 0.75) | +| Their camera's raws: decode × default look | 10% | 69% (92% whose camera fully works × colour factor 0.75) | | Export JPEG | 10% | 85% | -| **Weighted depth** | 100% | **77.5%** | +| **Weighted depth** | 100% | **78.0%** | -77.5% × 0.830 = **~64%** (range 59–69%). +78.0% × 0.830 = **~65%** (range 60–70%). ### User evidence (GitHub issues, excluding the four maintainers) @@ -257,7 +259,7 @@ verification. Calibrated on this repository's history (git log and GitHub PR ope (#279), NEF lossy after split 4.7 h (#633), ORF 12.8 bpp 1.7 h (#651), HEIC 7 h (#609). - **Large features:** AI RAW denoise 8.3 h (#384, 12.8k lines), SAM 3 masks 14.9 h (#162), HDR editing and export 2.3 h (#646, on existing groundwork), contact-sheet PDF (#563), German UI - 13.8 h (#265). + 13.8 h (#267). - PR open time includes queueing and review, so these are upper bounds per feature; the ranges add a long-tail factor of 1.3–1.5× for verification against real files. @@ -274,6 +276,7 @@ most of its list; what remains is the data-heavy part (colour, lenses, models) i | Date | Checklist (all / P0 / P1 / P2) | Ready for real work | Remaining to full parity | Source | |---|---|---|---|---| +| 2026-10-12 | 81.1% (unchanged) | ~61% full · ~51% mainstream · ~65% essentials | unchanged | 22 more CR3 bodies verified (raw-parity.md) | | 2026-10-10 (RAW) | 81.1% (unchanged) | ~61% full · ~49% mainstream · ~64% essentials | unchanged | RAW measured model by model against Adobe's list; RAW decode and colour as explicit rows | | 2026-10-10 (final) | 81.1% (unchanged) | ~59% full (weighted sum) · ~51% mainstream · ~63% essentials | full 1,000–1,800 h · mainstream 450–840 h · essentials 160–300 h | method aligned with the standard | | 2026-10-10 (later) | 81.1% (unchanged) | ~46% full · ~51% mainstream · ~63% essentials | 1,000–1,800 h | full × discounts (superseded) | @@ -297,11 +300,18 @@ P2 items 12–25 h; look tuning, performance, packaging, hardening 25–45 h. | Date | Change | Summary | |---|---|---| +| 2026-10-14 | minor | Localization ~52% → ~55% (mean of the 12 key languages): de, es, fr, ja and uk catalogs filled | +| 2026-10-12 | minor | UI/UX remaining 34–58 h → 32–54 h (canvas tools by keyboard); headline numbers unchanged | +| 2026-10-14 | minor | Merge and HDR: merge 100% → 88% (LR-MERGE-HDR ✅ → 🟡 after testing on real 67 MP brackets; gap 29); area percentages unchanged | | 2026-10-14 | minor | Gap 4 (full-resolution zoom) closed: removed from the interaction-fidelity notes, the alpha-gate partial rows and the beta needs; headline numbers and the ×0.95 interaction factor unchanged | | 2026-10-12 | minor | UI/UX remaining 40–70 h → 34–58 h (accessibility audit landed, ui-parity.md); percentages unchanged | +| 2026-10-14 | minor | Camera-coverage sweep (#1327): 30 models verified on 40 CC0 files (`coverage_sweep_corpus.rs`, `coverage-sweep.tsv`; `docs/raw/coverage.py` 515 → 545 verified rows); the counts above are not re-measured here. Sensor verification does not establish colour parity (#1277); headline numbers unchanged | +| 2026-10-12 | minor | 22 more CR3 bodies verified on one CC0 file each (raw-parity.md): use-weighted share whose camera decodes 85% → 92%, RAW decoding 77% → 83%; full 60.7% → 61.2% (~61%), mainstream depth 72.1% → 74.0% (~49% → ~51%), essentials depth 77.5% → 78.0% (~64% → ~65%) | +| 2026-10-12 | minor | 117 Nikon and Fujifilm bodies verified (raw-parity.md): use-weighted share whose camera decodes 85% → 86%, RAW decoding 77% (unchanged); mainstream depth 72.1% → 72.4%, essentials depth 77.5% → 77.6%; headline numbers unchanged | | 2026-10-11 | minor | EOS R5 Mark II CR3 verified (raw-parity.md): use-weighted share whose camera decodes 84% → 85%, RAW decoding 76% → 77%; full 60.6% → 60.7%, mainstream depth 71.8% → 72.1%, essentials depth 77.4% → 77.5%; the rounded numbers (~61%, ~49%, ~64%) are unchanged | | 2026-10-10 | minor | RAW measured model by model (raw-parity.md): RAW decoding (76%) and colour (35%) are explicit rows of the full sum (59% → 61%); mainstream uses the use-weighted 84% plus a ×0.85 colour discount (51% → 49%); essentials uses 84% × colour 0.75 (63% → 64%); beta ~14 points | | 2026-10-10 | minor | Full number restored to an additive weighted sum over the dimensions (~59%; method aligned with the standard, no new evidence); hours to ~95% for each of the three audiences; beta distance ~16 points | | 2026-10-10 | minor | Added mainstream practitioner (~51%) and essentials user (~63%) numbers; full ready re-derived from written weights × discounts (55% → 46%); user-issue evidence; beta hours 340–600 | | 2026-10-10 | minor | Stage re-checked against the core-workflow alpha gate: passes, stays alpha | | 2026-10-10 | major | Created per the progress-docs standard: full re-measure (checklist recount, installed Lightroom 9.6 bundle listing, code counts, catalogs, CI), feature areas with weights, dimensions, AI, stage, calibration; merged ROADMAP's *Where we stand* and *Parity estimate* | +| 2026-10-14 | minor | RAW: 82% of Adobe's models decode, 36% verified (integration batch 36) | diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 6ca7bfff5..d977e3904 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: diagnostic overlays sharp at 1:1); previously minor (zoom row: soft proofing sharp at 1:1) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-14 · **Change:** minor (zoom row: diagnostic overlays and soft proofing sharp at 1:1; canvas tools by keyboard; focus-order test; ~50 unnamed hand-drawn widgets named) · **Target:** Adobe Lightroom Classic 15.5 / Lightroom 9.6 How LightCraft's interaction compares with Lightroom's: views, panels, tools and handles, modifier keys, shortcuts, zoom, context menus and feel. Rows for individual menu items and shortcuts live in @@ -32,11 +32,11 @@ assessment and the notes that don't fit a row. Visual reference for agents: `pla | Shortcuts | Lightroom desktop and Classic keymaps | Lightroom-desktop keymap with Classic additions, editable | 85% | deliberate differences below; no Classic keymap layer | | Context menus | everywhere | 26 sites (grid, filmstrip, albums, folders, keywords, masks, presets…) | 70% | unaudited against Lightroom's menus item by item | | Text fields | native editing behaviour | custom text field (LR-BEHAV-TEXTFIELD) | 70% | IME and selection edge cases | -| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab | 55% | crop handles, spot and mask placement on the canvas still need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | +| Accessibility | VoiceOver / Narrator, keyboard navigation | AccessKit tree audited by a test in every view, Develop tool and the main dialogs: every control named, sliders with value, range and Increment / Decrement, dialogs titled, tool sliders reachable with Tab; crop handles, spots, gradient handles and mask pins by keyboard | 75% | brush strokes and red-eye ellipses need the pointer; not tried with a real screen reader ([audit](#accessibility-audit)) | | Feel and responsiveness | GPU-interactive sliders (15.3) | ≤ 16 ms slider budget on GPU; background renders | 75% | colour NR at half resolution, 100k-photo grid test | | **UI/UX overall** | | | **~70%** | estimated, weighted by use (develop and library 60%, tools 25%, rest 15%) | -Remaining: **34–58 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 4–8 +Remaining: **32–54 h** (zoom detail 8–15 h, brush and masking feel 8–12 h, accessibility 2–4 h, Classic keymap layer 4–8 h, context-menu audit 4–8 h, text-field and IME polish 4–8 h). Output modules and AI tools are counted with their features in [target-app-parity.md](target-app-parity.md). @@ -60,17 +60,21 @@ under the UI command that wraps it (a no-op error); the UI command now wins. | Expand/collapse edit sections | ⌘⌥1–5 | ⌘1–6 | ⌘0 = Zoom to Fit, ⌘1 = Zoom 100 %; Geometry lives in the Crop panel | | Histogram | ⌘⇧H | ⌘0 | ⌘0 = Zoom to Fit | | Square Grid | ⇧G | (none; ⇧G = Guided Upright) | Guided Upright is a button in the Crop panel | +| Hide / show pins (Tool Overlay) | ⇧H (cycles Auto, Always, Selected, Never) | H (Classic) | H opens Remove; View ▸ Tool Overlay and the Remove / Masking panels pick a mode | | Export dialog | ⌘⇧E (+ ⇧E) | ⇧E | ⌘⇧E is "Edit in Photoshop" there; no external-editor command yet | | Crop overlay cycle | ⇧O | O | O = mask overlay; ⇧O (mask colour / overlay orientation) unused otherwise | | Create Version | ⌘⇧S (+ ⇧M) | ⇧M (Windows: Ctrl+⇧S) | — | | Select None | ⌘⇧A (+ ⌘D) | ⌘D | — | -**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. Remaining Classic gaps: rating `[` / `]`, flag cycling and filter-bar keys. +**Library culling (KEYC-RATING):** `0–5`, `6–9` and `P/X/U` reach egui on macOS even when displayed in the native menu (adapted from PR #261; issue #283). `Shift+0–9` applies and advances once; `Shift+P` picks and advances in Photo Grid / Square Grid and retains Presets elsewhere. See [library shortcuts](library-shortcuts.md) for the verified Classic mapping and regression coverage. `` ` `` cycles the flag (unflagged → pick → reject → unflagged) and ⌘↑ / ⌘↓ raise / lower it; the native menu leaves ⌘↑ / ⌘↓ to egui so Compare/Survey act on the active photo. Remaining Classic gaps: filter-bar keys. + +**Grid thumbnail size (KEYC-COMPARE):** Classic's `=` and `-` increase and decrease the Grid thumbnail size (same keys on macOS and Windows; `+` also works for the larger size here, as Shift+`=` or the keypad). They step the bottom-bar slider by 30 pt inside its 90–480 pt range, in Photo Grid, Square Grid and People only; Detail, Compare, Survey and Reference ignore them, and ⌘= / ⌘− stay Zoom In / Out. See [library shortcuts](library-shortcuts.md). + +**Library navigation keys (KEYC-COMPARE):** in Photo Grid and Square Grid, `Home` / `End` make the first / last photo of the view the active, selected one, and `Shift+Left` / `Shift+Right` add the previous / next photo of the view to the selection (the added photo becomes active; the keys never remove a photo from the selection, and do nothing at either end of the view). They wait in the other views, in the full-screen preview, while a text field or a dialog has the keyboard, and when you gave the key to a command; they are listed under Fixed keys in Help → Keyboard Shortcuts. `Cmd+O` (`Ctrl+O` on Windows and Linux) runs File → Open Library… ; View → Visualize HDR has no default key (`Shift+H` cycles the Tool Overlay, issue #224; `Cmd+Shift+H` stays the Histogram). Covered by `crates/ui-egui/src/tests_keymap.rs`; the macOS native menu path for `Cmd+O` is not exercised by the headless tests. **Still missing / broken:** - No command yet: F1 help (verify the rest of the old list: full screen, settings, stacks, visualize spots and merges have commands now). -- `H` opens Remove; Lightroom also uses it (Classic) to hide pins — pins toggle from View → Show Mask Pins. - ⌘M / ⌘H / ⌘Q / ⌘W rely on the platform window defaults (unverified). @@ -96,16 +100,43 @@ Found and fixed (2026-10-12): 47 problems in the views and tools, 11 more in the - Auto Stack's time slider had no name. Keyboard reach: Crop (angle, offsets, scale, aspect) and Remove (size, feather, opacity) settings -are reached with Tab and adjusted with the arrow keys (tested). Still pointer-only: dragging crop -handles, placing a spot or a brush stroke, and moving a mask pin on the canvas. Not done: a session -with a real screen reader on each platform, and focus order (egui follows drawing order: top bar, -left panel, canvas, right panel, which is sensible but not tuned). +are reached with Tab and adjusted with the arrow keys (tested). + +Canvas tools by keyboard (`crates/ui-egui/src/canvas_keys.rs`, tests in `tests_canvas_keys.rs`): +every handle drawn on the photo has an invisible stand-in that takes the focus. Tab reaches it, a +screen reader reads its name and position ("Crop bottom-right corner", "left 0%, top 0%, right +100%, bottom 100%"; "Spot 1", "x 30%, y 60%, size 5%"), and the arrow keys move it by 0.5% of the +photo (Shift: 5%), one undo step per press. While it has the focus it keeps the arrows, Return +and Space, and the app's other shortcuts still work. +- Crop: four corners, four edges and the whole box (`crop.drag`, so the aspect lock and image + bounds apply as they do for a drag); Return applies the crop. +- Remove: each spot's target and source; a "New spot" point that starts at the centre, moves + with the arrows, and turns into a spot of the brush's size when you press Return. +- Masking: the four edges and the rotation handle of a radial gradient, the start and end of a + linear one, and each mask pin (moving one selects its mask). + +Focus order: the audit now also counts any widget that takes the focus or a click, whatever its +role. That found about 50 hand-drawn icons, tiles and rows with no name: top-bar icons, flags and +stars, filmstrip and survey tiles, preset and profile rows, curve channels, colour wheels, mask +tiles and rows, slider labels and readouts. They are now announced as buttons. A test walks Tab +through the Edit, Crop, Remove and Masking panels and checks that it goes top to bottom (the tool +strip beside them is its own column, walked first). No reordering was needed once nothing +unnamed was in the way. + +Not done: brush strokes and red-eye ellipses still need a pointer. Nobody has tried the app with +a real screen reader on any platform yet. ## Revision history | Date | Change | Summary | |---|---|---| | 2026-10-14 | minor | Zoom row: diagnostic overlays render zoom windows too | +| 2026-10-14 | patch | Shortcuts: Visualize HDR loses its default ⇧H, which cycles the Tool Overlay (issue #224); both PRs had claimed it | +| 2026-10-12 | minor | Accessibility: crop handles, spots, gradient handles and mask pins operable by keyboard with AccessKit names and values; Tab order test for the right panels; hand-drawn icons and rows named; 55% → 75%; UI remaining 34–58 h → 32–54 h | +| 2026-10-13 | minor | Shortcuts: Tool Overlay modes for spot and mask pins on ⇧H (H stays Remove; issue #224), listed with the deliberate differences; dropped from the missing list | +| 2026-10-13 | minor | Library culling: backtick cycles the flag; ⌘↑ / ⌘↓ raise / lower flag status | +| 2026-10-13 | minor | KEYC-COMPARE: `=` / `-` grid thumbnail size keys noted in the shortcuts section | +| 2026-10-13 | minor | Shortcuts: Home / End and Shift+Left / Right in the two library grids, Cmd+O (Open Library…) and Shift+H (Visualize HDR) get bindings and keyboard tests; percentages and the dated counts above are unchanged | | 2026-10-14 | minor | Zoom row: soft proofing gets zoom windows; LR-VIEW-ZOOM gap closed | | 2026-10-12 | minor | Accessibility: an AccessKit tree audit test over every view, Develop tool and 13 dialogs; unnamed fields, slider ranges and the Auto Stack slider fixed; 30% → 55%; UI remaining 40–70 h → 34–58 h | | 2026-10-11 | minor | Zoom row: Compare and Reference get zoom windows; what remains of LR-VIEW-ZOOM is overlays and soft proofing | diff --git a/docs/user/AGENTS.md b/docs/user/AGENTS.md new file mode 100644 index 000000000..abb8cf8f7 --- /dev/null +++ b/docs/user/AGENTS.md @@ -0,0 +1,60 @@ +# User docs: rules for agents and contributors + +Everything in this folder (except this file, CLAUDE.md and README.md) is published at +https://getartcraft.com/docs/lightcraft. The file path is the URL: +`troubleshooting/raw-photos-look-dull.md` → `/docs/lightcraft/troubleshooting/raw-photos-look-dull`. +Renaming a file breaks links, so add the old path to `redirect_from` when you rename. + +## Who reads this + +Artists and photographers using LightCraft, Discord mods answering them, and AI assistants looking +things up for either. Write for a photographer, not a developer. + +## Page rules + +- One question or problem per page. Lead with the answer or the fix in the first two lines. +- Plain words, short sentences, numbered steps for anything the reader does. +- Start the body at `##`. The page title comes from frontmatter. +- Link to other pages instead of repeating them. Use relative links to the `.md` file: + `[Install LightCraft](../getting-started/install.md)`. +- No marketing copy, no "simply", no claims you have not checked in the code or a release. +- Call the product "LightCraft", as the app, its downloads and getartcraft.com do. +- Name UI labels exactly as the app shows them, in **bold**. +- Troubleshooting pages: `## Symptoms`, `## Fix`, `## Why this happens` (optional), + `## Still stuck?` (link the issue tracker or Discord). + +## Frontmatter + + --- + title: Raw photos look dull + description: One sentence for search results, link previews and AI summaries. + order: 10 # optional, sorts within the folder (lower first) + verified: 0.6.0 # optional, app version you last checked this page against + redirect_from: [old/path] # optional, old URLs (relative to /docs/lightcraft) + --- + +Only `key: value` and `key: [a, b]` lines work. Quote values that start with a quote or contain ` #`. + +Set `verified` to the latest release (the newest release tag) after checking the page against that tag with `git show <tag>:<path>`. Pages describe the latest release users download, not unreleased code on main. + +## Folders + +- `getting-started/` install and first run +- `guides/` how to do one thing +- `troubleshooting/` symptom → fix +- `faq/` short answers +- `reference/` lists and settings + +A folder's `index.md` names the folder in the sidebar. Files or folders starting with `_` are +drafts and are not published. + +## Check before you push + + node <path to your artcraft-services checkout>/frontend/apps/artcraft-website-next/scripts/docs/core.mjs docs/user + +The checker lives in storytold/artcraft-services. A CI check will be added once it is on that repo's main branch. + +## Support PRs + +Pages written to answer a user question come in as PRs titled `docs(support): <page title>`. +Review them like code: check every claim against the source. diff --git a/docs/user/CLAUDE.md b/docs/user/CLAUDE.md new file mode 100644 index 000000000..e7bcfb70e --- /dev/null +++ b/docs/user/CLAUDE.md @@ -0,0 +1 @@ +Read `AGENTS.md` in this folder: it has the rules for the user docs. diff --git a/docs/user/faq/free-and-private.md b/docs/user/faq/free-and-private.md new file mode 100644 index 000000000..7eb64052e --- /dev/null +++ b/docs/user/faq/free-and-private.md @@ -0,0 +1,20 @@ +--- +title: Is LightCraft free, and does it need an account? +description: LightCraft is free and open source, needs no account or subscription, and keeps your library on your computer. +order: 10 +verified: 0.7.1 +--- + +Yes, it is free. LightCraft is open source under the MIT or Apache-2.0 licence, your choice. It needs no account, no cloud service and no subscription, and it sends no telemetry. + +## Where are my photos and edits kept? + +On your computer. Your library lives in a folder you choose, by default `~/Pictures/LightCraft Library`. Edits are non-destructive, so your original files are never touched. The catalog is a log of human-readable operations that you can back up. Use **File** > **Back Up Library…** to make a backup. + +## Which systems does it run on? + +LightCraft runs on Windows, macOS and Linux, and in a web browser. See [Install LightCraft](../getting-started/install.md). + +## Is it finished? + +No. LightCraft is in alpha. Some things from Adobe Lightroom are not there yet, such as on-screen HDR display, video and the Print, Book and Map modules. The project's [roadmap](https://github.com/storytold/lightcraft/blob/main/ROADMAP.md) lists what is missing. diff --git a/docs/user/faq/index.md b/docs/user/faq/index.md new file mode 100644 index 000000000..1ddafea53 --- /dev/null +++ b/docs/user/faq/index.md @@ -0,0 +1,8 @@ +--- +title: FAQ +description: Short answers about LightCraft, its price, platforms and file support. +order: 40 +--- + +- [Is LightCraft free, and does it need an account?](free-and-private.md) +- [Which cameras and file types does LightCraft open?](supported-files.md) diff --git a/docs/user/faq/supported-files.md b/docs/user/faq/supported-files.md new file mode 100644 index 000000000..143276f13 --- /dev/null +++ b/docs/user/faq/supported-files.md @@ -0,0 +1,22 @@ +--- +title: Which cameras and file types does LightCraft open? +description: The raw and image formats LightCraft reads, what happens with raws it cannot decode yet, and how to open Lightroom catalogs. +order: 20 +verified: 0.7.1 +--- + +LightCraft reads raw files from DNG, Canon CR2 and CR3, Sony ARW, Nikon NEF, Fujifilm RAF (including X-Trans), Panasonic RW2, Leica RWL, Pentax PEF and Olympus ORF cameras. It also opens JPEG, PNG, TIFF, WebP, PSD composites and JPEG XL. + +## What if my camera is not decoded yet? + +A raw that LightCraft cannot decode opens from the camera's embedded JPEG instead, marked **Preview only**. Compressed Olympus raws, some Canon CR3 variants and Nikon High Efficiency NEF files work this way. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). + +How closely the starting look matches your camera's own JPEG varies by model. See [Raw photos look dull or washed out](../troubleshooting/raw-photos-look-dull.md). The project's [raw support table](https://github.com/storytold/lightcraft/blob/main/docs/raw-parity.md) lists what is known per camera. + +## Can I open my Lightroom photos? + +Yes. LightCraft imports Lightroom Classic catalogs. See [Move over from Lightroom](../guides/import-lightroom-catalog.md). + +## What can I export? + +JPEG, PNG, TIFF, PSD, WebP, AVIF, JPEG XL and DNG, or the original files with XMP sidecars. See [Export photos](../guides/export-photos.md). diff --git a/docs/user/getting-started/index.md b/docs/user/getting-started/index.md new file mode 100644 index 000000000..7b7cef220 --- /dev/null +++ b/docs/user/getting-started/index.md @@ -0,0 +1,7 @@ +--- +title: Getting started +description: Install LightCraft on your computer and open your first library. +order: 10 +--- + +- [Install LightCraft](install.md) diff --git a/docs/user/getting-started/install.md b/docs/user/getting-started/install.md new file mode 100644 index 000000000..8327222cb --- /dev/null +++ b/docs/user/getting-started/install.md @@ -0,0 +1,57 @@ +--- +title: Install LightCraft +description: Download LightCraft for Windows 11, macOS or Linux, install it, and open your photo library. +order: 10 +verified: 0.7.1 +--- + +Download the file for your system from the [latest release](https://github.com/storytold/lightcraft/releases/latest) and install it. In the file names, `<version>` stands for the version number, for example `0.7.1`. The [LightCraft page on getartcraft.com](https://getartcraft.com/apps/lightcraft) also links the download. + +## Windows 11 + +1. Open the [latest release](https://github.com/storytold/lightcraft/releases/latest). +2. Download `lightcraft-<version>-windows-x64.msi`. It fits most PCs (Intel and AMD, 64-bit). +3. Run the file. The installer asks where to install. The default is `C:\Program Files\LightCraft`, and upgrades stay in the folder you chose. +4. At the end, choose the option to start LightCraft. + +For a Snapdragon or other Arm PC, download `lightcraft-<version>-windows-arm64.msi` instead. For a 32-bit Windows, download `lightcraft-<version>-windows-x86.msi`. + +Upgrading from an installer older than 0.6.0 also keeps LightCraft in the folder it was installed in. If that folder was deleted, the upgrade installs to `C:\Program Files\LightCraft`. + +To run it without installing, download `lightcraft-<version>-windows-x64-portable.zip`. Portable builds for `arm64` and `x86` are on the release page too. + +## macOS + +1. Download `lightcraft-<version>-macos-universal.dmg`. It works on Apple silicon and Intel Macs, and is signed and notarized. +2. Open the file and install LightCraft. + +## Linux + +The release has these files for `x86_64` and `aarch64` (Arm). Replace `x86_64` with `aarch64` on Arm. + +| File | Notes | +|----------------------------------------------|----------------------------------------------------------| +| `lightcraft-<version>-linux-x86_64.AppImage` | Runs anywhere. | +| `lightcraft-<version>-linux-x86_64.flatpak` | Sandboxed. Install with `flatpak install --user <file>`. | +| `lightcraft-<version>-linux-x86_64.deb` | For Debian and Ubuntu. | +| `lightcraft-<version>-linux-x86_64.rpm` | For Fedora, RHEL and openSUSE. | +| `lightcraft-<version>-linux-x86_64.tar.gz` | Unpack anywhere. | + +RISC-V and FreeBSD builds are on the release page too. On a Wayland desktop, LightCraft runs through XWayland when it is available, because only X11 gives it file drag-and-drop. + +## Install with ArtCraft Launcher + +[ArtCraft Launcher](https://getartcraft.com/docs/craft-launcher) lists LightCraft and can install and update it for you. On Windows it installs the release `.msi`. + +## In a web browser + +The release also has `lightcraft-web-<version>.zip`, a static site that runs LightCraft in a modern browser. Host it on any static web server. + +## First run + +1. Start LightCraft. It opens your library at `~/Pictures/LightCraft Library`. A new library starts with demo photos. +2. To bring in your own photos, see [Import photos](../guides/import-photos.md). + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). diff --git a/docs/user/guides/cull-photos.md b/docs/user/guides/cull-photos.md new file mode 100644 index 000000000..ae76982e9 --- /dev/null +++ b/docs/user/guides/cull-photos.md @@ -0,0 +1,40 @@ +--- +title: Rate, flag and label photos +description: Sort a shoot with star ratings, pick and reject flags and colour labels, from the keyboard or the Photo menu. +order: 20 +verified: 0.7.1 +--- + +Select photos in **Photo Grid** or **Square Grid**, then press a key: `1` to `5` for stars, `P` to pick, `X` to reject, `6` to `9` for a colour label. The same actions are in the **Photo** menu. + +## Steps + +1. Open the library in **View** > **Photo Grid** or **View** > **Square Grid**. +2. Click a photo to select it. Click more photos to build a selection, or use **Edit** > **Select All**. +3. Press a key: + + | Key | Action | + |--------------------|---------------------------------------------------------| + | `0` | Clear the star rating | + | `1` to `5` | Set the star rating | + | `[` and `]` | Lower or raise the rating by one, within 0 to 5 | + | `6`, `7`, `8`, `9` | Set a red, yellow, green or blue label | + | `P` | Flag as a pick | + | `X` | Flag as rejected | + | `U` | Clear the flag | + +4. To move on faster, hold `Shift` with a key. It applies the action and selects the next photo. Or turn on **Photo** > **Auto Advance** to move forward after every action. +5. For purple, or to clear a colour label, use **Photo** > **Set Color Label** or the photo's right-click menu. + +Rating, flag and label changes apply to every selected photo, and you can undo them with **Edit** > **Undo**. + +## Compare photos + +Choose **View** > **Compare** to see two photos side by side with synced zoom and pan, or **View** > **Survey** to see the selected photos tiled. The keys above then apply to the active photo only. + +## Good to know + +- **Rejected** only marks a photo for culling. It does not delete the original or remove it from the library. +- The sidebar has **Picks** for the photos you flagged. The search box understands fields, for example `rating:>3 flag:pick`. +- Choose **Help** > **Keyboard Shortcuts** to see every shortcut. +- Typing in a text field keeps the keys for typing. diff --git a/docs/user/guides/edit-a-photo.md b/docs/user/guides/edit-a-photo.md new file mode 100644 index 000000000..126324159 --- /dev/null +++ b/docs/user/guides/edit-a-photo.md @@ -0,0 +1,43 @@ +--- +title: Edit a photo +description: Open a photo in the Edit panel, adjust Light, Color and Effects sliders, compare with the original, and undo or reset. +order: 30 +verified: 0.7.1 +--- + +Double-click a photo in the grid, then press `E` to open the **Edit** panel. Drag a slider to change the photo. Your original file is never changed, because edits are stored in the library. + +## Steps + +1. In **Photo Grid** or **Square Grid**, double-click the photo. It opens in the **Detail** view. +2. Press `E`, or choose **Window** > **Edit**, to open the **Edit** panel. +3. Click **Auto** for a first pass, or skip this step and start with the sliders. +4. Open the **Light** section and set **Exposure**, **Contrast**, **Highlights**, **Shadows**, **Whites** and **Blacks**. +5. Open the **Color** section to change **Vibrance** and **Saturation**. Open **Effects** for **Texture**, **Clarity** and **Dehaze**. The panel also has **Detail** and **Optics** sections. +6. Check your work against the original. Hold `\` to peek at the original. Press `Y` (**View** > **Compare Before and After**) to see before and after side by side. +7. To go back a step, choose **Edit** > **Undo**. To drop all edits on the photo, click **Reset** at the top of the **Edit** panel. + +## Black and white + +Click **B&W** at the top of the **Edit** panel to turn the photo monochrome. Click it again to return to colour. + +## HDR + +Click **HDR**, next to **B&W**, to edit the photo in HDR, with highlights brighter than SDR white for HDR exports. LightCraft cannot show HDR on an HDR screen yet, so you edit without seeing the extra brightness. + +## Other edit tools + +Open them from the **Window** menu, or press the key shown next to each one: + +| Panel | Key | +|-------------------|-------------| +| **Crop & Rotate** | `C` | +| **Remove** | `H` | +| **Masking** | `M` | +| **Presets** | `Shift`+`P` | + +## Good to know + +- A raw photo that LightCraft cannot decode yet is edited from the camera's embedded JPEG. The **Edit** panel then shows **Preview only**. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). +- When you zoom in on a large raw, the view can look soft for a few seconds while LightCraft renders the full-resolution pixels. +- To save the result as a file, see [Export photos](export-photos.md). diff --git a/docs/user/guides/export-photos.md b/docs/user/guides/export-photos.md new file mode 100644 index 000000000..71bca3241 --- /dev/null +++ b/docs/user/guides/export-photos.md @@ -0,0 +1,34 @@ +--- +title: Export photos +description: Save edited photos as JPEG, PNG, TIFF and other files with the size, folder and file names you choose. +order: 40 +verified: 0.7.1 +--- + +Select the photos, then choose **File** > **Export…**. Pick a format, a size and a folder, then click **OK**. Your originals are not changed. + +## Steps + +1. Select one or more photos in the grid. +2. Choose **File** > **Export…**. The **Export** window shows how many photos it will export. +3. Set **Format**: **JPEG**, **PNG**, **TIFF**, **PSD**, **WebP**, **AVIF**, **JPEG XL**, **DNG** or **Original files**. **DNG** writes raw photos as DNG with the edits embedded. **Original files** copies the files unchanged, each with an XMP sidecar that holds its edits. +4. For **JPEG**, **AVIF** or lossy **JPEG XL**, set **Quality**. For **JPEG XL**, tick **Lossless** to keep every pixel exactly, at the cost of larger files. **Effort** trades time for file size, and **Progressive** makes the file show a coarse version first while it loads. +5. Under the size options, tick **Full size**, or clear it and choose **Resize to** one of **Long Edge**, **Short Edge**, **Width**, **Height**, **Width × Height**, **Megapixels** or **Percentage**, then enter the value. +6. Set **Color space** (**sRGB**, **P3**, **Adobe RGB**, **ProPhoto** or **Rec.2020**) if you need something other than the default. Lossy **JPEG XL** writes **Adobe RGB** and **ProPhoto** as **Rec.2020**. Tick **Lossless** to keep them. +7. Optional: set **Sharpen** for **Screen**, **Matte** or **Glossy** output, set **Metadata** to **All**, **No camera**, **Copyright** or **None**, and tick **Remove location info** or **Watermark**. +8. Next to **Folder**, click **Choose…** and pick where the files go. **Subfolder** adds a folder inside it. +9. Set **File name** to a template such as `{name}-{seq}`. Click **Tags** to see the tags you can insert. +10. Set **If file exists** to **Add number**, **Overwrite** or **Skip**. +11. On macOS, tick **Add to Apple Photos** to add the exported files to Photos as well. Type an **Album** name to put them in that album. The first time, macOS asks whether LightCraft may control Photos. +12. Click **OK**. The export shows its progress in the activity stack at the top left. + +If you did not choose a folder, the window stays open and shows an error, so your choices are not lost. + +## Reuse your settings + +- To keep a set of choices, type a name next to **Save preset** and click **Save**. Pick it again later with **Preset** at the top of the window. +- **File** > **Export with Previous** repeats your last export. It tells you to use **Export…** first if you have not exported yet. + +## Contact sheet + +To make a PDF of thumbnails, select photos and choose **File** > **Contact Sheet PDF…**. diff --git a/docs/user/guides/import-lightroom-catalog.md b/docs/user/guides/import-lightroom-catalog.md new file mode 100644 index 000000000..df26df566 --- /dev/null +++ b/docs/user/guides/import-lightroom-catalog.md @@ -0,0 +1,41 @@ +--- +title: Move over from Lightroom +description: Import a Lightroom Classic catalog into LightCraft, and learn what comes across and what does not. +order: 50 +verified: 0.7.1 +--- + +Choose **File** > **Import Lightroom Catalog…** and pick your `.lrcat` file. LightCraft reads it directly. You do not need Lightroom installed. + +## Steps + +1. Open the library you want the photos in. To start a new one, use **File** > **New Library…**. +2. Choose **File** > **Import Lightroom Catalog…**. +3. Pick your Lightroom Classic catalog, the file that ends in `.lrcat`. You can also give **File** > **Open Library…** a Lightroom catalog folder. LightCraft then offers this import instead of making a library there. +4. Wait for the import to finish. It runs in the background, with a progress row in the activity stack at the top left. A message reports how many photos were added. + +If you change your mind, one **Edit** > **Undo** reverses the changes to the catalog. + +## What comes across + +- Ratings, picks and rejects, and colour labels +- Keywords, including hierarchical ones, and XMP metadata +- Collections and collection sets, which become albums +- Virtual copies +- Develop settings that LightCraft can map + +Original files stay where they are, and LightCraft refers to them in place. Photos whose originals are missing stay in the library so you can relink them later. LightCraft never changes your Lightroom catalog or your originals. + +## What does not match + +- Develop settings are an approximation. Camera profiles, Adobe's AI models and some masking and retouch fields are not reproduced, and settings LightCraft cannot map are reported. +- Smart collections become regular albums that hold the photos they contain at the time of import. +- Lightroom's process version is not carried over. Imported photos use LightCraft's newest rendering. + +## Good to know + +- Before it changes anything, LightCraft tries to save your source settings, history and collections in a recovery archive in the library's `Interop` folder. This is best effort. If it fails, the photos are still imported with a warning. +- Importing the same catalog again does not duplicate virtual copies or collections. By default, edits you already made in LightCraft are kept. +- A big catalog can take a long time. In one report on an earlier version, 38,000 photos took about 25 minutes from a USB SSD. +- To stop the import, click ✕ on its row in the activity stack. This works while LightCraft reads the catalog. Once it starts adding the photos, it finishes. +- The browser version cannot import a catalog. diff --git a/docs/user/guides/import-photos.md b/docs/user/guides/import-photos.md new file mode 100644 index 000000000..8130272ae --- /dev/null +++ b/docs/user/guides/import-photos.md @@ -0,0 +1,33 @@ +--- +title: Import photos +description: Bring photos from a folder or card into your LightCraft library, in place or as copies. +order: 10 +verified: 0.7.1 +--- + +Use **File** > **Import Photos…** to pick files, or **File** > **Import from Folder…** to scan a whole folder. Both open the **Import Photos** window, where you choose what to bring in and how. + +## Steps + +1. Open the **File** menu and choose **Import Photos…** or **Import from Folder…**, then pick the files or the folder. +2. Look at the thumbnails. Every photo that can be imported has a checkbox. Click a photo to check or uncheck it. Shift-click sets a range. **Check All** and **Uncheck All** change every photo at once. +3. Photos already in your library are marked **Duplicate** or **In library** and start unchecked. A file LightCraft cannot read is marked **Unreadable**. A photo you deleted that is still in **Recently Deleted** is marked **Will be restored**. **In Recently Deleted** sets what happens to those: **Restore them** (the default) brings the photos back with their edits, ratings and albums, **Import as new** deletes the old record first, and **Leave them** skips them. +4. Under **Transfer**, choose how the files get into the library: + - **Add in place** keeps the files where they are. The library only points to them. Nothing is copied or moved. + - **Copy** copies the files into the library's `Originals` folder, or into a folder you pick with **Choose…** next to **Copy to**. + - **Move** does the same, then removes each original from its source once the copy is verified and catalogued. +5. With **Copy** or **Move**, set **Folders** to sort the copies **By day (YYYY/YYYY-MM-DD)**, **By month (YYYY/YYYY-MM)**, **Into one folder**, or with a **Custom template…**. Use **Rename** to give the files new names, for example `{date}_{seq:3}`. Leave it empty to keep the names. With **Copy**, **Copy as DNG** converts raw files to DNG. +6. Optional: set **Album** to add the photos to an existing album or a new one, **Preset** to apply a preset to every photo, and **Keywords** (separated by commas). +7. Click the button at the bottom. It shows the number of checked photos, for example **Import 24 Photos**. With **Move** it reads **Move 24 Photos**. + +The import runs in the background, with a progress row in the activity stack at the top left, and you can keep working while it does. The whole import is one undo step. + +## Good to know + +- Importing a folder scans it for photos. It does not add the folder to **Local**. To work in a folder without importing, use **Local** > **Browse Folder…**. +- Folder names and `{date}` use the capture time. A photo without one uses today's date. +- Raw formats LightCraft reads include DNG, Canon CR2 and CR3, Sony ARW, Nikon NEF, Fujifilm RAF, Panasonic RW2, Leica RWL, Pentax PEF and Olympus ORF. Some variants open as **Preview only**. See [A raw photo opens as "Preview only"](../troubleshooting/preview-only-raw.md). + +## Next + +[Rate, flag and label photos](cull-photos.md) to sort out the keepers. diff --git a/docs/user/guides/index.md b/docs/user/guides/index.md new file mode 100644 index 000000000..b3c656946 --- /dev/null +++ b/docs/user/guides/index.md @@ -0,0 +1,11 @@ +--- +title: Guides +description: How to do one thing in LightCraft. +order: 20 +--- + +- [Import photos](import-photos.md) +- [Rate, flag and label photos](cull-photos.md) +- [Edit a photo](edit-a-photo.md) +- [Export photos](export-photos.md) +- [Move over from Lightroom](import-lightroom-catalog.md) diff --git a/docs/user/index.md b/docs/user/index.md new file mode 100644 index 000000000..a0655a0d3 --- /dev/null +++ b/docs/user/index.md @@ -0,0 +1,26 @@ +--- +title: LightCraft +description: LightCraft is a free, open-source photo library and raw developer, an alternative to Adobe Lightroom. +verified: 0.7.1 +--- + +LightCraft is a photo library and raw developer that runs on your own computer. It is an open-source alternative to Adobe Lightroom, written in Rust, and part of the Craft App Suite from the ArtCraft team. + +You import photos, sort them with ratings, flags and albums, and edit them without touching the originals. It reads raw files from Canon, Sony, Nikon, Fujifilm, Panasonic, Leica, Pentax and Olympus cameras, and DNG. There is no account, no cloud, no telemetry and no subscription. It runs on Windows, macOS and Linux, and in a web browser. LightCraft is in alpha, so expect rough edges. + +## Start here + +- [Install LightCraft](getting-started/install.md) +- [Import photos](guides/import-photos.md) +- [Rate, flag and label photos](guides/cull-photos.md) +- [Edit a photo](guides/edit-a-photo.md) +- [Export photos](guides/export-photos.md) +- [Move over from Lightroom](guides/import-lightroom-catalog.md) +- [A raw photo opens as "Preview only"](troubleshooting/preview-only-raw.md) +- [Raw photos look dull or washed out](troubleshooting/raw-photos-look-dull.md) +- [Get help with a problem](troubleshooting/get-help.md) +- [Frequently asked questions](faq/index.md) + +## Get help + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). Say which system and camera you use, and your LightCraft version (**Help** > **About LightCraft**; on macOS, **LightCraft** > **About LightCraft**). diff --git a/docs/user/troubleshooting/get-help.md b/docs/user/troubleshooting/get-help.md new file mode 100644 index 000000000..963c58221 --- /dev/null +++ b/docs/user/troubleshooting/get-help.md @@ -0,0 +1,26 @@ +--- +title: Get help with a problem +description: What to include when you ask for help with a LightCraft bug, and where to post it. +order: 30 +verified: 0.7.1 +--- + +Open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues), or ask in [Discord](https://discord.gg/artcraft). Include your version, your system, what you did, and what you expected. + +## What to include + +1. **Your version.** Choose **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). +2. **Your system.** For example Windows 11, 64-bit. **Help** > **System Info…** shows details about your computer. +3. **The steps.** What you clicked, in order, and what happened. +4. **The camera and file type**, if the problem is with a photo. +5. **A sample file**, if a raw or other photo renders wrong. Maintainers ask for the original file, not an export. A link to a shared folder works. Uploading raws to [raw.pixls.us](https://raw.pixls.us) also helps, because every file there becomes a permanent test sample for that camera. +6. **A screenshot** of LightCraft next to the camera JPEG or the other app, for colour problems. +7. **The log file.** Choose **Help** > **Open Log Folder** and attach `lightcraft.log`. + +## Where the log is + +LightCraft writes its log to `logs/lightcraft.log` in its settings folder. On Windows that is `%APPDATA%\LightCraft\logs\`. Each start moves the previous log to `lightcraft.1.log`, and that one to `lightcraft.2.log`, so the log of a run that crashed survives the next start. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft). diff --git a/docs/user/troubleshooting/index.md b/docs/user/troubleshooting/index.md new file mode 100644 index 000000000..b82ed2e86 --- /dev/null +++ b/docs/user/troubleshooting/index.md @@ -0,0 +1,9 @@ +--- +title: Troubleshooting +description: Fixes for common LightCraft problems. +order: 30 +--- + +- [A raw photo opens as "Preview only"](preview-only-raw.md) +- [Raw photos look dull or washed out](raw-photos-look-dull.md) +- [Get help with a problem](get-help.md) diff --git a/docs/user/troubleshooting/preview-only-raw.md b/docs/user/troubleshooting/preview-only-raw.md new file mode 100644 index 000000000..b3eb4dab0 --- /dev/null +++ b/docs/user/troubleshooting/preview-only-raw.md @@ -0,0 +1,33 @@ +--- +title: A raw photo opens as "Preview only" +description: Why LightCraft shows a raw photo as Preview only, and how to make it decode after an update with Reload from Disk. +order: 10 +verified: 0.7.1 +--- + +Select the photo and choose **Photo** > **Reload from Disk**. If your version can decode that raw now, LightCraft reads the sensor data again and the **Preview only** notice goes away. + +## Symptoms + +- The **Edit** panel shows **Preview only**, with the text "LightCraft can't decode this raw variant yet" and the name of the variant. +- You updated LightCraft to a version that supports your camera, and the photo still looks the same. + +## Fix + +1. In the grid, select the photos that show **Preview only**. +2. Choose **Photo** > **Reload from Disk**. The right-click menu on a photo has the same entry. +3. Open a photo again. If **Preview only** is gone, the photo is now decoded from the raw data. + +If **Preview only** stays, your version still cannot decode that raw variant. Update to the [latest release](https://github.com/storytold/lightcraft/releases/latest) and try again, or report the file as described in [Get help with a problem](get-help.md). + +## Why this happens + +When LightCraft cannot decode a raw on import, it remembers that and shows the camera's embedded JPEG. It does not check again after an update on its own. **Reload from Disk** is the way to make it check. Compressed Olympus raws, some Canon CR3 variants and Nikon High Efficiency NEF files are examples of files that use the embedded preview. + +0.7.1 decodes Canon EOS R5 Mark II C-RAW files that earlier versions opened as **Preview only**. Photos imported before switch over after **Reload from Disk**. + +Photos shown from the preview keep the camera's picture style and white balance, because they are the camera's own JPEG. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). diff --git a/docs/user/troubleshooting/raw-photos-look-dull.md b/docs/user/troubleshooting/raw-photos-look-dull.md new file mode 100644 index 000000000..c1f6570f5 --- /dev/null +++ b/docs/user/troubleshooting/raw-photos-look-dull.md @@ -0,0 +1,40 @@ +--- +title: Raw photos look dull or washed out +description: What to do when a raw photo looks flat, grey or desaturated in LightCraft compared with the camera JPEG. +order: 20 +verified: 0.7.1 +--- + +Update to the latest LightCraft, then select the photo and choose **Photo** > **Reload from Disk**. Colour handling for raw files changed a lot between 0.4.0 and 0.7.1, and several flat-looking Sony and Fujifilm cases are fixed. Some Leica DNGs and dark, muted scenes still look dull. + +## Symptoms + +- A raw photo looks flat, grey or desaturated as soon as you open it. +- The preview or thumbnail looks right, but the opened photo does not. +- Blacks look lifted, and the shadows look grey. + +## Fix + +1. Check your version in **Help** > **About LightCraft** (on macOS, **LightCraft** > **About LightCraft**). If it is older than 0.7.1, install the [latest release](https://github.com/storytold/lightcraft/releases/latest). See [Install LightCraft](../getting-started/install.md). +2. Select the raw photo and choose **Photo** > **Reload from Disk**. +3. If it still looks dull, adjust it by hand in the **Edit** panel. See [Edit a photo](../guides/edit-a-photo.md). +4. If you think the starting look is wrong for your camera, report it with the original raw file. See [Get help with a problem](get-help.md). + +## Why this happens + +LightCraft develops a raw from the sensor data and tries to match the look of the camera's own JPEG. How well it matches depends on the camera model. + +- **Sony ARW:** on 0.5.0 and older, some Lossless Compressed M-size files had lifted blacks, and an A7 II file lost most of its colour. Both are fixed since 0.6.0. Some files still fall short on in-camera Dynamic Range Optimizer shots. +- **Dark, muted scenes:** LightCraft applies only part of the camera look to them, so they can start duller than the camera JPEG. This is not fixed in 0.7.1 ([issue 865](https://github.com/storytold/lightcraft/issues/865)). +- **Fujifilm X-Pro1 and Sony A7 IV:** reported dull on 0.4.0 and fine on 0.6.0. +- **Leica DNG:** these files carry a colour matrix but no tone curve, so LightCraft shows them darker and flatter than the camera JPEG. This is not fixed in 0.7.1 ([pull request 807](https://github.com/storytold/lightcraft/pull/807)). +- **Adobe RGB:** if the camera was set to Adobe RGB, 0.6.0 and older read the embedded JPEG as sRGB and matched to a too-dull target. Fixed in 0.7.1: LightCraft reads the JPEG in the colour space the raw file states. +- **Black-and-white picture style:** a raw shot in a monochrome picture style started about 1.3 EV darker than the same camera's colour shots. Since 0.7.1, LightCraft takes the tone from the camera's black-and-white JPEG. + +## A soft picture after zooming in + +If a large raw looks soft right after you zoom in, wait a few seconds. LightCraft first shows the fit-size picture enlarged, then renders the zoomed area from the full-resolution raw and swaps it in. On a 61 MP file this can take a few seconds. If it stays soft at **Zoom 100%** (**View** > **Zoom 100%**), report it. + +## Still stuck? + +Ask in [Discord](https://discord.gg/artcraft) or open an issue in the [issue tracker](https://github.com/storytold/lightcraft/issues). diff --git a/docs/web.md b/docs/web.md index a3f705db4..b275e837d 100644 --- a/docs/web.md +++ b/docs/web.md @@ -55,7 +55,8 @@ gzip size (5.25 MB). `wasm-opt -Oz`, when installed, shrinks it further. **Fonts.** The browser has no system fonts to fall back on, so CJK text comes from [craft-fonts](https://github.com/storytold/craft-fonts), the optional `CRAFT_FONTS_DIR` build input (`CRAFT_FONTS_DIR=../craft-fonts cargo xtask web`; release builds always set it). On wasm32 -`crates/engine/build.rs` embeds only BIZ UDPGothic Regular (UI and watermark fallback). The web +`crates/engine/build.rs` embeds BIZ UDPGothic Regular (UI and watermark fallback) and Noto Sans +Thai Regular (~214 KiB, for Thai file and folder names in every UI language). The web bundle also ships Noto Sans CJK SC Regular as `lightcraft_zh_hans.otf` and loads it before the UI starts. Keeping the 16 MB Chinese face separate leaves the module under Cloudflare's 25 MiB per-file limit (the older Japanese-only module measured 21.8 MB). Without `CRAFT_FONTS_DIR`, the diff --git a/docs/whats-new.md b/docs/whats-new.md index 1bf44c3f4..2f02ffb91 100644 --- a/docs/whats-new.md +++ b/docs/whats-new.md @@ -2,12 +2,233 @@ ## 2026-10-14 +### Model downloads time out on a name that doesn't resolve +- Model downloads (AI denoise, faces, SAM) give up on a server name that doesn't resolve within the connect timeout, + and Cancel stops a download that is still looking the name up (issue #1532). The lookup used to block with no time + limit, and each of a server's addresses then got the whole connect timeout of its own. + +### Lightroom radial masks on the right side +- Radial masks brought over from Lightroom Classic (catalog import, XMP sidecars, edits inside DNGs and presets) now + apply their effect inside the circle, as Lightroom does, instead of outside it (issue #1815). A mask inverted in + Lightroom stays inverted, and older Radial Filters keep their Effect Outside / Invert Mask setting. Photos imported + before this keep the masks they got: Photo ▸ Read Metadata from File reads a sidecar's again. + +### Delete in an album removes from the album only +- In an album, the Delete key now takes the selected photos out of the album, as in Lightroom. It used to move them + to Recently Deleted, so they also left their folder's view (issue #1805). They stay in the library and in their + folder; Edit ▸ Undo puts them back in the album. Photo ▸ Remove from Album does the same and now shows the key. + To delete photos from the library from an album, use Photo ▸ Delete Photo or right-click ▸ Delete Photo. In a smart + album, Delete changes nothing: its rules decide what it shows. + +### Local folders select a photo +- Showing a Local folder now makes its first photo the active one, as albums and the other sources do (issue + #1447), or keeps the photo you had when the folder holds it. Before, the photo from the previous view stayed active: + Photo Grid marked nothing, and Detail, the histogram and the photo info showed a photo that isn't in the folder. + +### Nikon Z 6II, Z 8 and D5300 colour profiles +- Raws from the Nikon Z 6II, Z 8 and D5300 now start from a colour profile built from a few hundred photos from each camera, the same way the + Sony and Fujifilm profiles were made, instead of fitting each photo's colour on its own. Colours that one photo + shows only a little of come out closer to the camera's JPEG. + +### Closer camera colour for lens-corrected raws +- Raws from cameras that correct their own JPEG for lens distortion (Panasonic and Fujifilm with that setting on, the + Sony A7R IVA) now start from colours closer to the camera's. The starting look was being fitted against a JPEG that + didn't line up with the raw near the edges. Photos you already imported pick this up when their previews are rebuilt. + +### HDR merges of moving lights, and many brackets at once +- A star, the moon or a light that moves between the photos of a bracket (a night bracket on a tripod) no longer + leaves a black hole where the middle photo had it clipped. With Deghost, the brightest photo's copy of it in a + dark sky is now removed too. +- Photo ▸ Photo Merge ▸ HDR for Each Bracket Set merges every bracket among the selected photos with the last HDR + settings, one after the other: each stack, and photos taken less than a second apart at different exposures. + From the command line: `lightcraft-cli merge hdr-batch FOLDER`. +- Merging uses about a quarter less memory: three 67 MP raws need 3.8 GB instead of 4.8 GB. + +### XMP sidecars catch up after a network share comes back +- With automatic XMP writing on, an edit to a photo whose folder can't be written (a network share that dropped + out, a folder that went read-only) used to be written only to the library, with a line in the log, so the sidecar + stayed behind the edit. LightCraft now says once that the sidecars are waiting, tries again every few seconds once + the files are reachable, and says when they're written. `library.retrySidecars` does it on request. + +### Reliability +- A damaged or crafted raw can no longer make LightCraft set aside gigabytes of memory before finding out the file is + bad (issue #1512): a compressed PEF whose data is too short for the size it declares, an uncompressed TIFF-based + raw whose tile claims far more samples than it holds, and an ARW6 with a broken tile are refused first. All 74 + files in the test corpus still decode. + +### Reliability +- Thumbnails and previews are written to the cache on disk by one background writer instead of a new thread for + each image (issue #1522). When renders come in faster than the disk takes them, at most 128 MB of images wait; + past that a write is skipped (the image stays in memory and is written by a later render), so a slow disk can't + pile up threads and memory. + +### Reliability +- Saving the catalog and resuming a model download no longer write through a link left at the temporary file's + name (`catalog.snap.tmp`, `model.onnx.part`): the link is removed and a new file made in its place, so the file it + pointed to is never changed (issue #1502). + +### Reliability +- A photo whose file was replaced (Reload Changed Files after an outside edit) no longer keeps the faces recognition + made from the old pixels: they are made again from the new file, and a scan still busy with the old file when it + changed doesn't put its faces on the new one (issue #1524). + +### Settings stays put when you change tabs +- The Settings window keeps its top edge and width when you switch tabs, so the tab row no longer jumps around + under the pointer. It used to be centred, so every tab of a different height moved it. + +### Layout image over the loupe +- View ▸ Loupe Overlay ▸ Choose Layout Image… draws a PNG with transparency over the photo in the loupe, such as + a magazine cover's masthead and cover lines, so you can frame a shot for the page it is made for. It fits inside + the photo, follows zoom and pan, and ⌥⌘O shows or hides it. The same menu sets its opacity and a matte that + darkens the photo outside it. The layout stays chosen across launches. + +### Smaller JPEG exports +- JPEG exports are 12–14 % smaller at the same quality, with exactly the same pixels (issue #1373): each file now + carries Huffman tables made for its own picture instead of the standard example tables. Every viewer opens them + as before. + +### Reliability +- A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer + crashes LightCraft each time it renders: the amount acts as 20. + +### Lens names for Nikon DSLR raws +- Photos from Nikon DSLRs like the D700, D750, D80 or D5300 now show which lens they were taken with, for example + "24.0-70.0 mm f/2.8". Those cameras don't write a lens name, so the lens field was empty and you couldn't filter or + search by lens. LightCraft now reads the lens range Nikon keeps in its own maker note, the way Lightroom shows a + lens it doesn't have a name for. Newly imported photos show it. + +### Import when a card is connected +- Settings → Import → Cameras and Cards has an option to open the import review as soon as a camera or memory card + is connected, the same review File → Import from Device opens. It's off by default. A card that's already in when + LightCraft starts doesn't ask, and if a dialog is open or an import is running it waits until that's done. + +### Local folders +- Photos deleted (or moved) by another app no longer stay in a Local folder, failing to load and logging an error + each time you look (issue #1392): the next time you browse the folder, in a later session, they're left out. A photo you had rated, flagged or + edited stays, marked missing, so its changes aren't lost; photos in My Photos are unchanged. + +### Full-resolution panoramas +- Panorama and HDR Panorama merges keep the resolution of their photos instead of stopping at 40 megapixels + (issue #1455), up to 512 MP and 65,000 pixels on the long edge, as in Lightroom. When the full size needs more + memory than the computer has, the panorama is made as large as fits (five 51 MP photos: up to 166 MP with + 16 GB, 355 MP with 32 GB) and the message says so. The merge also frees each photo's pixels once they are + projected, so it needs less memory than before at any size (an 81 MP stitch: 4.4 GB instead of 6.2 GB). + +### Nikon D600, D610 and D5000 +- Raws from the Nikon D600, D610 and D5000 no longer show a bright line down the right edge (issue #1627). Their + sensor data runs a few columns of filler past the picture, which stayed in the image; these bodies now develop + at the camera JPEG's size (6016 × 4016 for the D600 and D610, 4288 × 2848 for the D5000, as the D90 already + did). On the D600 and D610 the white level is also measured inside the picture, so the filler can't pass for + clipped highlights. + +### More Canon EOS R6 Mark II C-RAW files decode +- Some EOS R6 Mark II C-RAW files (about 1 in 50 in one photographer's library) opened as their preview with + "Canon CRX adaptive QP value 172" or "175". They now decode from the sensor data. Photos imported before this + switch over with Photo ▸ Reload from Disk. + +### Nikon raws without coloured edge bars +- Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top + of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914, + #1236). The D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, + Coolpix A, D3000, D40X, D60, D80, D2Xs, D200, D3200, D3100, D5000, D40, D50, D70, D70s and Nikon 1 V1 now show + the area the camera itself frames, a few pixels smaller than before. +### An empty library leads to Import +- A library with no photos now opens on the Photo Grid with the sidebar (Square Grid stays as it is), instead of a + Detail view that pointed at an empty grid. The grid and Detail view offer Import Photos… and, in the desktop app, + Import Lightroom Catalog… as buttons, and say that photos (and, in the desktop app, folders) can be dropped into + the window. The shortcut they show is the one Import Photos… has now. +- The top bar has an Import button, beside Export, in every library. + +### Reliability + +### One kind of button +- Dialogs' buttons and About's Discord link are drawn by one button component: primary (filled with the accent), + secondary, danger (filled red) and ghost, in three sizes, with an icon or without, and an icon alone (named for + screen readers). Each darkens a little when hovered and more when pressed, shows a ring where the keyboard is, and + its text reads at 4.5:1 or more on its fill in every theme. Dialog buttons are 30 points tall, like the welcome's. + +### A dialog's confirm button says it's the next step +- In dialogs, the confirm button is on the right with Cancel to its left, and filled with the accent when it makes or + brings in something (Import N Photos, Merge, Save, Create, Download…). Delete and Remove stay plain, so a destructive + choice is never the eye-catching default. +### Straight brush strokes with ⇧-click +- With the brush, click once, then ⇧-click somewhere else: the brush paints a straight line between the two, and + further ⇧-clicks carry on from the last point, as in Lightroom. Change the brush size, feather or flow before the + ⇧-click and the line tapers from the old values to the new ones. The whole line is one undo step. + `mask.brushStroke {connect: true}` does the same for agents. + ### Hard-edged Heal and Clone spots - A spot with Feather at 0 no longer draws black pixels exactly on its radius. This also fixes spots at 0% Opacity changing pixels along their edge. The hard edge now leaves pixels on the radius unchanged. +### Where the camera focused +- **View ▸ Show Focus Point** (⌥F) draws the autofocus frame the camera recorded over the photo, in Detail, Compare + and Survey, following rotation and crops; the Info panel says how it focused (`AF-C · Wide · Animal Eye Tracking`). + Lightroom has no such view. +- **Z zooms to it**: Toggle Zoom centres on the focus point, so checking that the subject is sharp is one key + (View ▸ Zoom to Focus Point turns this off). Photos without a point zoom as before. +- Sony α1 (ILCE-1) and A7S III (ILCE-7SM3) raws for now; manual-focus shots and Wide shots that only report the frame + centre show nothing. More cameras will follow. Details: [focus-points.md](focus-points.md). +- **Photos already in your library get theirs in the background**: LightCraft reads just the few kilobytes of each + file that hold the focus point (about 100 photos a second from a spinning disk), with a *Reading focus points* row + in the activity stack you can stop. Photo ▸ Read Focus Points runs it by hand. + +### Cleaner HDR merges +- Bright windows in an HDR made with Photo ▸ Photo Merge ▸ HDR no longer take a magenta tinge from the brighter + photos along the areas they clipped: right next to a clipped area, a photo's colour isn't used. It showed most when + the highlights were darkened or saturated. +- The same photos give the same HDR whatever order they are selected in; before, a different order could change a + few pixels. + +### HDR merges keep the white balance +- An HDR merged from raws that LightCraft colours from the camera's JPEG (CR2, NEF, ARW…) now opens at the same + As Shot temperature and tint as its photos, and changing the white balance no longer turns it green. Sony ARW + merges used to open at about 7000 K / +115 where the photos show 4000–5300 K / +8. HDRs merged before this keep + their old white balance; merge them again. + ## 2026-10-13 +### Edits keep up in a large library +- A small edit (a keyword, a rating, a flag, a slider) no longer has the grid's list worked out again from every + photo: only the photos the edit touched are asked about again and put at their place in the order. In a library of + 67,000 photos that step went from 37 ms to under 1 ms. Still worked out whole: an edit to more than 256 photos, a + change to an album, a stack or the keyword list, an album shown in its own order, Recently Added, Missing Photos, + and a view that asks about "the last N days". +- With no filter set, the count of the source is taken from the list instead of from a second pass over the library. +- The side panels keep a tally an edit cannot have changed: a rating or a slider changes none of the folders, + keywords, dates, people and counts; a keyword leaves the folders, the dates and the counts as they are. With both + side panels open, the frame after a keyword went from about 156 ms to about 54 ms in that library (2 ms with the + panels closed, from 71 ms). +- The count beside each smart album in the left panel follows an edit the same way: the album asks only the photos + the edit touched, where it asked every photo of the library after each edit (about 25 ms an album in that library). + With that, the frame after a keyword is at about 29 ms there. New rules, a change to an album or the keyword list, + an edit to more than 256 photos, and each new minute for an album that asks about "the last N days" are still + counted from every photo. +- Still tallied from every photo on each edit: the filter bar's lists of cameras, lenses and keywords. + +### A view can hold its photos until you refresh it +- View ▸ Hold View Until Refreshed (off unless you turn it on; kept with the library). With it on, an edit that makes + a photo stop matching the view keeps it in the view: a keyword given in a smart album of photos without keywords, + a flag taken off in Picks, a rating that falls under the filter bar's. With it off, as before, the photo leaves the + grid at once and every photo after it moves up. View ▸ Refresh View (also in the sort menu while the hold is on), + Reshuffle, another source, filter or sort, or new rules for the smart album shows what matches now. +- Photos that start matching still appear at once, and deleting a photo or taking it out of the album or folder + shown still takes it out of the view. + +### The window opens at the size you left it +- LightCraft's window opens at the size it had when you last used it, and zoomed (macOS) / maximized if it was: it + used to open at 1600 × 1000 every time. Double-click the top bar to zoom the window, quit, and it is zoomed when + you come back; un-zoom it and it goes back to the size it had before. +- Full screen is not remembered (the app opens as a window), and neither is where on the desktop the window was: + the system places it. +- On macOS the system does not tell LightCraft when a window is zoomed from the Window menu or resized after a + zoom, so it goes by the window's size, and when in doubt takes the window for a window of the size it has: it + comes back as large as you left it, but not as a zoomed window. That is so after a zoom from the Window menu + with the Dock at a side of the screen, or of a window already as wide as the screen. Zoomed by a double-click + on the top bar it comes back zoomed. The other way round, a window you drag out until it is as wide as the + screen and nearly as tall counts as zoomed. +- For scripts: a size set with `ui.resize` is remembered like any other, so a script that needs a size sets it. + ### HDR merges keep the lens correction - An HDR made with Photo ▸ Photo Merge ▸ HDR now keeps the lens correction the photos carry (Sony's, Panasonic's and Leica's, and the one some DNGs carry, e.g. from drones): Optics ▸ Enable Lens Corrections straightens it as it @@ -18,10 +239,6 @@ models in a `LightCraftData` folder beside it instead of the per-user settings folder (issue #1200), as PhotoCraft does. If that folder can't be written, the usual folder is used and the log says why. -### Reliability -- A photo whose Defringe amount was stored far past the sliders' 20 (in a damaged catalog or sidecar) no longer - crashes LightCraft each time it renders: the amount acts as 20. - ### Reliability - Importing a preset zip that unpacks to far more than any preset (a "zip bomb") is refused with a message naming the file, instead of asking for gigabytes of memory, which could end LightCraft. @@ -53,6 +270,23 @@ else (issue #1504): trimming or clearing the cache can't delete files outside it, and thumbnails are never read from or written through such a link. +### Synchronize Folder asks a network share less +- A Synchronize Folder scan no longer asks the disk about every photo of the library in the folder (is its file + there, has it a sidecar): it has just listed the folder, and reads the answers off the listing. On a network share + each of those questions is a round trip. Measured on a hard disk over SMB, for folders of 1,600 to 2,400 photos + that had not changed: 4.6 to 7.1 s of questions after the listing (about 3 ms a photo), now under 0.05 s. The + listing itself took 4 to 5 s and is as it was. +- The disk is still asked wherever a listing can't be sure what a file system would say: about a name that is + there in another case, a link, a name Windows reads another way (`aux.jpg`, a `~`, a `:`, a dot or a space at the + end), a folder the scan did not walk to its end, and, in a folder that holds any name with a character outside + ASCII (an umlaut, a curly apostrophe, a dash), about every name the folder does not list. Names it does list are + answered there too. In such a folder the scan saves less, and its checks now start after the listing where they + ran beside it. +- A photo the folder lists is there for the scan, also where its file could not be opened by its path (a folder + that may be listed but not searched, a path longer than the system takes): such photos used to be listed as + missing. And a share that drops after the scan has listed a folder no longer turns that folder's photos into + missing ones. + ### Large libraries: Synchronize Folder, import and sidecars start sooner - Opening Synchronize Folder, an import review or an import no longer makes a table of every photo in the library before it looks at the first file. In a library of 3.5 million photos that held the window for about 6 s before @@ -108,6 +342,26 @@ - An export enlarged far past the photo's size (20000 typed for 2000 with Don't Enlarge off) no longer ends LightCraft: an enlargement stops at 512 megapixels, as in Lightroom, and one that needs more memory than the computer has is refused with the size it would take, before it starts. +### Confirm matching faces together +- On a person's page, the ↑ control on a suggested face confirms it and all shown suggestions with equal or higher + similarity as one undo step. Hover or focus that face and press A for the same action; hidden suggestions stay hidden. + +### Noise reduction looks the same at every size +- Luminance and colour noise reduction no longer look much stronger in grid thumbnails and the filmstrip than in + the loupe (issue #960). Their radii are now set in the original photo's pixels, so a thumbnail, the fitted loupe + and a smaller export look like the full-size result scaled down; 1:1 views and full-size exports are unchanged. + Like in Lightroom, judge noise reduction at 1:1: at small sizes the noise it removes is mostly averaged away. + Cached thumbnails are redrawn. +- When the graphics driver resets the GPU while LightCraft is exporting or rendering (issue #1337, seen on Windows + with NVIDIA), LightCraft still stops, because its window can't keep drawing on the lost device, but it now says + "LightCraft stopped unexpectedly" and why, instead of "could not open its window", and the next start renders + on the CPU and explains how to turn the GPU back on. + +### Resetting a slider +- Resting the pointer on a slider's name or its thumb now says that a double-click resets it ("Exposure — double-click + to reset it"), in every language (issue #1436). +- A double-click on the thumb is a single step in History and Undo. Its first click no longer nudges the value to + where the pointer landed, and a single click on the thumb leaves the value alone. ### Slider drags on large raws - Dragging a slider no longer re-renders the edited photo's filmstrip thumbnail on every frame; it follows once you @@ -200,6 +454,23 @@ ## 2026-10-11 +### Keywords panel +- The titles **Keywords** and **Keyword List** in the Keywords panel fold their sections shut and open again, so + the list of every keyword is one click away however many keywords the selected photos have. What is folded is + remembered. +- Settings ▸ Interface ▸ Keywords of the selection ▸ **Grouped by parent** lays the selected photos' keywords out by + the keyword they are inside: "Places › Italy" once, on a line of its own, with Rome, Venice and the others side by + side under it. A long list stops after eight lines with "+N more", and the suggestions say only the keyword's own + name. Whole names, one chip per keyword, stays the default. +- A selection without keywords now says "No keywords yet" where its chips would be. +- Typing in **Add keyword** offers the library's matching keywords right under the box. ↓ and Return (or a click) + give the offered keyword and keep the cursor in the box for the next one; Return alone still adds what you typed. + A name the library doesn't have is marked as a new keyword before you press Return, so a misspelling no longer + becomes a keyword unnoticed. The suggestions further down stay put while you type. +- With several photos selected, the Keywords panel says how many photos its keywords belong to. The × on a keyword + is a larger target that lights up on its own, and taking a keyword off several photos at once now says which + keyword left how many photos, and that Edit ▸ Undo puts it back. + ### HDR and panorama merges of CR2 keep their colour - Photo Merge of Canon CR2 (and other raws whose colour LightCraft fits from the camera's own JPEG, such as NEF and ARW) gives the merged DNG the colour each photo has when opened alone (issue #965). An HDR merge of CR2s used to come @@ -218,6 +489,42 @@ - Compare and Reference View panes say "Rendering…" (or that the photo can't be opened) the same way, so a magnified preview isn't taken for the photo when checking focus at 1:1. +### Lens corrections +- Sony A7R VI (ILCE-7RM6) raws are corrected for lens distortion from the table the camera records in each file, + under Optics ▸ Enable Lens Corrections, on by default for newly imported photos. On 33 photos shot with the camera's + correction on (24–70 mm, 100 mm macro, 200–600 mm) the render lines up with the camera's JPEG within 0.68 px at + 1440 px at the corners, where it was up to 17 px off before. The camera's lateral chromatic aberration table is read + too, but not applied by default: our demosaic already hides most of that colour fringing, and the table on top made + it slightly worse. The vignetting table is read but not applied: the camera's own JPEGs don't apply it either. This + covers lossless, Compressed and Compressed HQ (ARW 6.0) ARWs alike. The A7R IVA (ILCE-7RM4A) has its own distortion + correction, checked against Sony's exports; other Sony bodies record the same tables, but they stay uncorrected until + they are checked the same way. Photos imported earlier get the correction with Photo ▸ Reload from Disk (when still + at their import settings). See `docs/sony-ilce7rm6-lens-corrections.md`. + +### The thumbnail cache is no longer taken for your photos +- LightCraft's cached thumbnails no longer show up in Windows Photos / Gallery or other photo apps as pictures of + yours (issues #610, #1066). They were ordinary `.jpg` files in the library's `thumbs` folder, which is usually + inside Pictures, so every thumbnail (one for each photo, size and state of its edits) was listed as a photo. + They are now kept under a name that is no picture's. The ones already there are taken over, not rendered again: + the first time a library opens after this update, "Tidying the thumbnail cache" shows in the activity stack + while they are renamed (it can be stopped, and goes on at the next open). +- Opening a library no longer rewrites the date of every thumbnail it shows, which made them all look new at each + launch. Reading a thumbnail writes nothing (but for one that went unused for a week, whose date is brought + forward); what was read is remembered for the session, so a full cache still lets go of what is not in use first. +- A `thumbs` folder that holds nothing but the cache says that it is one (`CACHEDIR.TAG`), so backup programs that + go by that can leave it out. It is safe to delete while LightCraft is closed, as before. A `thumbs` folder that + also holds files of yours (a library opened on a photo folder that had one) is not tagged, and your files in it + are left as they are. Note that files there named exactly like the cache's own (`ab/ab…` with 32 hex digits and + `.jpg`) have always been taken for the cache's, and are renamed with it. +- If you go back to an older version, it renders its thumbnails again as `.jpg`. This version then takes those + over one by one as they are used; the rest stay until the cache makes room. + +### Nikon raws without coloured edge bars +- Raws from older Nikon bodies no longer show a thin coloured or dark bar along one edge (the right edge, or the top + of a portrait photo): masked sensor columns were being developed as part of the picture (issues #976, #914). The + D7000, D5100, D800, D800E, Df, D4, D3, D3S, D700, D300, D300S, D600, D610, D810, D4S, D5600, Coolpix A, D3000, D40X, + D60, D80 and D2Xs now show the area the camera itself frames, a few pixels smaller than before. + ### JPEG XL export - Export as JPEG XL (issue #247): Format ▸ JPEG XL in the Export dialog, `format: "jxl"` for agents and the CLI (`render in.raw -o out.jxl`). Quality as for JPEG, or Lossless; Effort (Fast / Normal) trades time for a @@ -227,6 +534,16 @@ - Lossy JPEG XL files in Display P3 or Rec. 2020 now open in their real colours; saturated colours used to come in noticeably duller. +### Export next to the originals +- The Export dialog has Export to: Specific folder (as before) or Same folder as original photo, like Lightroom + Classic (issue #956). With the second, each photo is exported into the folder its original is in, plus the + Subfolder if you give one (say `Exports`), so a batch from several folders lands next to each set of originals. + "If file exists" works per folder as usual, and an original is never written over. +- A photo that has no folder on disk (a demo photo, or one in the web version's browser storage) or whose folder + is missing (moved, drive unplugged) is not exported; it gets its own error naming it. +- The choice is saved in export presets and Export with Previous. Agents pass + `destinationMode: "sameAsOriginal"` (or `"specificFolder"`) to `app.export`; `dir` is then not needed. + ### Open Library knows a Lightroom catalog - Choosing a Lightroom Classic catalog folder in File → Open Library… (or one of its `Previews.lrdata` / `.lrcat-data` folders, or the `.lrcat`) no longer makes an empty LightCraft library inside it (issue #891). The @@ -284,6 +601,88 @@ preset pickers in Import and Settings ▸ Import scroll the same way. - Importing a large collection and opening its library no longer compare every preset with every other. +### Slideshow export: PDF, JPEG and video +- View ▸ Export Slideshow… (or the Export buttons at the end of Settings ▸ Slideshow) writes the slideshow out, like + Lightroom Classic's Export PDF, Export JPEG and Export Video: the photos in view as their slides, with the layout, + overlays and the intro and ending screens, in view order. +- **PDF:** one page per slide at 1024, 1920 or 3840 pixels on the long side, at a JPEG quality you choose. Each page + carries the slide duration and fade, so a PDF viewer's presentation mode plays it as a slideshow, and "Automatically + show full screen" asks the viewer to open it that way. +- **JPEG:** numbered files (`Holiday-001.jpg`, `Holiday-002.jpg`…) in a new folder. +- **Video:** AV1 video in an MP4 (or WebM) file at 720p or 1080p, 24 or 30 frames per second: each slide stays up for + its duration and the fades are rendered frame by frame (crossfades, or fades through the fade colour). The video has + no sound yet. It is AV1 rather than Lightroom's H.264, which has no open encoder we can ship; current browsers, + VLC and recent phones and TVs play it. +- The export runs in the background with its row in the activity stack; ✕ stops it and leaves no half-written file + behind. A photo that can't be read is skipped and named in the message at the end. Agents use `slideshow.export` + (`slideshow.exportStatus` for the result). + +### Slideshow templates +- Settings ▸ Slideshow now opens with a template browser, like Lightroom Classic's Template Browser. A template holds + everything about how a slideshow looks and plays: the playback timing and fades, the layout, options and backdrop, + the overlays and the intro and ending screens. Click one to apply it; point at one to see it on the selected photo in + the preview beside it without changing anything. +- Five built-in templates of our own: Plain Black (the photo on black), Gallery Wall (a light wall, a white mat and a + soft shadow, with the caption and rating under the photo), Edge to Edge (photos cropped to fill the screen), Cinema + Wide (16:9 with black bars, the title in the corner, fades through black and a closing card) and Shooting Data (the + exposure, ISO, focal length, camera and lens under each photo on a dark backdrop). +- Save the current settings as a template of your own; right-click it to update it with the current settings, rename, + export or delete it, and import templates others share with you. Your templates are `.json` files in the settings + folder's `slideshow-templates` folder, shared by all your libraries. +- Applying a template can't be undone with Edit ▸ Undo (slideshow settings are library preferences), so the settings it + replaced are listed as "Previous settings" until you quit: click that to go back. +- A template file that can't be used (damaged, from a newer LightCraft, too large) is listed as skipped instead of + stopping the others, and a template whose identity plate image or background photo isn't there still applies, with a + note saying what is missing. Agents use `slideshow.templates.*`, and `slideshow.preview {template}` renders a template + without applying it. + +### Slideshow intro and ending screens +- A slideshow can now open with an intro screen and close with an ending screen, like Lightroom Classic's Titles + panel. Each is switched on on its own and has a background colour and, centred on it, your identity plate (the one + set under Overlays: its text or image) or a line of text of its own, in the plate's colour or one you pick, at a + scale. Each stays up for its own duration (by default the slide duration) and fades in and out like a slide. +- The intro plays once before the first photo; the ending after the last photo when Repeat is off (while repeating + the show runs until you stop it, so the ending doesn't come up). Random order never moves them, ←/→ step across them, + and the control bar says Intro or End while one is up (the photo count only counts photos). +- Set them in Settings ▸ Slideshow ▸ Titles; the preview there switches between the photo's slide, the intro and the + ending. They are saved with the library. Agents use `slideshow.titles`, and `slideshow.preview {title: "intro"}` + renders a screen as a PNG. + +### Slideshow overlays +- Slides can now carry Lightroom Classic's Slideshow overlays: an identity plate (a line of text in a colour, or an + image file such as a PNG logo with transparency, at a size and opacity, optionally drawn behind the photo), the + photo's rating as stars, and any number of text overlays (up to 16) built from tokens: `{caption}`, `{title}`, + `{file}`, `{date}`, `{exposure}` ("1/250 s at f/8"), `{iso}`, `{focal}`, `{camera}`, `{lens}`, `{rating}` and the + other file-naming tokens, with a size, colour, opacity and an optional soft shadow (opacity, offset, radius, angle). + Each sits at one of nine anchors of the slide or of the photo, moved by an offset, and never leaves the slide. +- The export watermark can be drawn on every slide's photo too ("Use export watermark" takes the one you last + exported with). +- Set them in Settings ▸ Slideshow ▸ Overlays; the slide preview there and View ▸ Slideshow show them. Sizes are a + share of the slide, so slides look the same on any screen. They are saved with the library. Agents use + `slideshow.overlays`; `slideshow.preview` now also returns where each overlay landed. + +### Slideshow layout, options and backdrop +- Slides can now be laid out like Lightroom Classic's Slideshow Layout, Options and Backdrop panels: margins (all four + linked, or each on its own), Zoom to Fill Frame (crop the photo to fill its cell), a stroke border (colour, width), + a cast shadow (opacity, offset, radius, angle), and a backdrop colour with an optional colour wash (a gradient from a + second colour, at an angle and opacity) and a background image (a photo from the library, at an opacity). +- Set them in Settings ▸ Slideshow, which now shows the selected photo as a slide as you change them (with the margin + guides, at the screen's aspect or 16:9 / 4:3). Sizes are a share of the slide, so a slide looks the same on any + screen. They are saved with the library. +- View ▸ Slideshow plays the composed slides, prepared ahead off the UI thread like before, so fades and the playback + controls work as they did. With the default layout (no margins, border, shadow or backdrop) it plays the photos as + before. Agents use `slideshow.layout`, and `slideshow.preview` returns one composed slide as a PNG. + +### Slideshow playback +- View ▸ Slideshow (⌥⌘↩) now plays with Lightroom Classic's Playback settings: how long each slide stays up, a fade + between slides (a crossfade, or through black, white or any colour; 0 cuts), random order (every photo once per + pass, a new order each time round), repeat (off: the slideshow ends after the last photo) and preparing previews in + advance (the next two slides render while the current one is up, so a fade never waits or flashes). +- Change them in Settings ▸ Slideshow (View ▸ Slideshow Options…); they are saved with the library. Agents use + `slideshow.playback`; `view.slideshow` takes any of them for one run (`interval` still sets the slide duration). +- While it plays, moving the pointer shows a small control bar: previous, play / pause, next, the slide number and + end. Space pauses, ← / → step (a cut), Esc ends. A slide's time starts once it is shown sharp. + ## 2026-10-10 ### Dialogs fit the window diff --git a/docs/xmp-interop.md b/docs/xmp-interop.md index ce235e2d3..b7bfa0038 100644 --- a/docs/xmp-interop.md +++ b/docs/xmp-interop.md @@ -147,7 +147,18 @@ Values pass through our control specs, so anything outside our slider ranges get `crs:CameraProfile` is kept as the selected name in the photo's import warning and the active profile is left as it is: the name carries no rendering data and never selects an Adobe profile. Import reports such a profile as not applied (colours may differ from Lightroom), except Lightroom's default profiles (`Adobe Standard`, `Adobe Color`), which every sidecar names and which LightCraft's own starting look stands in for. -**Not mapped:** camera profiles and looks (`CameraProfile`, `Look`; we have our own profile set), red eye, lens blur, +**Profiles named by presets (`Look`, issue #1001).** A preset keeps the profile it names (`crs:Look`: `Name`, `Group`, +`UUID`, `Amount`; in `.lrtemplate` files the `Look` table) as `profile.look` in its settings. When the user has imported +a profile of that name (`profile.import`; the name compared ignoring case and repeated spaces, the group breaking +ties), applying the preset selects it at the look's amount (`Amount` 1 = 100 %). Lightroom's basic profiles map to our +own (`Adobe Color` → Color, `Adobe Neutral`, `Adobe Vivid`, `Adobe Landscape`, `Adobe Portrait`, `Adobe Monochrome` +→ Monochrome). Any other `Adobe …` / `Camera …` profile and the numbered creative ones (`Artistic 01`, `B&W 05`, +`Modern 10`, `Vintage 02`) can't be used here, and a third-party profile not imported yet leaves the photo's profile as it +is; import reports either per preset (`Profile “Name” (Lightroom's own)` / `(not imported yet)` in `unmapped`). +References are resolved again whenever profiles are imported or deleted, so a pack's profiles and presets can come in +either order. The look's embedded `Parameters` are not read. + +**Not mapped:** camera profiles (`CameraProfile`; we have our own profile set), red eye, lens blur, process-version 2010 field names, and AI features. Local adjustments and spot removal are read as described below. ## Spot removal @@ -181,24 +192,57 @@ mask: `CorrectionName` → name, `CorrectionAmount` → Amount, inactive correct | `LocalExposure2012` | `adjust.exposure` | stored as a fraction, ×4 EV | | `LocalContrast2012`, `LocalHighlights2012`, `LocalShadows2012`, `LocalWhites2012`, `LocalBlacks2012`, `LocalClarity2012`, `LocalTexture`, `LocalDehaze`, `LocalTemperature`, `LocalTint`, `LocalSaturation`, `LocalHue`, `LocalSharpness`, `LocalLuminanceNoise`, `LocalMoire`, `LocalDefringe`, `LocalToningSaturation` | the matching `adjust.*` | stored −1..1, ×100 | | `LocalToningHue` | `adjust.color_hue` | degrees | +| other `Local*` sliders (`LocalGrain`…) | — | no counterpart in our masks: reported as `Mask: LocalGrain` when set (`LocalCurveRefineSaturation` 100 is neutral) | | `Mask/Gradient` (`FullX/Y`, `ZeroX/Y`) | linear gradient | full effect at the Full point | -| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape); `Flipped` → invert | +| `Mask/CircularGradient` (`Top/Left/Bottom/Right`, `Angle`, `Feather`, `Flipped`) | radial gradient | box fractions → long-edge radii (presets assume 3:2; sidecars use the photo's shape). `Flipped="true"` means effect inside: in `MaskGroupBasedCorrections` with `Version` 2 (what Lightroom writes for an ordinary radial) and in the legacy `CircularGradientBasedCorrections` Radial Filter, whose default (no `Flipped`) is outside. A `MaskGroupBasedCorrections` radial without `Version` is inside whatever `Flipped` says, and its `Feather` is halved (Lightroom rescales it that way when it upgrades one). Inverting is `MaskInverted` | | `Mask/Paint` (`Dabs` "d x y", `Radius`, `Flow`, `CenterWeight`, `MaskValue`) | brush | one brush component per correction; `MaskValue` ≤ 0 erases | | `Mask/Image` `MaskSubType` 1 / 2 | Subject / Sky | other AI selections are reported, not guessed | -| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; colour ranges are reported | +| `Mask/RangeMask` `Type` 2 / 3 (`LumRange`, `DepthRange`) | luminance / depth range | lightness converted to our range scale; a range at full strength at white (third `LumRange` value 1) stays open above our scene white; colour ranges are reported | | `MaskBlendMode` 0 / 1 / 2, `MaskInverted` | add / subtract / intersect, invert | | Applying a preset adds its masks to the photo's own (masks the photo already has are not added twice), and the Amount slider scales them. A sidecar's masks replace the photo's, because a sidecar holds the whole edit. Components we can't carry over are listed in `preset.import`'s `unmapped` as `Mask: <kind>`. +## Local adjustment presets (mask effects) + +`mask.importEffects {path | paths, group?, dryRun?}` (and `preset.import`, File ▸ Import Profiles & Presets…, drag & drop) +reads these into the Masking panel's Effect menu (`crates/engine/src/cmd/mask_effect_files.rs`): + +| File | How it is read | +|---|---| +| Lightroom Classic `.lrtemplate` (`%APPDATA%\Adobe\Lightroom\Local Adjustment Presets`, `~/Library/Application Support/Adobe/Lightroom/Local Adjustment Presets`) | a Lua table with `type = "LocalizedAdjustmentPreset"`; `title` (else `internalName`, else the file name) → name; `value` holds the `Local*` field names in lower camel case: `exposure2012` → `LocalExposure2012`, mapped as in the table above (`exposure`, `contrast`, `clarity` are the older process version's; the 2012 fields win); `luminanceNoise` → Noise, `toningHue` / `toningSaturation` → Color | +| Camera Raw local-correction `.xmp` (`CameraRaw/LocalCorrections`) | top-level `crs:Local*` properties (or, in a file with `crs:PresetType="Local"` without them, its first correction's); `crs:Name` → name, `crs:Group` → group. Develop presets (no `crs:Local*`) stay develop presets | +| `.lceffect` | ours: `{"format": "lightcraft.maskEffects", "version": 2, "effects": [{name, adjust, group?, favorite?, fields?, with_amount?, brush?, extra?}]}` (a bare array or one effect also reads); `mask.exportEffects {path, names?, group?}` writes it | +| `.zip` | the presets above inside it; its folders give groups | + +Groups: a file's folder (relative to the imported folder; names that only say "local adjustment presets" don't count) +names its group, unless the file names one. Lightroom users mark sections of the flat preset menu with **divider +presets**: presets that set nothing, named like `10 ----- DETAIL ENHANCERS ----- 10` (a run of three or more `-`, `=`, +`_`, `*`, `#`, `~`, `—`, `.`). These become groups instead of effects: a preset whose name starts with a number goes to +the divider with the largest number not above it (`12 Sky` under `10 ----- DETAIL ENHANCERS ----- 10` → *Detail +Enhancers*); presets without a number keep their folder's group. An earlier import's divider effects are removed. +Same-named effects are replaced (a star is kept). + +Settings with no local slider here are listed per preset in `unmapped` (and the toast) and **kept** with the effect in +`extra` (an `.lceffect` export carries them), but not applied: `grain` (Grain; LightCraft has no local grain), +`pointColors` (Point Color: `SrcHue/Sat/Lum`, `HueShift`, `SatScale`, `LumScale`, `RangeAmount`, `Hue/Sat/LumRange` +`{LowerNone, LowerFull, UpperFull, UpperNone}`; there is global Point Color only), `toningLuminance`. Zero values aren't +listed (`refineSaturation = 0` in most files). Lightroom keeps brush size, feather, flow and density per brush, not in +presets, so there is nothing to import for them; an effect saved here can carry them (`brush`). + +Applying (`mask.applyEffect {name, id? | ids? | all?, photos?: "selected"}`) sets the sliders the effect carries — +all of them for an imported preset (unnamed ones to 0, as Lightroom does), or only the ones chosen when it was saved +(`mask.saveEffect {fields: "changed" | [..]}`) — and Amount only if it carries it. The effect is remembered for the +mask's kind: the next brush / linear / radial / … mask starts with it (`mask.toolEffect`). + ## Preset files | | | |---|---| | Ours: `.lcpreset` | JSON `{"format": "lightcraft.preset", "version": 1, "presets": [{id, name, group, settings}]}`, where `settings` is a partial develop-settings object (only the groups the preset includes). A file can hold one preset or many, and every preset keeps its group. Import also accepts a bare preset object or an array of them. | | Export | `preset.export {path, ids?, group?}`: all user presets by default, or the given ids or one group. In the app: File ▸ Export Presets…, Presets panel ▸ ⋯ ▸ Export User Presets…, or right-click a group ▸ Export Group…. | -| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, `Look`, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | +| Import | `preset.import {paths, group?, dryRun?}`: files or folders (recursive): `.lcpreset`, `.xmp`, classic `.lrtemplate` (a Lua table: `value.settings` holds the same field names as `crs:`), photos that carry their edits in XMP ("DNG presets" from mobile apps; their crop, geometry and custom white balance are left out) and `.zip` bundles of any of these. Local adjustment (brush) presets — Lightroom Classic's `.lrtemplate` with `type = "LocalizedAdjustmentPreset"`, Camera Raw's local-correction `.xmp`, our `.lceffect`, also inside zips — become mask effects (`mask.effects`) instead (see *Local adjustment presets* below); the result lists them under `effects`, and divider presets under `effectDividers`. Presets in a folder (or a folder inside a zip) go to a group named after it, unless the file names its own group. The result lists, per preset, the settings that couldn't be carried over (`unmapped`, e.g. `CameraProfile`, a profile named by `Look` that isn't imported, local masks), and the app's toast names them; fields that only describe the preset or the photo (`Cluster`, `SortName`, `Description`, `SupportsAmount2`, `RequiresRGBTables`, `CropConstrainToWarp`, `OverrideLookVignette`, `AsShotTemperature` / `AsShotTint`, empty `PointColors` slots) aren't listed. Older (process version 2010) fields — `Exposure`, `Contrast`, `FillLight`, `HighlightRecovery`, `Shadows`, `Brightness`, `Clarity`, `ToneCurve` — are approximated with today's sliders when a preset has no 2012-era fields. Dropping preset files on the window imports them too. In the app: File ▸ Import Presets…, or Presets panel ▸ ⋯ ▸ Import Presets…. A preset that's already there (same name, group and settings) is skipped. If an id clashes, the import gets a fresh `user.*` id, and built-in presets are never replaced. | | XMP presets | Read with the `crs:` table above, with `crs:Name` as the name (falling back to the file name) and `crs:Group` as the group (falling back to "Imported Presets"). Only the fields the preset sets are included, so applying it leaves everything else alone and the Amount slider scales it like any other preset. We only read XMP presets; we don't write them. | LightCraft ships no third-party presets. Its built-in presets are its own values (`crates/engine/src/presets.rs`). diff --git a/flake.lock b/flake.lock index ecf87d370..a9953fd12 100644 --- a/flake.lock +++ b/flake.lock @@ -3,17 +3,17 @@ "craft-fonts": { "flake": false, "locked": { - "lastModified": 1791311399, - "narHash": "sha256-4E9OrthE5z39xFXpvpwn4Fn6RH3wX2sF56rZZejZT+I=", + "lastModified": 1791722036, + "narHash": "sha256-mNnDzBcOp1X10dzmiW0iBAkb74QjuzMO4slWudxXCTE=", "owner": "storytold", "repo": "craft-fonts", - "rev": "8dcdacd5153e64560d109541a47d806f26f048c0", + "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", "type": "github" }, "original": { "owner": "storytold", "repo": "craft-fonts", - "rev": "8dcdacd5153e64560d109541a47d806f26f048c0", + "rev": "3d9ab3a132ed0eff457e424226a435a901da30d3", "type": "github" } }, diff --git a/flake.nix b/flake.nix index e12b1f8af..51dd933f9 100644 --- a/flake.nix +++ b/flake.nix @@ -12,7 +12,7 @@ # glyphs. Pinned to the revision the release workflow uses — bump deliberately # (`nix flake update craft-fonts`); craftrules standards/fonts.md. craft-fonts = { - url = "github:storytold/craft-fonts/8dcdacd5153e64560d109541a47d806f26f048c0"; + url = "github:storytold/craft-fonts/3d9ab3a132ed0eff457e424226a435a901da30d3"; flake = false; }; }; diff --git a/packaging/check-app-specific.sh b/packaging/check-app-specific.sh new file mode 100644 index 000000000..c14ca5725 --- /dev/null +++ b/packaging/check-app-specific.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# App-specific installer and package assertions retained alongside shared packaging lint. +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +for want in '<UIRef Id="LightcraftUI" />' 'Value="InstallDirDlg"' '<Property Id="WIXUI_INSTALLDIR" Value="INSTALLFOLDER" />' \ + 'Key="Software\LightCraft" Name="InstallFolder"' 'Value="LaunchLightcraft"'; do + grep -qF "$want" packaging/windows/lightcraft.wxs || { echo "::error::lightcraft.wxs lost: $want"; exit 1; } +done +grep -qF '<DialogRef Id="ExitDialog" />' packaging/windows/lightcraft.wxs \ + || { echo "::error::ExitDialog is not referenced; installs and removes end without a completion dialog"; exit 1; } + +python3 - <<'PYEOF' +import yaml +manifests = [] +for name in ("ai.storyteller.lightcraft.yml", "ai.storyteller.lightcraft.bundle.yml"): + with open("packaging/linux/flatpak/" + name) as source: + manifest = yaml.safe_load(source) + assert manifest["id"] == "ai.storyteller.lightcraft" and manifest["command"] == "lightcraft", name + manifests.append(manifest) +for key in ("runtime", "runtime-version", "sdk", "finish-args"): + assert manifests[0][key] == manifests[1][key], f"Flatpak manifests disagree on {key}" +print("LightCraft package identity ok") +PYEOF diff --git a/packaging/fonts.sh b/packaging/fonts.sh new file mode 100644 index 000000000..70d9fe14e --- /dev/null +++ b/packaging/fonts.sh @@ -0,0 +1,31 @@ +# shellcheck shell=bash +# Font setup for local packaging; sourced after packaging/env.sh. +prepare_fonts() { + local font_dir="${CRAFT_FONTS_DIR:-$ROOT/../craft-fonts}" + # Relative overrides are documented relative to the workspace, regardless of the caller's cwd. + case "$font_dir" in + /*) ;; + *) font_dir="$ROOT/$font_dir" ;; + esac + + if [ -z "${CRAFT_FONTS_DIR:-}" ] && [ ! -e "$font_dir" ]; then + # Same revision as release.yml. Never update or reset an existing checkout. + local revision=3d9ab3a132ed0eff457e424226a435a901da30d3 + echo "==> downloading craft-fonts ($revision) to $font_dir" + if ! git clone --no-checkout https://github.com/storytold/craft-fonts "$font_dir" || + ! git -C "$font_dir" checkout --detach "$revision"; then + echo "error: could not prepare craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR" >&2 + return 1 + fi + fi + + if [ ! -f "$font_dir/fonts/manifest.txt" ]; then + echo "error: missing $font_dir/fonts/manifest.txt; set CRAFT_FONTS_DIR to a complete craft-fonts checkout" >&2 + return 1 + fi + CRAFT_FONTS_DIR="$(cd "$font_dir" && pwd)" || return 1 + export CRAFT_FONTS_DIR + # Fail the build if required faces are missing, rather than ship empty glyph boxes. + export CRAFT_FONTS_REQUIRED=1 + echo "==> embedding fonts from $CRAFT_FONTS_DIR" +} diff --git a/packaging/linux/ai.storyteller.lightcraft.desktop b/packaging/linux/ai.storyteller.lightcraft.desktop index f827c5820..044c9ca63 100644 --- a/packaging/linux/ai.storyteller.lightcraft.desktop +++ b/packaging/linux/ai.storyteller.lightcraft.desktop @@ -9,4 +9,4 @@ Terminal=false Categories=Graphics;2DGraphics;RasterGraphics;Photography; Keywords=photo;raw;camera;library;develop; StartupWMClass=ai.storyteller.lightcraft -MimeType=image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; +MimeType=image/heic;image/heif;image/jpeg;image/png;image/tiff;image/webp;image/x-adobe-dng;image/x-sony-arw;image/x-canon-cr2;image/x-canon-cr3;image/x-nikon-nef;image/x-fuji-raf;image/x-olympus-orf;image/x-panasonic-rw2;image/x-panasonic-rw;image/x-pentax-pef; diff --git a/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in b/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in index 8964e76e9..119aaae9b 100644 --- a/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in +++ b/packaging/linux/ai.storyteller.lightcraft.metainfo.xml.in @@ -35,8 +35,8 @@ </keywords> <screenshots> <screenshot type="default"> - <caption>Editing The Great Wave off Kanagawa with type layers and adjustment layers</caption> - <image>https://raw.githubusercontent.com/storytold/lightcraft/main/docs/images/lightcraft-demo.jpg</image> + <caption>Photo editing with The Tetons and the Snake River by Ansel Adams</caption> + <image type="source">https://raw.githubusercontent.com/storytold/lightcraft/3b4279fc2485bb51847c0071a1b3c514aa0f98f4/docs/images/hero-tetons.jpg</image> </screenshot> </screenshots> <branding> diff --git a/packaging/linux/nfpm.yaml b/packaging/linux/nfpm.yaml index e7103f7f3..47543d01d 100644 --- a/packaging/linux/nfpm.yaml +++ b/packaging/linux/nfpm.yaml @@ -43,9 +43,16 @@ overrides: - mesa-vulkan-drivers - xdg-desktop-portal rpm: + # Library sonames rather than package names, so the same requirements resolve on Fedora/RHEL + # and openSUSE (which has no libxkbcommon, vulkan-loader or libglvnd-egl package). The glibc + # floor names libm because the binaries' GLIBC_2.35 symbols (hypot, hypotf) are libm's: RHEL 9's + # glibc 2.34 provides libc.so.6(GLIBC_2.35) through backports, but not this, so it refuses. depends: - - libxkbcommon - - (vulkan-loader or libglvnd-egl) + - libc.so.6()(64bit) + - libm.so.6(GLIBC_2.35)(64bit) + - libgcc_s.so.1()(64bit) + - libxkbcommon.so.0()(64bit) + - (libvulkan.so.1()(64bit) or libEGL.so.1()(64bit)) recommends: - libwayland-client - libX11 diff --git a/packaging/linux/package.sh b/packaging/linux/package.sh index b5e1d9457..383c20f07 100755 --- a/packaging/linux/package.sh +++ b/packaging/linux/package.sh @@ -8,6 +8,7 @@ # $DIST/lightcraft-<version>-linux-<arch>.tar.gz plain FHS-style tree (bin/, share/) # # Usage: packaging/linux/package.sh [--skip-build] [--formats "appimage deb rpm tar"] +# Fonts: use CRAFT_FONTS_DIR or ../craft-fonts; download the pinned checkout if absent. # # Needs: cargo; nfpm for deb/rpm (https://nfpm.goreleaser.com); appimagetool for the AppImage # (downloaded into $CARGO_TARGET_DIR if missing). Build on an old distro (CI: Ubuntu 22.04, @@ -45,6 +46,9 @@ BASENAME="lightcraft-$VERSION-linux-$ARCH" echo "==> LightCraft $VERSION for Linux $ARCH ($FORMATS)" if [ "$SKIP_BUILD" = 0 ]; then + # shellcheck source=../fonts.sh + . "$ROOT/packaging/fonts.sh" + prepare_fonts (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif \ ${CROSS_TARGET:+--target "$CROSS_TARGET"}) fi diff --git a/packaging/macos/package.sh b/packaging/macos/package.sh index b26aa96d6..f26237a4c 100755 --- a/packaging/macos/package.sh +++ b/packaging/macos/package.sh @@ -5,6 +5,7 @@ # $DIST/lightcraft-cli-<version>-macos-<arch>.zip the headless CLI # # Usage: packaging/macos/package.sh [--arch universal|aarch64|x86_64] [--skip-build] +# Fonts: use CRAFT_FONTS_DIR or ../craft-fonts; download the pinned checkout if absent. # # Signing (env): # MACOS_SIGN_IDENTITY codesign identity (name or SHA-1). Default "-" = ad-hoc (local testing; @@ -56,6 +57,9 @@ echo "==> LightCraft $VERSION for macOS ($ARCH), identity: $IDENTITY, notarize: # ---- build ------------------------------------------------------------------------------------- if [ "$SKIP_BUILD" = 0 ]; then + # shellcheck source=../fonts.sh + . "$ROOT/packaging/fonts.sh" + prepare_fonts args=() for t in "${TARGETS[@]}"; do args+=(--target "$t"); done (cd "$ROOT" && cargo build --release --locked -p lightcraft -p lightcraft-cli --features lightcraft/heif,lightcraft-cli/heif "${args[@]}") diff --git a/packaging/web/package.sh b/packaging/web/package.sh index 64b6d2149..16691a6ba 100755 --- a/packaging/web/package.sh +++ b/packaging/web/package.sh @@ -12,6 +12,15 @@ set -euo pipefail HERE="$ROOT/packaging/web" if [ "${1:-}" != "--skip-build" ]; then + # CI (the shared release workflow installs only trunk): install the wasm-bindgen CLI at the + # version in Cargo.lock. Locally, `cargo xtask web` says which version to install. + if [ -n "${CI:-}" ]; then + WASM_BINDGEN_VERSION="$(awk '$0 == "name = \"wasm-bindgen\"" { getline; gsub(/version = |"/, ""); print; exit }' "$ROOT/Cargo.lock")" + [ -n "$WASM_BINDGEN_VERSION" ] || { echo "error: wasm-bindgen not found in Cargo.lock" >&2; exit 1; } + if ! { command -v wasm-bindgen >/dev/null && wasm-bindgen --version | grep -qF " $WASM_BINDGEN_VERSION"; }; then + cargo install wasm-bindgen-cli --version "$WASM_BINDGEN_VERSION" --locked + fi + fi (cd "$ROOT" && cargo xtask web) fi diff --git a/packaging/windows/font-licences.ps1 b/packaging/windows/font-licences.ps1 new file mode 100644 index 000000000..3fd143adb --- /dev/null +++ b/packaging/windows/font-licences.ps1 @@ -0,0 +1,22 @@ +# Keep the upstream licence texts intact, including the copyright notices above the OFL. +function Write-FontLicences([string] $Destination, [string] $InterLicence, [string] $CraftFontsDir) { + if ($env:CRAFT_FONTS_REQUIRED -and -not $CraftFontsDir) { throw 'CRAFT_FONTS_REQUIRED is set but CRAFT_FONTS_DIR is missing' } + $parts = [Collections.Generic.List[string]]::new() + $parts.Add("LightCraft font licences`n") + $parts.Add([IO.File]::ReadAllText($InterLicence)) + if ($CraftFontsDir) { + $manifest = Join-Path $CraftFontsDir 'fonts\manifest.txt' + $licences = Get-Content -LiteralPath $manifest | ForEach-Object { + if ($_.Trim() -and -not $_.Trim().StartsWith('#')) { + $fields = $_ -split '\|' + if ($fields.Count -lt 6) { throw "Malformed font manifest line: $_" } + $fields[5].Trim() + } + } | Sort-Object -Unique + foreach ($licence in $licences) { + $parts.Add("`n--- $licence ---`n") + $parts.Add([IO.File]::ReadAllText((Join-Path $CraftFontsDir $licence))) + } + } + [IO.File]::WriteAllText($Destination, ($parts -join "`n"), [Text.UTF8Encoding]::new($false)) +} diff --git a/packaging/windows/fonts.ps1 b/packaging/windows/fonts.ps1 new file mode 100644 index 000000000..74f383fd8 --- /dev/null +++ b/packaging/windows/fonts.ps1 @@ -0,0 +1,24 @@ +# Font setup for local packaging; dot-sourced by package.ps1. +function Initialize-CraftFonts([string] $WorkspaceRoot) { + $fontDir = $env:CRAFT_FONTS_DIR + if (-not $fontDir) { $fontDir = Join-Path $WorkspaceRoot '../craft-fonts' } + if (-not [IO.Path]::IsPathRooted($fontDir)) { $fontDir = Join-Path $WorkspaceRoot $fontDir } + + if (-not $env:CRAFT_FONTS_DIR -and -not (Test-Path -LiteralPath $fontDir)) { + # Same revision as release.yml and packaging/fonts.sh. Existing checkouts stay untouched. + $revision = '3d9ab3a132ed0eff457e424226a435a901da30d3' + Write-Output "==> downloading craft-fonts ($revision) to $fontDir" + & git clone --no-checkout https://github.com/storytold/craft-fonts $fontDir + if ($LASTEXITCODE -ne 0) { throw 'Could not download craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' } + & git -C $fontDir checkout --detach $revision + if ($LASTEXITCODE -ne 0) { throw 'Could not check out craft-fonts; provide a complete checkout with CRAFT_FONTS_DIR' } + } + + $manifest = Join-Path $fontDir 'fonts/manifest.txt' + if (-not (Test-Path -LiteralPath $manifest -PathType Leaf)) { + throw "Missing $manifest; set CRAFT_FONTS_DIR to a complete craft-fonts checkout" + } + $env:CRAFT_FONTS_DIR = (Resolve-Path -LiteralPath $fontDir).Path + $env:CRAFT_FONTS_REQUIRED = '1' + Write-Output "==> embedding fonts from $env:CRAFT_FONTS_DIR" +} diff --git a/packaging/windows/lightcraft.wxs b/packaging/windows/lightcraft.wxs index 38fe8f391..533c24d20 100644 --- a/packaging/windows/lightcraft.wxs +++ b/packaging/windows/lightcraft.wxs @@ -206,6 +206,9 @@ <Component Id="LightcraftCli"> <File Id="LightcraftCliExe" Source="$(var.BinDir)\lightcraft-cli.exe" KeyPath="yes" /> </Component> + <Component Id="LightcraftFontLicences"> + <File Id="LightcraftFontLicencesFile" Source="$(var.BinDir)\FONT-LICENCES.txt" KeyPath="yes" /> + </Component> </ComponentGroup> </Fragment> </Wix> diff --git a/packaging/windows/package.ps1 b/packaging/windows/package.ps1 index e2cd2a8e7..50aa77ea8 100644 --- a/packaging/windows/package.ps1 +++ b/packaging/windows/package.ps1 @@ -11,6 +11,8 @@ zip needs the Visual C++ redistributable. Signing is delegated to sign.ps1 (skipped with a warning when no signing secrets are set). + Fonts: uses CRAFT_FONTS_DIR or ../craft-fonts; downloads the pinned checkout if absent. + Needs: Rust (MSVC toolchain + the target), the Windows SDK (rc.exe, signtool.exe), and WiX v5: dotnet tool install --global wix --version 5.0.2 The WiX UI and Util extensions (installer dialogs, "Launch LightCraft" on Finish) are added to @@ -81,6 +83,8 @@ if (-not $env:LIGHTCRAFT_BUILD_DATE) { $env:LIGHTCRAFT_BUILD_DATE = (Get-Date).T Write-Output "LightCraft $Version for Windows $Arch ($Target)" if (-not $SkipBuild) { + . (Join-Path $PSScriptRoot 'fonts.ps1') + Initialize-CraftFonts $Root # Static CRT: no VC++ redistributable needed. Scoped to the target so host build scripts and # proc-macros are unaffected. $flagVar = 'CARGO_TARGET_' + ($Target.ToUpper() -replace '-', '_') + '_RUSTFLAGS' @@ -112,6 +116,9 @@ $Stage = Join-Path $TargetDir "windows-package\$Arch" Remove-Item -Recurse -Force $Stage -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Stage | Out-Null Copy-Item (Join-Path $Bin 'lightcraft.exe'), (Join-Path $Bin 'lightcraft-cli.exe') $Stage +# Include the embedded fonts' unmodified OFL texts in both installer and portable releases. +. (Join-Path $PSScriptRoot 'font-licences.ps1') +Write-FontLicences (Join-Path $Stage 'FONT-LICENCES.txt') (Join-Path $Root 'assets\fonts\OFL-Inter.txt') $env:CRAFT_FONTS_DIR & (Join-Path $PSScriptRoot 'sign.ps1') (Join-Path $Stage 'lightcraft.exe') (Join-Path $Stage 'lightcraft-cli.exe') @@ -147,6 +154,7 @@ $Portable = Join-Path $TargetDir "windows-package\lightcraft-$Version-windows-$A Remove-Item -Recurse -Force $Portable -ErrorAction SilentlyContinue New-Item -ItemType Directory -Force -Path $Portable | Out-Null Copy-Item (Join-Path $Stage '*.exe') $Portable +Copy-Item (Join-Path $Stage 'FONT-LICENCES.txt') $Portable foreach ($f in 'README.md', 'LICENSE', 'LICENSE-MIT', 'LICENSE-APACHE', 'NOTICE') { $p = Join-Path $Root $f if (Test-Path $p) { Copy-Item $p $Portable } @@ -163,7 +171,15 @@ Compress-Archive -Path $Portable -DestinationPath $Zip # Smoke-test the CLI when this machine can run it. An ARM64 build made on an x64 runner can't run # here; .github/workflows/windows-arm64.yml installs and runs it on ARM64 instead. -$HostArch = [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture.ToString().ToLowerInvariant() +# Windows PowerShell 5.1 can expose OSArchitecture as null. Use Windows' environment +# variables instead; PROCESSOR_ARCHITEW6432 identifies the OS when the shell is emulated. +$nativeArchitecture = if ($env:PROCESSOR_ARCHITEW6432) { $env:PROCESSOR_ARCHITEW6432 } else { $env:PROCESSOR_ARCHITECTURE } +$HostArch = switch ($nativeArchitecture) { + 'AMD64' { 'x64' } + 'x86' { 'x86' } + 'ARM64' { 'arm64' } + default { 'unknown' } +} if ($Arch -ne 'arm64' -or $HostArch -eq 'arm64') { Invoke-Native 'lightcraft-cli --version' { & (Join-Path $Stage 'lightcraft-cli.exe') --version } } else { diff --git a/xtask/src/ci_report.rs b/xtask/src/ci_report.rs new file mode 100644 index 000000000..dfbd9f5f9 --- /dev/null +++ b/xtask/src/ci_report.rs @@ -0,0 +1,599 @@ +//! What a `cargo xtask ci` run covered on this machine: the capability probe (OS, CPUs, RAM, GPU +//! adapters per backend, corpus, craft-fonts), each step's result and time, and the tests that +//! skipped for want of hardware or data. Written to `target/ci/report.json` and +//! `target/ci/summary.md` (a block to paste into a PR) and printed after the CI summary. +//! +//! Tests record skips with `lightcraft_testkit::skip!`; each test step points +//! `LIGHTCRAFT_SKIP_LOG` at `target/ci/skips-<step>.jsonl`. The GPU adapters come from the ignored +//! `gpu_probe` test of `lightcraft-gpu` (xtask has no wgpu). Nothing here is sent anywhere. +//! +//! The report holds no host name, user name or absolute path: the workspace root and the home +//! directory are cut from skip reasons. + +use std::collections::BTreeMap; +use std::fmt::Write as _; +use std::path::{Path, PathBuf}; +use std::process::Command; + +use serde_json::{Value, json}; + +/// Bumped when a field changes meaning. +pub const SCHEMA: u64 = 1; + +/// The environment variable `lightcraft-testkit` appends skips to. +pub const SKIP_LOG_ENV: &str = "LIGHTCRAFT_SKIP_LOG"; + +/// `target/ci` under the workspace root (not `CARGO_TARGET_DIR`: one place to look, like +/// `target/bench`). +pub fn dir(root: &Path) -> PathBuf { + root.join("target").join("ci") +} + +/// Create `target/ci` and remove the previous run's skip logs, report and summary. +pub fn prepare(root: &Path) -> Result<PathBuf, String> { + let dir = dir(root); + std::fs::create_dir_all(&dir).map_err(|e| format!("{}: {e}", dir.display()))?; + for entry in std::fs::read_dir(&dir).map_err(|e| format!("{}: {e}", dir.display()))?.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + if (name.starts_with("skips-") && name.ends_with(".jsonl")) || name == "report.json" || name == "summary.md" { + let _ = std::fs::remove_file(entry.path()); + } + } + Ok(dir) +} + +/// The skip log of one step. +pub fn skip_log(root: &Path, step: &str) -> PathBuf { + let safe: String = step.chars().map(|c| if c.is_ascii_alphanumeric() || c == '-' { c } else { '-' }).collect(); + dir(root).join(format!("skips-{safe}.jsonl")) +} + +fn output(program: &str, args: &[&str], cwd: Option<&Path>) -> Option<String> { + let mut c = Command::new(program); + c.args(args); + if let Some(cwd) = cwd { + c.current_dir(cwd); + } + let out = c.output().ok()?; + out.status.success().then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned()) +} + +/// Commit, branch and whether the work tree has uncommitted changes. +pub fn git(root: &Path) -> Value { + let commit = output("git", &["rev-parse", "HEAD"], Some(root)); + let branch = output("git", &["rev-parse", "--abbrev-ref", "HEAD"], Some(root)); + let dirty = output("git", &["status", "--porcelain"], Some(root)).map(|s| !s.is_empty()); + json!({"commit": commit, "branch": branch, "dirty": dirty}) +} + +/// OS, version, architecture, CPUs and RAM. +pub fn machine(ram_total_gb: Option<u64>, ram_available_mb: Option<u64>) -> Value { + json!({ + "os": std::env::consts::OS, + "os_version": os_version(), + "arch": std::env::consts::ARCH, + "cpus": std::thread::available_parallelism().map(|n| n.get()).ok(), + "ram_total_gb": ram_total_gb, + "ram_available_mb": ram_available_mb, + }) +} + +fn os_version() -> Option<String> { + if cfg!(target_os = "linux") { + let release = std::fs::read_to_string("/etc/os-release") + .ok() + .and_then(|s| s.lines().find_map(|l| l.strip_prefix("PRETTY_NAME=")).map(|v| v.trim_matches('"').to_owned())); + let kernel = output("uname", &["-r"], None); + match (release, kernel) { + (Some(r), Some(k)) => Some(format!("{r}, kernel {k}")), + (r, k) => r.or(k), + } + } else if cfg!(target_os = "macos") { + output("sw_vers", &["-productVersion"], None).map(|v| format!("macOS {v}")) + } else if cfg!(windows) { + output("cmd", &["/C", "ver"], None) + } else { + output("uname", &["-sr"], None) + } +} + +/// `rustc -V` of the toolchain cargo uses. +pub fn toolchain() -> Option<String> { + output(&std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()), &["-V"], None) +} + +/// Test data present: how many of the pinned raw samples, other corpus files, craft-fonts. +pub fn data(root: &Path, pinned_raw: &[&str]) -> Value { + let corpus = std::env::var_os("LIGHTCRAFT_CORPUS").map(PathBuf::from).unwrap_or_else(|| root.join("corpus")); + let count = |sub: &str| -> usize { std::fs::read_dir(corpus.join(sub)).map(|d| d.flatten().filter(|e| e.path().is_file()).count()).unwrap_or(0) }; + let pinned = pinned_raw.iter().filter(|name| corpus.join("raw").join(name).is_file()).count(); + let fonts = std::env::var_os("CRAFT_FONTS_DIR").filter(|d| !d.is_empty()).map(PathBuf::from); + json!({ + "corpus": { + "custom_dir": std::env::var_os("LIGHTCRAFT_CORPUS").is_some(), + "raw_pinned": pinned, + "raw_pinned_total": pinned_raw.len(), + "raw_files": count("raw"), + "images": count("images"), + "pngsuite": count("pngsuite"), + }, + "craft_fonts": { + "set": fonts.is_some(), + // crates/engine/build.rs embeds the fonts of `fonts/manifest.txt` (a relative dir may mean the root) + "found": fonts.as_deref().is_some_and(|d| [d.to_path_buf(), root.join(d)].iter().any(|d| d.join("fonts/manifest.txt").is_file())), + }, + }) +} + +/// Backends wgpu can probe, in the order reports list them. +const BACKENDS: [&str; 4] = ["vulkan", "metal", "dx12", "gl"]; + +/// Run `gpu_probe` (the pipeline's device), then once per backend: the backends the pipeline may +/// use, or with `all` every backend in the build (`--hardware`; on Windows that loads the Vulkan +/// driver, which the app avoids by default, issue #136). +pub fn gpu(mut probe: impl FnMut(Option<&str>) -> Result<String, String>, all: bool) -> Value { + let pipeline = match probe(None).and_then(|s| first_json(&s)) { + Ok(v) => v.get("pipeline").cloned().unwrap_or(Value::Null), + Err(e) => return json!({"error": e}), + }; + let names = |key: &str| -> Vec<String> { + pipeline[key].as_array().map(|a| a.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default() + }; + let (compiled, used) = (names("compiled"), names("backends")); + let mut backends = Vec::new(); + for b in BACKENDS.iter().filter(|b| compiled.iter().any(|c| c == *b) && (all || used.iter().any(|u| u == *b))) { + backends.push(match probe(Some(b)).and_then(|s| first_json(&s)) { + Ok(v) => v, + Err(e) => json!({"backend": b, "adapters": [], "error": e}), + }); + } + json!({"pipeline": pipeline, "backends": backends}) +} + +/// The JSON of the `gpu_probe: {…}` line in the probe's output (libtest prints around it). +fn first_json(s: &str) -> Result<Value, String> { + let line = s.lines().find_map(|l| l.trim().strip_prefix("gpu_probe: ")).ok_or_else(|| format!("gpu_probe printed no result: {s:?}"))?; + serde_json::from_str(line).map_err(|e| format!("gpu_probe: bad JSON ({e}): {line}")) +} + +/// Backends with at least one hardware (not software) adapter: `--hardware` runs the GPU tests +/// on each. +pub fn hardware_backends(gpu: &Value) -> Vec<(String, String)> { + let mut out = Vec::new(); + for b in gpu["backends"].as_array().into_iter().flatten() { + let Some(name) = b["backend"].as_str() else { continue }; + let hw = b["adapters"].as_array().into_iter().flatten().find(|a| a["type"].as_str().is_some_and(|t| t != "Cpu")); + if let Some(a) = hw { + out.push((name.to_owned(), a["name"].as_str().unwrap_or("?").to_owned())); + } + } + out +} + +/// One test that skipped, possibly in several steps. +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Skip { + pub need: String, + pub reason: String, + pub test: String, + pub binary: String, + pub location: String, + pub steps: Vec<String>, +} + +/// The skips of every `skips-<step>.jsonl` in `dir`, deduplicated (the HEIF step reruns some test +/// binaries), sorted by need, then reason. Unreadable lines are dropped. +pub fn read_skips(dir: &Path, root: &Path) -> Vec<Skip> { + let mut by_key: BTreeMap<(String, String, String, String, String), Vec<String>> = BTreeMap::new(); + let Ok(entries) = std::fs::read_dir(dir) else { return Vec::new() }; + let mut files: Vec<PathBuf> = entries.flatten().map(|e| e.path()).collect(); + files.sort(); + for path in files { + let name = path.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default(); + let Some(step) = name.strip_prefix("skips-").and_then(|n| n.strip_suffix(".jsonl")) else { continue }; + let Ok(text) = std::fs::read_to_string(&path) else { continue }; + for line in text.lines() { + let Ok(v) = serde_json::from_str::<Value>(line) else { continue }; + let field = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let key = (field("need"), sanitize(&field("reason"), root), field("test"), field("binary"), field("location")); + let steps = by_key.entry(key).or_default(); + if !steps.iter().any(|s| s == step) { + steps.push(step.to_owned()); + } + } + } + by_key.into_iter().map(|((need, reason, test, binary, location), steps)| Skip { need, reason, test, binary, location, steps }).collect() +} + +/// Cut the workspace root and the home directory from `s`, and fold `dir/../` in paths. +pub fn sanitize(s: &str, root: &Path) -> String { + let mut out = s.to_owned(); + let root = root.display().to_string(); + if !root.is_empty() { + for sep in ['/', '\\'] { + out = out.replace(&format!("{root}{sep}"), ""); + } + out = out.replace(&root, "."); + } + let home = std::env::var_os("HOME").or_else(|| std::env::var_os("USERPROFILE")).map(|h| h.to_string_lossy().into_owned()); + if let Some(home) = home.filter(|h| h.len() > 1) { + out = out.replace(&home, "~"); + } + out.split(' ').map(fold_parent_dirs).collect::<Vec<_>>().join(" ") +} + +/// `crates/raw/../../corpus/raw` → `corpus/raw` (lexically; leading `..` stay). +fn fold_parent_dirs(token: &str) -> String { + if !token.contains("/../") && !token.contains("\\..\\") { + return token.to_owned(); + } + let sep = if token.contains("/../") { '/' } else { '\\' }; + let mut parts: Vec<&str> = Vec::new(); + for part in token.split(sep) { + if part == ".." && parts.last().is_some_and(|p| *p != ".." && !p.is_empty()) { + parts.pop(); + } else { + parts.push(part); + } + } + parts.join(&sep.to_string()) +} + +pub fn skips_json(skips: &[Skip]) -> Value { + Value::Array( + skips + .iter() + .map(|s| json!({"need": s.need, "reason": s.reason, "test": s.test, "binary": s.binary, "location": s.location, "steps": s.steps})) + .collect(), + ) +} + +/// What an empty skip list means: no test skipped, or the tests never ran. +fn no_skips(report: &Value) -> &'static str { + let test_ran = report["steps"].as_array().into_iter().flatten().any(|s| s["name"] == "test" && s["status"] != "not run"); + if test_ran { "none recorded" } else { "unknown, the test step did not run" } +} + +fn skips_from(report: &Value) -> Vec<Skip> { + report["skips"] + .as_array() + .into_iter() + .flatten() + .map(|v| { + let f = |k: &str| v[k].as_str().unwrap_or("").to_owned(); + let steps = v["steps"].as_array().map(|a| a.iter().filter_map(|s| s.as_str().map(str::to_owned)).collect()).unwrap_or_default(); + Skip { need: f("need"), reason: f("reason"), test: f("test"), binary: f("binary"), location: f("location"), steps } + }) + .collect() +} + +/// Skip counts per need, most first: `[("corpus", 40), ("gpu", 5)]`. +pub fn counts_by_need(skips: &[Skip]) -> Vec<(String, usize)> { + let mut counts: BTreeMap<&str, usize> = BTreeMap::new(); + for s in skips { + *counts.entry(&s.need).or_default() += 1; + } + let mut v: Vec<(String, usize)> = counts.into_iter().map(|(k, n)| (k.to_owned(), n)).collect(); + v.sort_by(|a, b| b.1.cmp(&a.1).then_with(|| a.0.cmp(&b.0))); + v +} + +/// What gets the skipped tests of `need` running. +fn hint(need: &str) -> &'static str { + match need { + "gpu" => "a hardware GPU adapter the pipeline accepts", + "corpus" => "the corpus (`cargo xtask corpus --download`; some files are private)", + "fonts" => "craft-fonts (`CRAFT_FONTS_DIR`, see the README)", + "model" => "the model files the reasons name", + "input" => "the input files the reasons name", + "system" => "what the operating system provides", + "feature" => "support this build doesn't have yet", + _ => "what the reasons name", + } +} + +/// The skips of `need`, grouped by reason, largest group first. +fn by_reason<'a>(skips: &'a [Skip], need: &str) -> Vec<(&'a str, Vec<&'a Skip>)> { + let mut groups: BTreeMap<&str, Vec<&Skip>> = BTreeMap::new(); + for k in skips.iter().filter(|k| k.need == need) { + groups.entry(&k.reason).or_default().push(k); + } + let mut groups: Vec<(&str, Vec<&Skip>)> = groups.into_iter().collect(); + groups.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0))); + groups +} + +pub fn seconds(s: f64) -> String { + if s < 60.0 { format!("{s:.1} s") } else { format!("{}m {:02}s", (s / 60.0).floor() as u64, (s % 60.0).floor() as u64) } +} + +fn test_name(s: &Skip) -> String { + if s.test.is_empty() || s.test == "main" { format!("{} ({})", s.binary, s.location) } else { format!("{}::{}", s.binary, s.test) } +} + +fn adapter_line(a: &Value) -> String { + let name = a["name"].as_str().unwrap_or("?"); + let kind = a["type"].as_str().unwrap_or("?"); + let driver = [a["driver"].as_str().unwrap_or(""), a["driver_info"].as_str().unwrap_or("")] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::<Vec<_>>() + .join(" "); + if driver.is_empty() { format!("{name} ({kind})") } else { format!("{name} ({kind}; {driver})") } +} + +/// "the pipeline's device" line. +fn gpu_line(report: &Value) -> String { + let gpu = &report["capabilities"]["gpu"]; + if let Some(e) = gpu["error"].as_str() { + return format!("probe failed: {e}"); + } + let p = &gpu["pipeline"]; + match (p["available"].as_bool(), p["adapter"].as_str(), p["reason"].as_str()) { + (Some(true), Some(a), _) => a.to_owned(), + (_, _, Some(r)) => format!("none ({r})"), + _ => "not probed".to_owned(), + } +} + +fn adapters_lines(report: &Value) -> Vec<String> { + let mut out = Vec::new(); + for b in report["capabilities"]["gpu"]["backends"].as_array().into_iter().flatten() { + let name = b["backend"].as_str().unwrap_or("?"); + let list: Vec<String> = b["adapters"].as_array().into_iter().flatten().map(adapter_line).collect(); + let text = match (b["error"].as_str(), list.is_empty()) { + (Some(e), _) => format!("probe failed: {e}"), + (None, true) => "no adapter".to_owned(), + (None, false) => list.join(", "), + }; + out.push(format!("{name}: {text}")); + } + out +} + +fn machine_line(report: &Value) -> String { + let m = &report["machine"]; + let mut s = format!("{} {}", m["os"].as_str().unwrap_or("?"), m["arch"].as_str().unwrap_or("?")); + if let Some(v) = m["os_version"].as_str() { + let _ = write!(s, " ({v})"); + } + if let Some(n) = m["cpus"].as_u64() { + let _ = write!(s, ", {n} CPUs"); + } + if let Some(gb) = m["ram_total_gb"].as_u64() { + let _ = write!(s, ", {gb} GB RAM"); + } + if let Some(mb) = m["ram_available_mb"].as_u64() { + let _ = write!(s, " ({:.1} GB available)", mb as f64 / 1024.0); + } + s +} + +fn data_line(report: &Value) -> String { + let d = &report["capabilities"]["data"]; + let c = &d["corpus"]; + let n = |k: &str| c[k].as_u64().unwrap_or(0); + let fonts = match (d["craft_fonts"]["set"].as_bool(), d["craft_fonts"]["found"].as_bool()) { + (Some(true), Some(true)) => "CRAFT_FONTS_DIR set", + (Some(true), _) => "CRAFT_FONTS_DIR set but not found", + _ => "no CRAFT_FONTS_DIR", + }; + format!("corpus: {} of {} pinned raws, {} images, {} PngSuite files; {fonts}", n("raw_pinned"), n("raw_pinned_total"), n("images"), n("pngsuite")) +} + +fn commit_line(report: &Value) -> String { + let g = &report["git"]; + let commit = g["commit"].as_str().map_or("unknown commit".to_owned(), |c| c.chars().take(10).collect()); + let state = match g["dirty"].as_bool() { + Some(true) => "with uncommitted changes", + Some(false) => "clean", + None => "state unknown", + }; + format!("{commit} ({state})") +} + +/// The lines printed after the CI summary. +pub fn summary_text(report: &Value) -> String { + let mut s = String::new(); + let _ = writeln!(s, "\nMachine: {}", machine_line(report)); + let _ = writeln!(s, "GPU for the pipeline: {}", gpu_line(report)); + for line in adapters_lines(report) { + let _ = writeln!(s, " adapters on {line}"); + } + let _ = writeln!(s, "Data: {}", data_line(report)); + for h in report["hardware"].as_array().into_iter().flatten() { + let _ = writeln!( + s, + "GPU tests on {} ({}): {}", + h["backend"].as_str().unwrap_or("?"), + h["adapter"].as_str().unwrap_or("?"), + h["status"].as_str().unwrap_or("?") + ); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "Skipped tests: {}", no_skips(report)); + } else { + let counts: Vec<String> = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "Skipped tests: {} passed without running their checks ({}):", skips.len(), counts.join(", ")); + for (need, n) in counts_by_need(&skips) { + let _ = writeln!(s, " {need} ({n}): needs {}", hint(&need)); + let groups = by_reason(&skips, &need); + const SHOWN: usize = 4; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let more = if tests.len() > 1 { ", …" } else { "" }; + let _ = writeln!(s, " {:>3}× {reason} ({first}{more})", tests.len()); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, " {rest:>3}× {} other reasons (target/ci/report.json)", groups.len() - SHOWN); + } + } + } + s +} + +fn cell(s: &str) -> String { + s.replace('|', "\\|").replace('\n', " ") +} + +/// A Markdown block for a PR description or comment. +pub fn summary_md(report: &Value) -> String { + let mut s = String::new(); + let result = report["result"].as_str().unwrap_or("?"); + let command = report["command"].as_str().unwrap_or("cargo xtask ci"); + let _ = writeln!(s, "<!-- lightcraft ci report, schema {SCHEMA} -->"); + let _ = writeln!(s, "**`{command}`: {result}** at {}\n", commit_line(report)); + let _ = writeln!(s, "| | |\n|---|---|"); + let _ = writeln!(s, "| Machine | {} |", cell(&machine_line(report))); + if let Some(t) = report["toolchain"].as_str() { + let _ = writeln!(s, "| Rust | {} |", cell(t)); + } + let _ = writeln!(s, "| GPU for the pipeline | {} |", cell(&gpu_line(report))); + let adapters = adapters_lines(report); + if !adapters.is_empty() { + let _ = writeln!(s, "| Adapters | {} |", cell(&adapters.join("; "))); + } + let _ = writeln!(s, "| Data | {} |", cell(&data_line(report))); + let _ = writeln!(s, "\n| Step | Result | Time |\n|---|---|---|"); + for step in report["steps"].as_array().into_iter().flatten() { + let time = step["seconds"].as_f64().map(seconds).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {time} |", cell(step["name"].as_str().unwrap_or("?")), step["status"].as_str().unwrap_or("?")); + } + for h in report["hardware"].as_array().into_iter().flatten() { + let time = h["seconds"].as_f64().map(seconds).unwrap_or_default(); + let name = format!("GPU tests on {} ({})", h["backend"].as_str().unwrap_or("?"), h["adapter"].as_str().unwrap_or("?")); + let _ = writeln!(s, "| {} | {} | {time} |", cell(&name), h["status"].as_str().unwrap_or("?")); + } + let skips = skips_from(report); + if skips.is_empty() { + let _ = writeln!(s, "\nSkipped tests: {}.", no_skips(report)); + } else { + let counts: Vec<String> = counts_by_need(&skips).iter().map(|(k, n)| format!("{k} {n}")).collect(); + let _ = writeln!(s, "\n<details><summary>{} tests skipped ({})</summary>\n", skips.len(), counts.join(", ")); + let _ = writeln!(s, "| Need | Tests | Reason | For example |\n|---|---|---|---|"); + for (need, _) in counts_by_need(&skips) { + let groups = by_reason(&skips, &need); + const SHOWN: usize = 12; + for (reason, tests) in groups.iter().take(SHOWN) { + let first = tests.first().map(|t| test_name(t)).unwrap_or_default(); + let _ = writeln!(s, "| {} | {} | {} | `{}` |", cell(&need), tests.len(), cell(reason), cell(&first)); + } + if groups.len() > SHOWN { + let rest: usize = groups.iter().skip(SHOWN).map(|(_, t)| t.len()).sum(); + let _ = writeln!(s, "| {} | {rest} | {} other reasons | |", cell(&need), groups.len() - SHOWN); + } + } + let _ = writeln!(s, "\n</details>"); + } + s +} + +#[cfg(test)] +mod tests { + use super::*; + + fn tmp(name: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("xtask-ci-report-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d + } + + #[test] + fn skips_are_read_per_step_deduplicated_and_cut_free_of_the_root() { + let root = Path::new("/work/lightcraft"); + let dir = tmp("skips"); + let gpu = + r#"{"need":"gpu","reason":"no GPU adapter","test":"camera_tone","binary":"equivalence","location":"crates/gpu/tests/equivalence.rs:22"}"#; + let corpus = r#"{"need":"corpus","reason":"skip /work/lightcraft/crates/raw/../../corpus/raw absent","test":"decodes","binary":"corpus","location":"crates/raw/tests/corpus.rs:28"}"#; + std::fs::write(dir.join("skips-heif.jsonl"), format!("{corpus}\n")).unwrap(); + std::fs::write(dir.join("skips-test.jsonl"), format!("{gpu}\n{corpus}\nnot json\n")).unwrap(); + std::fs::write(dir.join("other.jsonl"), format!("{gpu}\n")).unwrap(); + let skips = read_skips(&dir, root); + assert_eq!(skips.len(), 2, "{skips:?}"); + assert_eq!(skips[0].need, "corpus"); + assert_eq!(skips[0].reason, "skip corpus/raw absent"); + assert_eq!(skips[0].steps, ["heif", "test"]); + assert_eq!(skips[1].steps, ["test"]); + assert_eq!(counts_by_need(&skips), [("corpus".to_owned(), 1), ("gpu".to_owned(), 1)]); + std::fs::remove_dir_all(&dir).unwrap(); + } + + #[test] + fn prepare_clears_the_last_runs_files_only() { + let root = tmp("prepare"); + let dir = dir(&root); + std::fs::create_dir_all(&dir).unwrap(); + for f in ["skips-test.jsonl", "report.json", "summary.md", "keep.txt"] { + std::fs::write(dir.join(f), "x").unwrap(); + } + prepare(&root).unwrap(); + let left: Vec<String> = std::fs::read_dir(&dir).unwrap().flatten().map(|e| e.file_name().to_string_lossy().into_owned()).collect(); + assert_eq!(left, ["keep.txt"]); + assert_eq!(skip_log(&root, "gpu:dx12").file_name().unwrap(), "skips-gpu-dx12.jsonl"); + std::fs::remove_dir_all(&root).unwrap(); + } + + #[test] + fn parent_dirs_fold_in_path_tokens_only() { + assert_eq!(fold_parent_dirs("crates/raw/../../corpus/raw"), "corpus/raw"); + assert_eq!(fold_parent_dirs("../corpus/raw"), "../corpus/raw"); + assert_eq!(fold_parent_dirs("C:\\w\\crates\\raw\\..\\..\\corpus"), "C:\\w\\corpus"); + assert_eq!(fold_parent_dirs("absent"), "absent"); + } + + #[test] + fn the_probe_asks_the_pipelines_backends_or_all_with_hardware() { + let pipeline = r#"{"pipeline":{"available":false,"adapter":null,"reason":"software adapter (llvmpipe) skipped","backends":["vulkan","dx12"],"compiled":["vulkan","dx12","gl"]}}"#; + let asked = std::cell::RefCell::new(Vec::new()); + let probe = |b: Option<&str>| -> Result<String, String> { + asked.borrow_mut().push(b.map(str::to_owned)); + Ok(match b { + None => format!("running 1 test\ntest gpu_probe ... \ngpu_probe: {pipeline}\nok\n"), + Some("vulkan") => r#"gpu_probe: {"backend":"vulkan","adapters":[{"name":"llvmpipe","type":"Cpu","driver":"llvmpipe","driver_info":"Mesa"}]}"#.to_owned(), + Some("dx12") => r#"gpu_probe: {"backend":"dx12","adapters":[{"name":"Intel(R) UHD Graphics 620","type":"IntegratedGpu","driver":"","driver_info":"31.0.101"}]}"#.to_owned(), + Some(_) => return Err("crashed".into()), + }) + }; + let gpu_default = gpu(probe, false); + assert_eq!(*asked.borrow(), [None, Some("vulkan".to_owned()), Some("dx12".to_owned())]); + assert_eq!(hardware_backends(&gpu_default), [("dx12".to_owned(), "Intel(R) UHD Graphics 620".to_owned())]); + asked.borrow_mut().clear(); + let gpu_all = gpu(probe, true); + assert_eq!(asked.borrow().len(), 4, "gl too"); + assert_eq!(gpu_all["backends"][2]["error"], "crashed"); + + let report = json!({ + "command": "cargo xtask ci", + "result": "pass", + "git": {"commit": "0123456789abcdef", "dirty": false}, + "machine": {"os": "windows", "arch": "x86_64", "cpus": 8, "ram_total_gb": 16}, + "capabilities": {"gpu": gpu_all, "data": {"corpus": {"raw_pinned": 3, "raw_pinned_total": 30}, "craft_fonts": {"set": false}}}, + "steps": [{"name": "fmt", "status": "ok", "seconds": 1.25}, {"name": "test", "status": "ok", "seconds": 125.0}], + "skips": [{"need": "gpu", "reason": "no GPU | adapter", "test": "memory", "binary": "memory", "location": "x.rs:1", "steps": ["test"]}], + }); + let text = summary_text(&report); + assert!(text.contains("GPU for the pipeline: none (software adapter (llvmpipe) skipped)"), "{text}"); + assert!(text.contains("adapters on dx12: Intel(R) UHD Graphics 620 (IntegratedGpu; 31.0.101)"), "{text}"); + assert!(text.contains("adapters on gl: probe failed: crashed"), "{text}"); + assert!(text.contains("Skipped tests: 1 passed without running their checks (gpu 1)"), "{text}"); + assert!(text.contains(" gpu (1): needs a hardware GPU adapter"), "{text}"); + assert!(text.contains(" 1× no GPU | adapter (memory::memory)"), "{text}"); + assert!(text.contains("corpus: 3 of 30 pinned raws"), "{text}"); + let md = summary_md(&report); + assert!(md.contains("**`cargo xtask ci`: pass** at 0123456789 (clean)"), "{md}"); + assert!(md.contains("| test | ok | 2m 05s |"), "{md}"); + assert!(md.contains("| gpu | 1 | no GPU \\| adapter | `memory::memory` |"), "{md}"); + assert!(md.contains("1 tests skipped (gpu 1)"), "{md}"); + } + + #[test] + fn no_skips_is_not_claimed_when_the_tests_never_ran() { + let stopped = json!({"steps": [{"name": "clippy", "status": "fail"}, {"name": "test", "status": "not run"}], "skips": []}); + assert!(summary_text(&stopped).contains("Skipped tests: unknown, the test step did not run")); + let ran = json!({"steps": [{"name": "test", "status": "ok"}], "skips": []}); + assert!(summary_md(&ran).contains("Skipped tests: none recorded.")); + } +} diff --git a/xtask/src/layers.rs b/xtask/src/layers.rs index 714b47a2f..d5be5523f 100644 --- a/xtask/src/layers.rs +++ b/xtask/src/layers.rs @@ -51,6 +51,7 @@ pub const TABLE: &[(&str, Class)] = &[ ("faces", Class::Layer(1)), ("geocode", Class::Layer(1)), ("scenes", Class::Layer(1)), + ("slideshow", Class::Layer(1)), ("pipeline", Class::Layer(2)), ("gpu", Class::Layer(3)), ("catalog", Class::Layer(3)), @@ -191,11 +192,8 @@ pub fn check(crates: &[Crate]) -> Vec<Violation> { match classify(&d.name) { // Unregistered deps are reported on their own entry. None => {} - Some(Class::Testkit) => { - if d.kind != DepKind::Dev { - out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }); - } - } + Some(Class::Testkit) if d.kind != DepKind::Dev => out.push(Violation::TestkitAsNormalDep { krate: c.name.clone() }), + Some(Class::Testkit) => {} Some(dc) => { let to = dc.layer().unwrap_or(u8::MAX); if to >= layer && !(to == layer && intra_layer_allowed(&c.name, &d.name)) { diff --git a/xtask/src/main.rs b/xtask/src/main.rs index c392d0890..8a7d597b5 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -5,6 +5,7 @@ mod assets; mod bench; +mod ci_report; mod ico; mod layers; mod parity; @@ -33,10 +34,19 @@ commands: web [--serve [port]] [--dev] build the browser app (apps/lightcraft-web) into <target>/web-site/; --serve serves it on http://127.0.0.1:<port> (default 8080) - ci fmt --check, clippy -D warnings, heif, test, parity refs, layers, assets, wasm (stops at first failure) + ci [--hardware] [--summary-md] + fmt --check, clippy -D warnings, heif, test, GPU probe, parity refs, layers, assets, wasm + (stops at first failure); writes target/ci/report.json (machine, GPU adapters, data, + steps, skipped tests) and target/ci/summary.md; --hardware also runs the GPU tests on + every backend with a hardware adapter; --summary-md prints the Markdown summary corpus [--download] show where test corpora live; --download fetches PngSuite and CC0 raw samples (raw.pixls.us) into corpus/ and checks their sha256 stats [--exact] count tests and lines per crate (--exact: ask the test harness via `-- --list`) + raw-frame [--json] FILE… + measure where each raw's embedded camera JPEG sits in its sensor data, which edge bands hold no + picture and what the decoder's crop keeps beyond the camera frame (any make with a full-size + embedded JPEG); print image-area entries for Nikon bodies that record no crop (MEASURED_BODIES in + crates/raw/src/vendor/nef.rs) "; fn main() -> ExitCode { @@ -51,9 +61,10 @@ fn main() -> ExitCode { Some("parity") => parity::run(&root(), rest.contains(&"--write")), Some("wasm") => cmd_wasm(), Some("web") => web::run(&rest), - Some("ci") => cmd_ci(), + Some("ci") => cmd_ci(&rest), Some("corpus") => cmd_corpus(rest.contains(&"--download")), Some("stats") => stats::run(&root(), rest.contains(&"--exact")), + Some("raw-frame") => cmd_raw_frame(&rest), Some("-h" | "--help" | "help") | None => { print!("{USAGE}"); Ok(()) @@ -69,6 +80,20 @@ fn main() -> ExitCode { } } +/// `cargo xtask raw-frame FILE…`: runs the `raw_frame` example of `lightcraft-engine` on the files. +fn cmd_raw_frame(files: &[&str]) -> Result<(), String> { + if files.is_empty() { + return Err("raw-frame needs one or more raw files (several per camera, with detail across the frame)".into()); + } + eprintln!("$ cargo run --release -p lightcraft-engine --example raw_frame -- {}", files.join(" ")); + let status = cargo() + .args(["run", "--release", "-q", "-p", "lightcraft-engine", "--example", "raw_frame", "--"]) + .args(files) + .status() + .map_err(|e| format!("raw_frame: {e}"))?; + if status.success() { Ok(()) } else { Err("raw_frame failed".into()) } +} + /// Workspace root (parent of the xtask crate). pub fn root() -> PathBuf { // `cargo run` sets it at run time; prefer that over the compile-time value, which goes stale @@ -240,9 +265,38 @@ fn cmd_wasm() -> Result<(), String> { if failed == 0 { Ok(()) } else { Err(format!("{failed} crate(s) failed the wasm check")) } } -fn cmd_ci() -> Result<(), String> { - type Step = (&'static str, Box<dyn Fn() -> Result<(), String>>); - let steps: Vec<Step> = vec![ +fn cmd_ci(args: &[&str]) -> Result<(), String> { + let hardware = args.contains(&"--hardware"); + let print_md = args.contains(&"--summary-md"); + if let Some(other) = args.iter().find(|a| !matches!(**a, "--hardware" | "--summary-md")) { + return Err(format!("ci: unknown option `{other}`\n\n{USAGE}")); + } + let root = root(); + let report_dir = ci_report::prepare(&root)?; + let started = std::time::Instant::now(); + let started_unix = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0); + // test runs append the skips their tests record (`lightcraft_testkit::skip!`) to target/ci/skips-<step>.jsonl + let test = |cargo_args: &[&str], step: &str| { + let mut c = cargo(); + c.args(cargo_args).env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, step)); + run(c, &format!("cargo {}", cargo_args.join(" "))) + }; + let gpu = std::cell::RefCell::new(serde_json::Value::Null); + let hardware_runs = std::cell::RefCell::new(Vec::new()); + // What this machine offers the GPU tests, once, after the workspace tests (whose build the + // probe reuses). Never fails the run: the report says what went wrong. + let probe = || { + if gpu.borrow().is_null() { + eprintln!("\n=== ci: GPU probe ==="); + let t = std::time::Instant::now(); + let mut found = ci_report::gpu(probe_gpu, hardware); + found["seconds"] = serde_json::json!(t.elapsed().as_secs_f64()); + *gpu.borrow_mut() = found; + } + }; + + type Step<'a> = (&'static str, Box<dyn Fn() -> Result<(), String> + 'a>); + let mut steps: Vec<Step> = vec![ ( "fmt", Box::new(|| { @@ -266,46 +320,144 @@ fn cmd_ci() -> Result<(), String> { let mut c = cargo(); c.args(["clippy", "-p", "lightcraft-codecs", "--features", "heif", "--all-targets", "--", "-D", "warnings"]); run(c, "cargo clippy -p lightcraft-codecs --features heif --all-targets -- -D warnings")?; - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"]); - run(c, "cargo test -p lightcraft-codecs -p lightcraft-heif --features lightcraft-codecs/heif")?; + test(&["test", "-p", "lightcraft-codecs", "-p", "lightcraft-heif", "--features", "lightcraft-codecs/heif"], "heif")?; // the engine's HEIC import test, with the decoder (the workspace run checks the error) - let mut c = cargo(); - c.args(["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"]); - run(c, "cargo test -p lightcraft-engine --lib --features lightcraft-codecs/heif heic") + test(&["test", "-p", "lightcraft-engine", "--lib", "--features", "lightcraft-codecs/heif", "heic"], "heif") }), ), - ( - "test", - Box::new(|| { - let mut c = cargo(); - c.args(["test", "--workspace"]); - run(c, "cargo test --workspace") - }), - ), - ("parity", Box::new(|| parity::run(&root(), false))), - ("layers", Box::new(cmd_layers)), - ("assets", Box::new(|| assets::run(&root()))), - ("wasm", Box::new(cmd_wasm)), + ("test", Box::new(|| test(&["test", "--workspace"], "test"))), ]; - let mut done = Vec::new(); + if hardware { + steps.push(( + "gpu-backends", + Box::new(|| { + // the GPU tests once per backend with a hardware adapter (`LIGHTCRAFT_GPU_BACKEND`) + probe(); + let backends = ci_report::hardware_backends(&gpu.borrow()); + if backends.is_empty() { + eprintln!("no hardware GPU adapter on any backend: nothing to run"); + } + let mut failed = Vec::new(); + for (backend, adapter) in backends { + let step = format!("gpu-{backend}"); + let mut c = cargo(); + c.args(["test", "-p", "lightcraft-gpu", "--features", "denoise"]) + .env("LIGHTCRAFT_GPU_BACKEND", &backend) + .env(ci_report::SKIP_LOG_ENV, ci_report::skip_log(&root, &step)); + let t = std::time::Instant::now(); + let ok = run(c, &format!("LIGHTCRAFT_GPU_BACKEND={backend} cargo test -p lightcraft-gpu --features denoise")).is_ok(); + hardware_runs.borrow_mut().push(serde_json::json!({ + "backend": backend, + "adapter": adapter, + "status": if ok { "ok" } else { "fail" }, + "seconds": t.elapsed().as_secs_f64(), + })); + if !ok { + failed.push(backend); + } + } + if failed.is_empty() { Ok(()) } else { Err(format!("the GPU tests failed on {}", failed.join(", "))) } + }), + )); + } + steps.push(("parity", Box::new(|| parity::run(&root, false)))); + steps.push(("layers", Box::new(cmd_layers))); + steps.push(("assets", Box::new(|| assets::run(&root)))); + steps.push(("wasm", Box::new(cmd_wasm))); + + let mut results = Vec::new(); + let mut failure = None; for (name, f) in &steps { eprintln!("\n=== ci: {name} ==="); - if let Err(e) = f() { + let t = std::time::Instant::now(); + let r = f(); + let status = if r.is_ok() { "ok" } else { "fail" }; + results.push(serde_json::json!({"name": name, "status": status, "seconds": t.elapsed().as_secs_f64()})); + if *name == "test" { + // right after the workspace tests, before parity and wasm build other feature sets + probe(); + } + if let Err(e) = r { + failure = Some((*name, e)); + break; + } + } + if gpu.borrow().is_null() { + *gpu.borrow_mut() = serde_json::json!({"error": "not probed: the test step did not run"}); + } + let done: Vec<&str> = steps.iter().take(results.len()).map(|(n, _)| *n).collect(); + match &failure { + Some((name, e)) => { println!("\nCI summary:"); - for d in &done { + for d in done.iter().filter(|d| *d != name) { println!(" ok {d}"); } println!(" FAIL {name}: {e}"); - for (n, _) in steps.iter().skip(done.len() + 1) { + for (n, _) in steps.iter().skip(results.len()) { println!(" skip {n}"); } - return Err(format!("ci failed at `{name}`")); } - done.push(*name); + None => println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")), } - println!("\nCI summary: all {} steps passed ({})", done.len(), done.join(", ")); - Ok(()) + for (n, _) in steps.iter().skip(results.len()) { + results.push(serde_json::json!({"name": n, "status": "not run"})); + } + + let skips = ci_report::read_skips(&report_dir, &root); + let ram_mb = available_ram_mb(); + let report = serde_json::json!({ + "schema": ci_report::SCHEMA, + "command": std::iter::once("cargo xtask ci").chain(args.iter().copied()).collect::<Vec<_>>().join(" "), + "result": if failure.is_some() { "fail" } else { "pass" }, + "failed_step": failure.as_ref().map(|(n, _)| *n), + "started_unix": started_unix, + "seconds": started.elapsed().as_secs_f64(), + "git": ci_report::git(&root), + "machine": ci_report::machine(total_ram_gb(), ram_mb), + "toolchain": ci_report::toolchain(), + "capabilities": {"gpu": gpu.take(), "data": ci_report::data(&root, &RAW_SAMPLES.iter().map(|s| s.0).collect::<Vec<_>>())}, + "steps": results, + "hardware": hardware_runs.take(), + "skips": ci_report::skips_json(&skips), + }); + print!("{}", ci_report::summary_text(&report)); + let md = ci_report::summary_md(&report); + let written = serde_json::to_string_pretty(&report) + .map_err(|e| e.to_string()) + .and_then(|json| std::fs::write(report_dir.join("report.json"), json + "\n").map_err(|e| e.to_string())) + .and_then(|()| std::fs::write(report_dir.join("summary.md"), &md).map_err(|e| e.to_string())); + match written { + Ok(()) => println!("Report: target/ci/report.json; to paste into a PR: target/ci/summary.md (or run with --summary-md)"), + Err(e) => eprintln!("ci: could not write the report to {}: {e}", report_dir.display()), + } + if print_md { + println!("\n{md}"); + } + match failure { + Some((name, _)) => Err(format!("ci failed at `{name}`")), + None => Ok(()), + } +} + +/// Run the ignored `gpu_probe` test of lightcraft-gpu: the pipeline's device (`None`) or one +/// backend's adapters. `--workspace`, like the test step, so its build is reused. Its stdout, or why +/// it failed (a driver that crashes while it is enumerated, issue #136, ends up here, not in xtask). +fn probe_gpu(backend: Option<&str>) -> Result<String, String> { + let mut c = cargo(); + c.args(["test", "-q", "--workspace", "--test", "gpu_probe", "--", "--ignored", "--exact", "gpu_probe", "--nocapture"]); + c.env("LIGHTCRAFT_GPU_PROBE", backend.unwrap_or("")); + let out = c.output().map_err(|e| format!("gpu_probe: {e}"))?; + if !out.status.success() { + let err = String::from_utf8_lossy(&out.stderr); + let tail: Vec<&str> = err.lines().rev().filter(|l| !l.trim().is_empty()).take(3).collect(); + return Err(format!( + "gpu_probe {}exited with {}: {}", + backend.map(|b| format!("{b} ")).unwrap_or_default(), + out.status, + tail.into_iter().rev().collect::<Vec<_>>().join(" / ") + )); + } + Ok(String::from_utf8_lossy(&out.stdout).into_owned()) } /// CC0 raw samples (raw.pixls.us). Small, representative set; extend freely (CC0 only). @@ -313,6 +465,207 @@ fn cmd_ci() -> Result<(), String> { /// The third field is the SHA-256 of the file as downloaded; `cargo xtask corpus --download` checks it. /// One per format / compression variant we decode or deliberately report as unsupported (preview only). const RAW_SAMPLES: &[(&str, &str, &str)] = &[ + // Camera-coverage sweep: CC0 files, sensor hashes and comparisons in docs/raw/coverage-sweep.tsv. + ( + "coverage-915.dng", + "https://raw.pixls.us/getfile.php/915/nice/Apple%20-%20iPhone%206s%20Plus%20-%2016bit%20%284:3%29.DNG", + "845fee4f09c832af3728778737f5ae9b99124debb821b6e2066b94aed1daa91c", + ), + ( + "coverage-939.dng", + "https://raw.pixls.us/getfile.php/939/nice/Apple%20-%20iPhone%207%20Plus%20-%2016bit%20%284:3%29.DNG", + "e8c7ebd8f22f7281d165496b91c8fea8a8e79c8113172fb499e1d66d6045332e", + ), + ( + "coverage-3372.dng", + "https://raw.pixls.us/getfile.php/3372/nice/Apple%20-%20iPhone%20XS%20-%2016bit%20%284:3%29.dng", + "50bc64ee8cfbbfb6c0531e29ab428ff985506a16fe2d1d2e74656f21b54505f6", + ), + ( + "coverage-3009.dng", + "https://raw.pixls.us/getfile.php/3009/nice/Hasselblad%20-%20L1D-20c%20-%2016bit%20%283:2%29.DNG", + "7afecae0db80d832941eeeb3c0eb2dc03f5696637b6e78b3c592e8c002dce4ec", + ), + ( + "coverage-5140.dng", + "https://raw.pixls.us/getfile.php/5140/nice/Hasselblad%20-%20L2D-20c%20-%2016bit%20%284:3%29.DNG", + "dbe98cb202f884ff11e0144899613b26d33cf9cddd9f1a62226f4352397f18e4", + ), + ( + "coverage-8041.dng", + "https://raw.pixls.us/getfile.php/8041/nice/DJI%20-%20FC9287%20-%2016bit%20%2816:9%29.DNG", + "901b7970f02f7197c782f111363feef02108f75811342d8cc4a2b66757bb7af0", + ), + ( + "coverage-4785.dng", + "https://raw.pixls.us/getfile.php/4785/nice/DJI%20-%20FC7303%20-%2016bit%20%284:3%29.DNG", + "21a49e63966f82391ca1188c7559c3acc01a35a89a70194fda4bf44cbd64a851", + ), + ( + "coverage-4898.dng", + "https://raw.pixls.us/getfile.php/4898/nice/DJI%20-%20FC7303%20-%2016bit%20%2816:9%29.DNG", + "fb4f294e12cab7b507fd0b7c07f24e5d13e7c0dd127b534ce12c48d644c9363d", + ), + ( + "coverage-3502.dng", + "https://raw.pixls.us/getfile.php/3502/nice/Google%20-%20Pixel%204%20XL%20-%2016bit%20%284:3%29.dng", + "0c03a6c93b4b8593cd0e4426166e6389710007238b82eb23861ef191322f0c54", + ), + ( + "coverage-4176.dng", + "https://raw.pixls.us/getfile.php/4176/nice/Google%20-%20Pixel%204a%20-%2016bit%20%284:3%29.dng", + "11b38186ee3455376291bcdb3049728a3f9dcd94b9e82ff76855d9f3430e44be", + ), + ( + "coverage-5961.dng", + "https://raw.pixls.us/getfile.php/5961/nice/Google%20-%20Pixel%206%20Pro%20-%2016bit%20%284:3%29.dng", + "3a859e917a91698a3e15ce3b3721a48a1fb1296873e0697d3bf7c2793297200d", + ), + ( + "coverage-7751.dng", + "https://raw.pixls.us/getfile.php/7751/nice/Google%20-%20Pixel%208%20Pro%20-%2016bit%20%284:3%29.dng", + "45420c595401547cca950ae58d552bc82b32d31ce1d58c082df33004016197f5", + ), + ( + "coverage-7217.3fr", + "https://raw.pixls.us/getfile.php/7217/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.3FR", + "e27ff177519298fe7bae64506b975190789dc0e6ad70bea58be7c78fd094eae0", + ), + ( + "coverage-7218.fff", + "https://raw.pixls.us/getfile.php/7218/nice/Hasselblad%20-%20CFV-50c%20-%2016bit%20%284:3%29.fff", + "c8d78912ae725a59acff42a4b4731350bbddd4a600423f319579261bd41da932", + ), + ( + "coverage-1640.fff", + "https://raw.pixls.us/getfile.php/1640/nice/Hasselblad%20-%20H5D-40%20-%2016bit%20%284:3%29.fff", + "209d464e13702de5f4fdb830ac26ac140c98a8d0dc3e81c5ba61d93677330832", + ), + ( + "coverage-1639.fff", + "https://raw.pixls.us/getfile.php/1639/nice/Hasselblad%20-%20H5D-50c%20-%2016bit%20%284:3%29.fff", + "1aad9319fcbab81b851a74c3eeaa2a07fc5013bf2c4e272fd31d6419b6b9adb0", + ), + ( + "coverage-2058.3fr", + "https://raw.pixls.us/getfile.php/2058/nice/Hasselblad%20-%20X1D%20-%2016bit%20%284:3%29.3FR", + "395acb2c706dc10270c58ec261e42a5e36f6ac8c39d2a767e86e83648aa318a3", + ), + ( + "coverage-3874.fff", + "https://raw.pixls.us/getfile.php/3874/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.fff", + "8c2451b3478fb529b4e9bd51ed3267999ce26eb1fcb5c3b93c3ae7bc56267794", + ), + ( + "coverage-3941.3fr", + "https://raw.pixls.us/getfile.php/3941/nice/Hasselblad%20-%20X1D%20II%2050C%20-%2016bit%20%284:3%29.3FR", + "809d498263d6f07cbaa4b9a5be97933057b4ab21e7243bb7afbe3862f3b48b47", + ), + ( + "coverage-6568.fff", + "https://raw.pixls.us/getfile.php/6568/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.fff", + "44977fdaa3063eacbb2ac416dfb531bb4e3b96f3745d073dc49fb1ebcd78bedc", + ), + ( + "coverage-6565.3fr", + "https://raw.pixls.us/getfile.php/6565/nice/Hasselblad%20-%20X2D%20100C%20-%2016bit%20%284:3%29.3FR", + "dcc5a4abe3498e6f25e89bb491995fd12c3b669d9277c71b45b49210d3e56280", + ), + ( + "coverage-4726.nef", + "https://raw.pixls.us/getfile.php/4726/nice/Nikon%20-%20D6%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3a098e704d0e1af97efc37541b43a37e5b3457d2f4e7828c0d8d9af37332f233", + ), + ( + "coverage-4720.nef", + "https://raw.pixls.us/getfile.php/4720/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "827bcfff8a3cd86278382c353e4542faab52cf12c365540f30753bb4c6fabc0f", + ), + ( + "coverage-4721.nef", + "https://raw.pixls.us/getfile.php/4721/nice/Nikon%20-%20D6%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "081d5dbc1d6c97935020d33ffa486dcaa8d4564cbde280cfadcf26ff61da2838", + ), + ( + "coverage-3584.nef", + "https://raw.pixls.us/getfile.php/3584/nice/Nikon%20-%20Z%206%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "3cbd9acf9e9bfd7d0c62c46436109556a101f7535c5fd25f86dad6ea61bf3886", + ), + ( + "coverage-3581.nef", + "https://raw.pixls.us/getfile.php/3581/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "8ca94584578b35a422744c1a610174ce37df0691588edfdecaec3111b3fbbb02", + ), + ( + "coverage-3582.nef", + "https://raw.pixls.us/getfile.php/3582/nice/Nikon%20-%20Z%206%20-%2014bit%2014bit%20lossless%20compressed%20%283:2%29.NEF", + "c079345fc93f53a4f0d322f8ddaae505f920c36015f25b58befccaa61db1af31", + ), + ( + "coverage-2792.nef", + "https://raw.pixls.us/getfile.php/2792/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "c861546fbcf5826d5eb26d223e2143fa32968e89f7edbc5bb10c283ea5461ded", + ), + ( + "coverage-2789.nef", + "https://raw.pixls.us/getfile.php/2789/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "e54a6c4264bf685214f538036fd9d39088e510b2bfedf288e568f0db1897880e", + ), + ( + "coverage-2790.nef", + "https://raw.pixls.us/getfile.php/2790/nice/Nikon%20-%20Z%207%20-%2012bit%2012bit%20compressed%20%283:2%29.NEF", + "f0e756dbfd00f3b71cb3d8aa5b9e2e1be32f1052a6c4380e09ebc7c5e7748114", + ), + ( + "coverage-6617.nef", + "https://raw.pixls.us/getfile.php/6617/nice/Nikon%20-%20Z%208%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), + ( + "coverage-2912.dng", + "https://raw.pixls.us/getfile.php/2912/nice/Parrot%20-%20Anafi%20-%2016bit%20%284:3%29.DNG", + "a257a296047ff2d396109fd2e6947fa36aa7986695b6815f83979d59e98a84e6", + ), + ( + "coverage-6728.dng", + "https://raw.pixls.us/getfile.php/6728/nice/Samsung%20-%20Galaxy%20S21%20Ultra%20-%204:3.dng", + "5baeb5f0c4a125647df03b82c2d38b0f02784393bd39eff1a5b23d8f5308dc5e", + ), + ( + "coverage-6261.dng", + "https://raw.pixls.us/getfile.php/6261/nice/Samsung%20-%20Galaxy%20S22%20Ulra%20-%204:3.dng", + "53e7f83a94c965495ba32d3ea6021ef08ed02244ce64d111d1329bbd01fd5581", + ), + ( + "coverage-6697.dng", + "https://raw.pixls.us/getfile.php/6697/nice/Samsung%20-%20Galaxy%20S23%20-%204:3.dng", + "1c04574f32b5a139922f075291c52018b89d9981ee155a16958a231c1fdbb067", + ), + ( + "coverage-1297.dng", + "https://raw.pixls.us/getfile.php/1297/nice/Samsung%20-%20G935F%20-%2016bit%20%2816:9%29.dng", + "843617c8b572320b7badfb4ac2eda19479f5804a1dabb909c861bde23ed30561", + ), + ( + "coverage-2645.dng", + "https://raw.pixls.us/getfile.php/2645/nice/Samsung%20-%20SM-G950F%20-%2016bit%20%284:3%29.dng", + "6abdd353094964eaadddf69bba3a0d551ecce1bc0ed013a434691df4f64dcbf2", + ), + ( + "coverage-2310.dng", + "https://raw.pixls.us/getfile.php/2310/nice/Samsung%20-%20SM-G955F%20-%2016bit%20%284:3%29.dng", + "3cf5da4331d062bc6b31e0e8f5b7ff2cd21107de4262d79a2667f670516e8e8c", + ), + ( + "coverage-7273.dng", + "https://raw.pixls.us/getfile.php/7273/nice/Sigma%20-%20fp%20-%2014bit%20%283:2%29.DNG", + "0844bcf23a49e21b5c2f8489de55ce38fb7b594e3ed337d6b9af2dd9341354e9", + ), + ( + "coverage-6949.dng", + "https://raw.pixls.us/getfile.php/6949/nice/Sigma%20-%20fp%20L%20-%2014bit%20%283:2%29.DNG", + "8e7229f28be30bfa45289940eac04194af2b3474f6db286e4fa4070c145a0a70", + ), ( "arw-sony-a7rm4a-compressed.arw", "https://raw.pixls.us/getfile.php/4822/nice/Sony%20-%20ILCE-7RM4A%20-%2014bit%2014bit%20compressed%20%283:2%29.ARW", @@ -383,6 +736,12 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/ILCE-7CM2/DSC02436.ARW", "002c86d38a16b08a2438780728b4fda8efcc745fa6c26231dad6af67b383b11e", ), + // ILCA-99M2 (42 MP, compressed): checks that it takes its colour from the bundled ILCA-99M2 profile + ( + "arw-sony-a99m2.arw", + "https://raw.pixls.us/data/Sony/ILCA-99M2/BR_00086.ARW", + "be898847ac6da485874d95469bf274df1f8f77f8b8542460464f0f393981b2d2", + ), // a dusk sky clipped in green behind a poplar: the clipped-highlight colour of issue #523 ( "arw-sony-a7rm4-14bit-compressed.arw", @@ -448,6 +807,13 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/data/Sony/DSLR-A900/_DSC7969.ARW", "ac7c1532df77c321e8010aa1be60c1b9f245b1ac0db6f38fe617a73aae81af50", ), + // Sony ILCE-7RM6 lossless, CC0 1.0 (RawDB): the APS-C file, for its lens tables (`vendor::arw_lens_rm6`). The + // full-frame lossless file is not pinned (at most two pins per PR); the two ARW6 pins below cover Compressed files. + ( + "arw-sony-ilce7rm6-apsc-land-lossless.arw", + "https://rawdb.dnglab.org/api/download/Sony/ILCE-7RM6/raw_modes/ILCE-7RM6_APSC_land_RAW.ARW", + "7aaf0b3941509d7364a6f03844783155ebc912d246d1c456e5cdb4693ec09fbc", + ), // CR2 colour-filter layouts differ by model (issue #85): CR2CFAPattern 3 (GBRG) and 1 (RGGB) samples ( "cr2-canon-40d.cr2", @@ -565,6 +931,111 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/7884/nice/Canon%20-%20EOS%20R5%20Mark%20II%20-%20CRAW%20%283:2%29.CR3", "6a597497776a6abbfab8fcf12d0086ac7a3f8afea2879d61accf6bbf48d357d7", ), + ( + "cr3-canon-m200.cr3", + "https://raw.pixls.us/getfile.php/8469/nice/Canon%20-%20EOS%20M200%20-%20RAW%20%283:2%29.CR3", + "9b900c04ae37ba827b7894e66907657a0b174a74855c40039a8ca0c95583f141", + ), + ( + "cr3-canon-r10.cr3", + "https://raw.pixls.us/getfile.php/5871/nice/Canon%20-%20EOS%20R10%20-%203:2.CR3", + "5d2e076d38e7835d4c4f13dc2030a913cc573a31d7199b60777d23a25ccc3ef9", + ), + ( + "cr3-canon-r50v.cr3", + "https://raw.pixls.us/getfile.php/8403/nice/Canon%20-%20EOS%20R50%20V%20-%20RAW%20%283:2%29.CR3", + "955ff98710505498ce7f98cbcc5875793ef2d6941b3b5692ed3baf2817ee7c9f", + ), + ( + "cr3-canon-r6m3.cr3", + "https://raw.pixls.us/getfile.php/8961/nice/Canon%20-%20EOS%20R6%20Mark%20III%20-%20RAW%20%283:2%29.CR3", + "e491e4bb960961b5fa299361bf698310a80cbe7b15d30d8dad3bb21bc5457dab", + ), + ( + "cr3-canon-powershot-v1-craw.cr3", + "https://raw.pixls.us/getfile.php/8500/nice/Canon%20-%20PowerShot%20V1%20-%20CRAW%20%283:2%29.CR3", + "0a0abfd81b3a34fa513ac8ed24c809275160d65b07e892c2eb4353b77c535a31", + ), + ( + "cr3-canon-1dxm3-craw.cr3", + "https://raw.pixls.us/getfile.php/4654/nice/Canon%20-%20EOS-1D%20X%20Mark%20III%20-%203:2.CR3", + "e5755d1414a2dd51fb0120b272058f00b0199861646070c00304d927eb119560", + ), + ( + "cr3-canon-250d-craw.cr3", + "https://raw.pixls.us/getfile.php/3383/nice/Canon%20-%20EOS%20250D%20-%20C-RAW%20%283:2%29.CR3", + "69bdd267defb3d676f41f62a5e5ea65651669a04191cf4e89740917c59cedbcc", + ), + ( + "cr3-canon-850d.cr3", + "https://raw.pixls.us/getfile.php/4867/nice/Canon%20-%20EOS%20850D%20-%203:2.CR3", + "47f7747b74073515bdbd65015738bc31947504d68255c5377baa21f179df2486", + ), + ( + "cr3-canon-90d-craw.cr3", + "https://raw.pixls.us/getfile.php/4650/nice/Canon%20-%20EOS%2090D%20-%203:2.CR3", + "c8c918b2d1a53e64d7d5009ebac89a920dbba5f766c2f396bb4ec95312b21034", + ), + ( + "cr3-canon-m50m2.cr3", + "https://raw.pixls.us/getfile.php/4430/nice/Canon%20-%20EOS%20M50%20Mark%20II%20-%203:2.CR3", + "1a3c11d85f48b8c644664b505f69fbdd75eb51270bb8ab556dbc660f0487e957", + ), + ( + "cr3-canon-m6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/3714/nice/Canon%20-%20EOS%20M6%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "94635c6f91e7d743cee6881cc2519992ae9f9031fd86b49e5d0f16edf5961b55", + ), + ( + "cr3-canon-r3.cr3", + "https://raw.pixls.us/getfile.php/5051/nice/Canon%20-%20EOS%20R3%20-%203:2.CR3", + "295d46a4d5383895e63126f67000d4352a5bb425ee4d8b16eb6b6c284bdf7994", + ), + ( + "cr3-canon-r50-craw.cr3", + "https://raw.pixls.us/getfile.php/6646/nice/Canon%20-%20EOS%20R50%20-%20CRAW%20%283:2%29.CR3", + "b79af9ffa184dd740e8af7a9f451f7150053d7e456b3435ca2e43bbbf5f65026", + ), + ( + "cr3-canon-r5-craw.cr3", + "https://raw.pixls.us/getfile.php/4698/nice/Canon%20-%20EOS%20R5%20-%203:2.CR3", + "2deae46b816e56ba91ae7a3892ca121290e12d5b22afb11f5b05a3d8f09f759f", + ), + ( + "cr3-canon-r6-craw.cr3", + "https://raw.pixls.us/getfile.php/4660/nice/Canon%20-%20EOS%20R6%20-%203:2.CR3", + "4775df76433b1e5844bc7a42b95d93efc8438c0a4cd9681e120e23fe3e3007e8", + ), + ( + "cr3-canon-r6m2-craw.cr3", + "https://raw.pixls.us/getfile.php/6409/nice/Canon%20-%20EOS%20R6%20Mark%20II%20-%203:2.cr3", + "409eb724212abde94e18fbd3170abf3264951fab13384c36cbf6fb4f22ea4201", + ), + ( + "cr3-canon-r7.cr3", + "https://raw.pixls.us/getfile.php/5633/nice/Canon%20-%20EOS%20R7%20-%203:2.CR3", + "887439cb1a45becd6a5c85fe75ae10e6c520a9f13a8b9ed077c6cf5d7c37700c", + ), + ( + "cr3-canon-r-craw.cr3", + "https://raw.pixls.us/getfile.php/4543/nice/Canon%20-%20EOS%20R%20-%203:2.CR3", + "1b3efe0664a28a0decb53e225cbfecdbc0dd27d85abdc4683fc2a4add5129b4d", + ), + ( + "cr3-canon-g5xm2-craw.cr3", + "https://raw.pixls.us/getfile.php/3506/nice/Canon%20-%20PowerShot%20G5%20X%20Mark%20II%20-%20C-RAW%20%283:2%29.CR3", + "16d7b944344004c866bf2f84c5673969e76adfe16e5fb4388da48c72bdd58058", + ), + ( + "cr3-canon-g7xm3.cr3", + "https://raw.pixls.us/getfile.php/6053/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20III%20-%203:2.CR3", + "63ce623055dd2ac7374e0b1c645a4eabc7e181cd12c2039625f64b4e63a5ee86", + ), + ( + "cr3-canon-sx70.cr3", + "https://raw.pixls.us/getfile.php/3276/nice/Canon%20-%20PowerShot%20SX70%20HS%20-%20RAW%2C%204:3%20%284:3%29.CR3", + "3b346078836f933f505338ba45965be17a39cac82bfffc7006db07e3af944839", + ), ( "dng-adobe-canon-5d3-linear-lj92.dng", "https://raw.pixls.us/getfile.php/1032/nice/Adobe%20DNG%20Converter%20-%20Canon%20EOS%205D%20Mark%20III%20-%20Lossless%20JPEG%20compression%2C%20rgb%20%283:2%29.DNG", @@ -646,6 +1117,18 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/download/data/Nikon/Z%20f/DSC_0040.NEF", "83c82be0be8865d796096dfbcc8ef2abf5af1bd37db44dfad6715070b0c99d15", ), + ( + // Z 6II, NIKKOR Z 24-200 at 70 mm, Vivid picture control (not used to fit the bundled Z 6II profile) + "nef-nikon-z6ii-lossless.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%20compressed%20(Lossless)%20(3:2).NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), + ( + // Z 8, NIKKOR Z 14-24 f/2.8 S (not used to fit the bundled Z 8 profile) + "nef-nikon-z8-lossless.nef", + "https://raw.pixls.us/data/Nikon/Z%208/Nikon_Z8_raw_14_bit_lossless_compression.NEF", + "c13a8675294456b9c979e6c1e556951654f35377b2870af2874bfb556f21ce40", + ), ( "nrw-nikon-b700-uncompressed.nrw", "https://raw.pixls.us/getfile.php/1621/nice/Nikon%20-%20COOLPIX%20B700%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", @@ -905,6 +1388,1329 @@ const RAW_SAMPLES: &[(&str, &str, &str)] = &[ "https://raw.pixls.us/getfile.php/4204/nice/Leica%20-%20D-Lux%207%20-%204:3.RWL", "4f6db6492da83f7f2a5012591e370557249d1ed44d4c948784016605b799327c", ), + ( + "nef-nikon-d700.nef", + "https://raw.pixls.us/getfile.php/2249/nice/Nikon%20-%20D700%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "00c3314f2ccf92aff17644943e6149e8793b517c24bb2c38a15ec41f3af109cb", + ), + ( + "raf-fujifilm-xf10.raf", + "https://raw.pixls.us/getfile.php/3124/nice/Fujifilm%20-%20XF10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0486676fdbbdc7f3f4c63cd45aa76581cbc1316f4c9baef8781bced0fbc137db", + ), + ( + "raf-fujifilm-x-a10.raf", + "https://raw.pixls.us/getfile.php/3570/nice/Fujifilm%20-%20X-A10%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "05309c5434500cc53d5782072cc0dcdd947d2f0a34f0ddcd4656c82cc7803272", + ), + ( + "nef-nikon-d5300.nef", + "https://raw.pixls.us/getfile.php/8365/nice/Nikon%20-%20D5300%20-%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0ae88fc81f082929d76dd1b932aacf7815cd6bc670b96ef55a81928e11084a6c", + ), + ( + "raf-fujifilm-x-t100.raf", + "https://raw.pixls.us/getfile.php/2676/nice/Fujifilm%20-%20X-T100%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "0af086b9569b59317da42b8e136013245486073b6b8d409fc997a71788538c4d", + ), + ( + "raf-fujifilm-x-pro1.raf", + "https://raw.pixls.us/getfile.php/1191/nice/Fujifilm%20-%20X-Pro1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "0b1046c299d9eb222a8f003bd047f2a34f507da0d1704ac32cefbc82f799d121", + ), + ( + "nef-nikon-1-v1.nef", + "https://raw.pixls.us/getfile.php/2210/nice/Nikon%20-%201%20V1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0dc76bc274f9cb776eb930683a4e53a3ebede1719abafd1cba4b0c800f13f7e4", + ), + ( + "nef-nikon-1-j4.nef", + "https://raw.pixls.us/getfile.php/5922/nice/Nikon%20-%201%20J4%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "0e2887892b8cfc80891b2c510721088fcac6d77eff9ba68089f382a0115b274c", + ), + ( + "raf-fujifilm-gfx100s-ii.raf", + "https://raw.pixls.us/getfile.php/7772/nice/Fujifilm%20-%20GFX100S%20II%20-%2016bit%20uncompressed%20%284:3%29.RAF", + "10e007f3d1aef2ac9bf4da063ea1983e44515af818bf0ab63e3703cd67803810", + ), + ( + "nef-nikon-d610.nef", + "https://raw.pixls.us/getfile.php/888/nice/Nikon%20-%20D610%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "11f0a71bb4141a8ed44c7e1c64f9a12571f5f67a11571f2bde8516aa95a7bea3", + ), + ( + "nef-nikon-d5.nef", + "https://raw.pixls.us/getfile.php/1636/nice/Nikon%20-%20D5%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "13191560a6a9d9ed4156ec5d20863277c9618916aaba934758c5ae2be6654f6a", + ), + ( + "nef-nikon-d2h.nef", + "https://raw.pixls.us/getfile.php/5227/nice/Nikon%20-%20D2H%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "155edb938f884ea7372ce98d4ff5f965c3e413b43b95bc9923da6e92082cf914", + ), + ( + "nef-nikon-z-6-2.nef", + "https://raw.pixls.us/getfile.php/4160/nice/Nikon%20-%20Z%206_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "1b67d15817dbd4143a1f1089e2f09e319a2a2327c124e2643284509dd7621030", + ), + ( + "nrw-nikon-coolpix-p1000.nrw", + "https://raw.pixls.us/getfile.php/3381/nice/Nikon%20-%20COOLPIX%20P1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "1d9470ee083f914e51aa0be782161b432f27f87dbef551a59ec435f5dbc289dd", + ), + ( + "raf-fujifilm-finepix-x100.raf", + "https://raw.pixls.us/getfile.php/2392/nice/Fujifilm%20-%20FinePix%20X100%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "1f4aea4fa43dd314f8b90954d7823655d4906d3e468a9ea014652b872a68db16", + ), + ( + "raf-fujifilm-x30.raf", + "https://raw.pixls.us/getfile.php/1144/nice/Fujifilm%20-%20X30%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "1f65f680600c85a6f561d2b0ad7cd1f9d93ee7f733aafe4ecd37667d984dd6c4", + ), + ( + "raf-fujifilm-finepix-x10.raf", + "https://raw.pixls.us/getfile.php/1344/nice/Fujifilm%20-%20FinePix%20X10%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "206448688c49b0a9da74dd94f12aba143c2d8842cb5bbf64d4ecdc501874451b", + ), + ( + "nef-nikon-d2xs.nef", + "https://raw.pixls.us/getfile.php/2871/nice/Nikon%20-%20D2Xs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "2204bc2e2178bf594e17886ca92a50a94350403a110846ca6e83bdc5f3352706", + ), + ( + "nef-nikon-d4.nef", + "https://raw.pixls.us/getfile.php/1974/nice/Nikon%20-%20D4%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "23aa2dd3c409b3cbbfeea3e3f7b6752180efa15430d6e059e87404e969875cd7", + ), + ( + "nef-nikon-d800.nef", + "https://raw.pixls.us/getfile.php/750/nice/Nikon%20-%20D800%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "26d58c21ed3019af5a22ef6bc02f017b863aad9eafcf84252ed375cd495c0efb", + ), + ( + "nef-nikon-d780.nef", + "https://raw.pixls.us/getfile.php/3825/nice/Nikon%20-%20D780%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "3178d0656e3b5fbe90176e38a1cd608d0b69292299445932dddc9a0b2d196b36", + ), + ( + "nef-nikon-d850.nef", + "https://raw.pixls.us/getfile.php/1835/nice/Nikon%20-%20D850%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "31b7fe298a5c91694c6ca8b3284a0a016e572a02c3964b502d4732e8e9283da8", + ), + ( + "raf-fujifilm-x-e2s.raf", + "https://raw.pixls.us/getfile.php/745/nice/Fujifilm%20-%20X-E2S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "330fc84b597e7be6d7477aad5715705efaa99d4663eb05aa47e1250d6f421549", + ), + ( + "nef-nikon-d500.nef", + "https://raw.pixls.us/getfile.php/2504/nice/Nikon%20-%20D500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "33c96a2ba7167f780928767c0545719737d601883e5f98e5edba4f71bdc99d83", + ), + ( + "raf-fujifilm-gfx-50r.raf", + "https://raw.pixls.us/getfile.php/2979/nice/Fujifilm%20-%20GFX%2050R%20-%2014bit%2014bit%20compressed%20%283:2%29.raf", + "35109fc20a849026c43c47674902431ef5a2d54b09b616995263dc75df17912f", + ), + ( + "nef-nikon-df.nef", + "https://raw.pixls.us/getfile.php/1385/nice/Nikon%20-%20Df%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "38ea7aba6b7e0cdbd338fe6703d3c98291f4a9a28b0d1a81bfb1f77b21712c63", + ), + ( + "raf-fujifilm-x-s10.raf", + "https://raw.pixls.us/getfile.php/4188/nice/Fujifilm%20-%20X-S10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "3d080f9ce8ac65c4beab1e9c0f49f7c72cdec71f31fe9e27dd1d88e551a1c262", + ), + ( + "nef-nikon-1-v2.nef", + "https://raw.pixls.us/getfile.php/1693/nice/Nikon%20-%201%20V2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3d29dfa6397b0aa4e72969bb5b85d040e8bebf2e02f61f6364f580ce39f30bf2", + ), + ( + "nef-nikon-1-v3.nef", + "https://raw.pixls.us/getfile.php/2802/nice/Nikon%20-%201%20V3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "3f5376481f9ae59e9712b537dcc4eaffa72e311a8768c5396414506ae2eb1d69", + ), + ( + "nef-nikon-d3s.nef", + "https://raw.pixls.us/getfile.php/5203/nice/Nikon%20-%20D3S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "402898e57007a4176b8686019ef2878c28481f7520c6136127f00148de9960b5", + ), + ( + "nef-nikon-z-fc.nef", + "https://raw.pixls.us/getfile.php/4812/nice/Nikon%20-%20Z%20fc%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.nef", + "4099ecc46ebaca26e2a66a96a8313fed5e8c707b3836ccf0e77a134844b041c1", + ), + ( + "nef-nikon-d810.nef", + "https://raw.pixls.us/getfile.php/2029/nice/Nikon%20-%20D810%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "40a9df99865ad25a68b1131014cf954fb72da8bc32991b6a5e19d14937e21bc9", + ), + ( + "raf-fujifilm-x-a3.raf", + "https://raw.pixls.us/getfile.php/1371/nice/Fujifilm%20-%20X-A3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4187cb5caa72320a74e9b4355db01e8e21edd43f3084873f9b9b67169d8d4ad8", + ), + ( + "nef-nikon-d40.nef", + "https://raw.pixls.us/getfile.php/3618/nice/Nikon%20-%20D40%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "44e88bc77b7a531b22647bcd07b9393c4568062e8f0906d3bbdecb42fbe29e03", + ), + ( + "nef-nikon-d800e.nef", + "https://raw.pixls.us/getfile.php/1303/nice/Nikon%20-%20D800E%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "4559b9614ab6d558192d9d30e1db4a4ec44752553e45b2bd26c1569c1820441a", + ), + ( + "nef-nikon-d3x.nef", + "https://raw.pixls.us/getfile.php/3708/nice/Nikon%20-%20D3X%20-%2014bit%2014bit%20uncompressed%20%283:2%29.NEF", + "48dc0339ef86541aabcc6bb68a6452f603e23a7d7a5597dad0d267f69e3e79d4", + ), + ( + "raf-fujifilm-x100v.raf", + "https://raw.pixls.us/getfile.php/3812/nice/Fujifilm%20-%20X100V%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "48f2055ab497c687347eee84103d4f48deb016fff711d2810eaf1317e506f621", + ), + ( + "nef-nikon-d4s.nef", + "https://raw.pixls.us/getfile.php/2220/nice/Nikon%20-%20D4S%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "49067c81374549465f3c621acd249bf05dbdef4e44b9201fef1fb11c93572983", + ), + ( + "raf-fujifilm-x-t30-iii.raf", + "https://raw.pixls.us/getfile.php/8769/nice/Fujifilm%20-%20X-T30%20III%20-%2014bit%20compressed%20%283:2%29.RAF", + "49f77d6162abfa5c94d2d8b90e4e926b7386c42bcf7e84a152c9ffe1ebd584da", + ), + ( + "raf-fujifilm-xq2.raf", + "https://raw.pixls.us/getfile.php/1844/nice/Fujifilm%20-%20XQ2%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "4bce88593f5ae8fc45aeeec8a6dee9a84c773493cc6db9dabdc28744c8ac4b4d", + ), + ( + "raf-fujifilm-x-t10.raf", + "https://raw.pixls.us/getfile.php/1192/nice/Fujifilm%20-%20X-T10%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4c09401975cc6f95b1284693aac39fef85498f70b55e35bf458a133c6e61885d", + ), + ( + "nef-nikon-d2x.nef", + "https://raw.pixls.us/getfile.php/9064/nice/Nikon%20-%20D2X%20-%2012bit%20uncompressed%20%283:2%29.NEF", + "4d4b89f85bdfef233ef9b8aa7ecfeb609874ab592cb44e777f5b36814df5883c", + ), + ( + "raf-fujifilm-x70.raf", + "https://raw.pixls.us/getfile.php/1301/nice/Fujifilm%20-%20X70%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "4f9c33fde578addebda991c37d26551b46fab8f592c74f7192560a05ec08d577", + ), + ( + "dng-nikon-d80.dng", + "https://raw.pixls.us/getfile.php/1988/nice/Nikon%20-%20D80%20-%2016bit%20%283:2%29.dng", + "5095ca264e763624cd3aa62940286dd1bcff4504eefa1ce61cbb72f50f8426bb", + ), + ( + "nef-nikon-1-aw1.nef", + "https://raw.pixls.us/getfile.php/1171/nice/Nikon%20-%201%20AW1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "519d354ec40907f1cd30329fc1a641ddec5b46e08e88883f3cf2fddc4ddd9974", + ), + ( + "raf-fujifilm-x-h1.raf", + "https://raw.pixls.us/getfile.php/2537/nice/Fujifilm%20-%20X-H1%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5210bf896d2757cecda88bab0a87ef0b534d4b36c31dc08d5db7cd8f5bbf9b4e", + ), + ( + "nef-nikon-1-j5.nef", + "https://raw.pixls.us/getfile.php/1342/nice/Nikon%20-%201%20J5%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.nef", + "56efc4f426fa489383a967102c6d30fa9d6b198ca0484c4725c91f0b0d47c063", + ), + ( + "nef-nikon-1-j1.nef", + "https://raw.pixls.us/getfile.php/1017/nice/Nikon%20-%201%20J1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5965935729931b72ca8398041f8fc15cb0a226004db941d1f0626ccc2c5bb5f3", + ), + ( + "nef-nikon-d7200.nef", + "https://raw.pixls.us/getfile.php/832/nice/Nikon%20-%20D7200%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "5dea8ff6f95f372e4eb0852f75e728f182d7aa4057b2b76631f0fd3066c6a4b5", + ), + ( + "raf-fujifilm-x-t30-ii.raf", + "https://raw.pixls.us/getfile.php/5064/nice/Fujifilm%20-%20X-T30%20II%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "5e2678038377cc7f146ceb7693c08535fa1af162cec705d06b0446c3d3017d47", + ), + ( + "nef-nikon-z-50.nef", + "https://raw.pixls.us/getfile.php/3647/nice/Nikon%20-%20Z%2050%20-%2014bit%2014bit%20compressed%20%283:2%29.NEF", + "5e93735923229117abee2779d81f114f180f361e8ae553c9dd9bdaaedb0d9e02", + ), + ( + "nef-nikon-d5000.nef", + "https://raw.pixls.us/getfile.php/1341/nice/Nikon%20-%20D5000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "5f8e8005be87366bbf7746d35c3c88f82a12a1dd18206c939d8f1254c5922360", + ), + ( + "raf-fujifilm-xq1.raf", + "https://raw.pixls.us/getfile.php/1741/nice/Fujifilm%20-%20XQ1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "663c0bfcb8819c3931444acd9d1491de4d0c0aa1fd745b99e1faa68cc8de033b", + ), + ( + "raf-fujifilm-finepix-hs30exr.raf", + "https://raw.pixls.us/getfile.php/2613/nice/Fujifilm%20-%20FinePix%20HS30EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "6b95bb5133d026dbbb7361985e81d9a2b93000e2aea88bcd3f120412d0061da7", + ), + ( + "nef-nikon-d70s.nef", + "https://raw.pixls.us/getfile.php/1117/nice/Nikon%20-%20D70s%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "6c46f9854033e7623ebbb5de8c951df6762ca44f05d45035ccc5cb7e9f4e5692", + ), + ( + "nef-nikon-d5200.nef", + "https://raw.pixls.us/getfile.php/1691/nice/Nikon%20-%20D5200%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "6c49ca47d189f9d6766f94be7c9d7d779ce45c79c0c6e48a0eba7d92f320fe0e", + ), + ( + "nrw-nikon-coolpix-p1100.nrw", + "https://raw.pixls.us/getfile.php/7689/nice/Nikon%20-%20Coolpix%20P1100%20-%2012bit%20uncompressed%20%284:3%29.NRW", + "706840f7a36c3852092d4aad0465e75920e04f1aa653adf31626c0c05ab41258", + ), + ( + "raf-fujifilm-x-s20.raf", + "https://raw.pixls.us/getfile.php/6666/nice/Fujifilm%20-%20X-S20%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "737c1551843282d4963ccb351a4d6409f3eaf172928d563bbaf72fe0f1f74fdd", + ), + ( + "nef-nikon-z-30.nef", + "https://raw.pixls.us/getfile.php/5812/nice/Nikon%20-%20Z%2030%20-%2012bit%2012bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "75249c11a3a845367c7a322a1c4e7076f33c785ec2518fc6594c34c185b41161", + ), + ( + "raf-fujifilm-x-e2.raf", + "https://raw.pixls.us/getfile.php/937/nice/Fujifilm%20-%20X-E2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "7588c70af7fd6be3abcec52546aabcd04e0aafc6541e7f7f50bdc60b60513c57", + ), + ( + "nef-nikon-d60.nef", + "https://raw.pixls.us/getfile.php/3698/nice/Nikon%20-%20D60%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "780ce4867e997143acbd1734a889575a5eaeeb40c6afb33b6006c266831caaa6", + ), + ( + "nef-nikon-1-j2.nef", + "https://raw.pixls.us/getfile.php/923/nice/Nikon%20-%201%20J2%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "81051ee36135883acea55bd1e609d6d31212c0bf03640b7ae9fe177a1ca51219", + ), + ( + "nef-nikon-d5600.nef", + "https://raw.pixls.us/getfile.php/1415/nice/Nikon%20-%20D5600%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "82e15f02c4037820e80f14bacfacdc52ea29721b3c70d2cf08b237b50ce8db05", + ), + ( + "nef-nikon-d3.nef", + "https://raw.pixls.us/getfile.php/3516/nice/Nikon%20-%20D3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "880c60c5f611adf6b70de3d099f8433492de3a3d96866d570122f17f0a651fc8", + ), + ( + "nef-nikon-d300.nef", + "https://raw.pixls.us/getfile.php/924/nice/Nikon%20-%20D300%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "8b989f53c96995f7014ddd16124530cf6d8f3341ea9570d6312d9da1eb0a117a", + ), + ( + "raf-fujifilm-finepix-sl1000.raf", + "https://raw.pixls.us/getfile.php/5760/nice/Fujifilm%20-%20FinePix%20SL1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "8bc9903cb1bff93376eb73b64f35861dcf3c66e2ca936cfac2d7c62435689dec", + ), + ( + "raf-fujifilm-x-pro3.raf", + "https://raw.pixls.us/getfile.php/3700/nice/Fujifilm%20-%20X-Pro3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8c50e965d7627932414e8790a5ef1d673fcb44b548b64130df6b9be42c418554", + ), + ( + "raf-fujifilm-x-pro2.raf", + "https://raw.pixls.us/getfile.php/1680/nice/Fujifilm%20-%20X-Pro2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8cc12566e5c0fc676b713bb1612ce0510d47b1dc691e9a5a0bcf3799ad9b8af1", + ), + ( + "nef-nikon-1-s1.nef", + "https://raw.pixls.us/getfile.php/5661/nice/Nikon%20-%201%20S1%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "8e540d40b842f61de69161cb0b8be72b024629c752fd82b6f8d10e460bce88cb", + ), + ( + "raf-fujifilm-x100vi.raf", + "https://raw.pixls.us/getfile.php/7300/nice/Fujifilm%20-%20X100VI%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8e5e4d8d2e0a865ca79b3d40734b6abc2f4b00281afea8b9bd3d8c29808f2fb3", + ), + ( + "raf-fujifilm-x-t200.raf", + "https://raw.pixls.us/getfile.php/3899/nice/Fujifilm%20-%20X-T200%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "8fc8dfa5b4741246287c430010d9699fe6f1e512ac03e14abea72e63cb022051", + ), + ( + "nef-nikon-d3200.nef", + "https://raw.pixls.us/getfile.php/743/nice/Nikon%20-%20D3200%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "91657dd8e9c7a086826683f6d20799685657dd7ca2056702b145851465f57cd3", + ), + ( + "raf-fujifilm-x-t3.raf", + "https://raw.pixls.us/getfile.php/2783/nice/Fujifilm%20-%20X-T3%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "95b33021160b239ceb1a09d46a1b29cb60cb7dd47581feb126b337c219091754", + ), + ( + "raf-fujifilm-finepix-f550exr.raf", + "https://raw.pixls.us/getfile.php/3869/nice/Fujifilm%20-%20FinePix%20F550EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "95b6c8a9d5d885f60bbc965ddfd899d325f2225c99ab087d02739543a3ff7dd6", + ), + ( + "nef-nikon-d5500.nef", + "https://raw.pixls.us/getfile.php/1469/nice/Nikon%20-%20D5500%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "96d5b85dae24e190691c504781d8e7ee58cb9b63ea715760de9af3d345dc4c13", + ), + ( + "raf-fujifilm-x-a7.raf", + "https://raw.pixls.us/getfile.php/3879/nice/Fujifilm%20-%20X-A7%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "97db251dd129663da00d33e8eb4d7932d222f58d99ff7311c09cc277de5b6f18", + ), + ( + "nef-nikon-d3300.nef", + "https://raw.pixls.us/getfile.php/1734/nice/Nikon%20-%20D3300%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "97dfc95aecc00383d6da6197cb759bacb1e43c86071b4ca2d94748e498620f2f", + ), + ( + "nef-nikon-z-5.nef", + "https://raw.pixls.us/getfile.php/4136/nice/Nikon%20-%20Z%205%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "9870248c45532080c2459f3b52cdcc4a07bc8846ad50cbb81c7c1c6dc5e36d91", + ), + ( + "nef-nikon-1-j3.nef", + "https://raw.pixls.us/getfile.php/1814/nice/Nikon%20-%201%20J3%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "996d65705bf1ac9f83f055817ea92618ea44c822109196d7d97a803a0268b576", + ), + ( + "nef-nikon-z-9.nef", + "https://raw.pixls.us/getfile.php/5146/nice/Nikon%20-%20Z%209%20-%208bit%208bit%20compressed%20%283:2%29.NEF", + "99d0fe24a38f0f7623aeab21440d266a5ac09f9665cf5283f590337e84323c38", + ), + ( + "nrw-nikon-coolpix-a1000.nrw", + "https://raw.pixls.us/getfile.php/4337/nice/Nikon%20-%20Coolpix%20A1000%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "9b4edd03bbb17d66eacc2a72b30f044969e67666446f3f1ed25e88bb947d4f8a", + ), + ( + "raf-fujifilm-x100s.raf", + "https://raw.pixls.us/getfile.php/2115/nice/Fujifilm%20-%20X100S%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "9cea5b0d4dd3682f8850dc34e93f0045e0e28fa69f9715576d40c5a8a59e8ae9", + ), + ( + "nef-nikon-d2hs.nef", + "https://raw.pixls.us/getfile.php/2852/nice/Nikon%20-%20D2Hs%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "9ea1c3d5d6c8a11f33239079559f1f0c86bbdb9440a2c7d2578368647abf7ea4", + ), + ( + "nef-nikon-d90.nef", + "https://raw.pixls.us/getfile.php/1325/nice/Nikon%20-%20D90%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "9eb3daa4e3d1c1614bccc4c6d136c4166960f936f17c437cc4d11a5786b2df06", + ), + ( + "nef-nikon-d50.nef", + "https://raw.pixls.us/getfile.php/1282/nice/Nikon%20-%20D50%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "9f94c27672a63f946c92d43910ef0c266dffaa14dae0007ae00ac72d2c684935", + ), + ( + "nef-nikon-d3000.nef", + "https://raw.pixls.us/getfile.php/2161/nice/Nikon%20-%20D3000%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "a07ee8a0b1ff7a1ad06085306ef8f9a197d95fec639994467317c56108eeb435", + ), + ( + "raf-fujifilm-x100t.raf", + "https://raw.pixls.us/getfile.php/2137/nice/Fujifilm%20-%20X100T%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "a12bb7fa3585b636b7fa487d767ba0ba1ae10fe6aaa74db4147dc0ec2b180a0a", + ), + ( + "nef-nikon-z50-2.nef", + "https://raw.pixls.us/getfile.php/7762/nice/Nikon%20-%20Z50_2%20-%208bit%20compressed%20%283:2%29.NEF", + "a3314a026524396e603b461afc4fe61a8a5a3958232f3687bb1c8eca0dcbed93", + ), + ( + "nef-nikon-d600.nef", + "https://raw.pixls.us/getfile.php/916/nice/Nikon%20-%20D600%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "a70c3e54501efa7e4391ec4cf5705912a10c356a029e984ee13be5f68bc7482d", + ), + ( + "raf-fujifilm-xf1.raf", + "https://raw.pixls.us/getfile.php/3209/nice/Fujifilm%20-%20XF1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b17c389a59228881af8d50c22bdd8ce2295a54290d9a18447dd1e56d0743e913", + ), + ( + "nef-nikon-d7100.nef", + "https://raw.pixls.us/getfile.php/1855/nice/Nikon%20-%20D7100%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "b241cb95b1008f25b8ed7d1443354e8cc7dee9c4d4e1edd0a5856d92063a48d0", + ), + ( + "raf-fujifilm-x-e3.raf", + "https://raw.pixls.us/getfile.php/1864/nice/Fujifilm%20-%20X-E3%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "b2aeb7fd72a9ea116b1dcc2c8a8cf5462b15757ba3aca33bad8ce36339a1642f", + ), + ( + "raf-fujifilm-x-a1.raf", + "https://raw.pixls.us/getfile.php/1214/nice/Fujifilm%20-%20X-A1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "b2ddb5fe31a3b976d12961c72a2689de8742739df3d8f757614eca65f8df2a61", + ), + ( + "nef-nikon-d3500.nef", + "https://raw.pixls.us/getfile.php/2902/nice/Nikon%20-%20D3500%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "b53395f74674588a63f6b667325c5a2b400f26cba72ff402a65ddd02c5f841d5", + ), + ( + "raf-fujifilm-finepix-hs50exr.raf", + "https://raw.pixls.us/getfile.php/6018/nice/Fujifilm%20-%20FinePix%20HS50EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "b5c4f3658dc686e535eeaf639729f2cb6b54e60a3e1ab206eabba1024c4dce83", + ), + ( + "raf-fujifilm-gfx100-ii.raf", + "https://raw.pixls.us/getfile.php/7174/nice/Fujifilm%20-%20GFX100%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "b7b329bcbfcc07c5523a2b172a5173f4de4b841c8315078a1b69150ddaa16f30", + ), + ( + "raf-fujifilm-x100f.raf", + "https://raw.pixls.us/getfile.php/1936/nice/Fujifilm%20-%20X100F%20-%2014bit%2014bit%20compressed%20%283:2%29.RAF", + "bca8ad027b0ee0038107c01792ccb5c6b776512b77b584c6f93c6f2ced5f15a3", + ), + ( + "nef-nikon-d40x.nef", + "https://raw.pixls.us/getfile.php/804/nice/Nikon%20-%20D40X%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "c3e4956f7e2d1f1b3f846b6ec9552678073db0bcaeb51132ec4c60bd2229d56e", + ), + ( + "nef-nikon-coolpix-a.nef", + "https://raw.pixls.us/getfile.php/2238/nice/Nikon%20-%20Coolpix%20A%20-%2014bit%2014bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "c5ca0333a7cda8a2fc33fb2157d18dfa89f1844548dc49c2fd2e67f502f72e25", + ), + ( + "raf-fujifilm-x20.raf", + "https://raw.pixls.us/getfile.php/1549/nice/Fujifilm%20-%20X20%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ccb4fad836f1561b52859c3ee01db0fef7db770980d4a84e95595da1eda72b2a", + ), + ( + "raf-fujifilm-x-e4.raf", + "https://raw.pixls.us/getfile.php/4446/nice/Fujifilm%20-%20X-E4%20-%2014bit%2014bit%20compressed%20lossless%20%283:2%29.RAF", + "ce4215079e4c6c8a86d493e15f6e574e4aa7684b346ef3439026c835ed2c8948", + ), + ( + "raf-fujifilm-gfx50s-ii.raf", + "https://raw.pixls.us/getfile.php/5002/nice/Fujifilm%20-%20GFX50S%20II%20-%2014bit%2014bit%20uncompressed%20%284:3%29.RAF", + "cf53a9018d5a77cd854ea0a91afee2a35ac8fab8f814020d79f728f99ba99892", + ), + ( + "nef-nikon-d1h.nef", + "https://raw.pixls.us/getfile.php/5364/nice/Nikon%20-%20D1H%20-%2012bit%2012bit%20uncompressed%20%283:2%29.NEF", + "d110b80a8b10a3a67b5cd1c15ef33f1a6397557b73d5d3f4e565739f0c072571", + ), + ( + "nef-nikon-d200.nef", + "https://raw.pixls.us/getfile.php/824/nice/Nikon%20-%20D200%20-%2012bit%2012bit%20uncompressed%20%283:2%29.nef", + "d468895b0b30a60d05a7f6468d69f2f15830dd49b2d82698b34245072a22e747", + ), + ( + "raf-fujifilm-finepix-hs33exr.raf", + "https://raw.pixls.us/getfile.php/2907/nice/Fujifilm%20-%20FinePix%20HS33EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d72d5b67d67379fa310fc5a4615016d9456d086a8bfd32c74a3b256e0dfecee8", + ), + ( + "raf-fujifilm-finepix-s1.raf", + "https://raw.pixls.us/getfile.php/1651/nice/Fujifilm%20-%20FinePix%20S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "d77f104a9fa4ce11c78b5078ac40acf8b150d67ea837d187e0903d208bf2dbae", + ), + ( + "nef-nikon-z-7-2.nef", + "https://raw.pixls.us/getfile.php/4213/nice/Nikon%20-%20Z%207_2%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "dc83d3e49db54ca9c3a1e41dc847afd213c822d482c40158e42d01ee8968f33b", + ), + ( + "nef-nikon-d70.nef", + "https://raw.pixls.us/getfile.php/2060/nice/Nikon%20-%20D70%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%201%29%29%20%283:2%29.NEF", + "dd6405aeb33b0cd5bf66c98ba98ccbb478a765450cfd130810e470dab8d1f4b4", + ), + ( + "nef-nikon-d300s.nef", + "https://raw.pixls.us/getfile.php/3049/nice/Nikon%20-%20D300S%20-%2014bit%2014bit%20compressed%20%28Lossless%29%20%283:2%29.NEF", + "de950a2263c87f085ee611a21e69fc4e0ecb6e417fff61ff6a23ea3121e21a81", + ), + ( + "raf-fujifilm-x-s1.raf", + "https://raw.pixls.us/getfile.php/997/nice/Fujifilm%20-%20X-S1%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "e4652f3710cc972712a25370988ad8d72c8bf368956215348ea77b17d95f36f7", + ), + ( + "nef-nikon-d750.nef", + "https://raw.pixls.us/getfile.php/895/nice/Nikon%20-%20D750%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e4a3f85e014275fc0a47600a373af95ec850872a5f6cf87649bcaa42b77ef2e4", + ), + ( + "nef-nikon-d3100.nef", + "https://raw.pixls.us/getfile.php/4229/nice/Nikon%20-%20D3100%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "e8113b6e7285155f9f2b88f81566dab63a59cf5559d6915113b74e8c3a8680f6", + ), + ( + "raf-fujifilm-x-t1.raf", + "https://raw.pixls.us/getfile.php/2421/nice/Fujifilm%20-%20X-T1%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "e994a1fd6e87e392432fe146a35b0b88584dc2bd50bee2c8c7e886ac2b59fcde", + ), + ( + "nef-nikon-d3400.nef", + "https://raw.pixls.us/getfile.php/1561/nice/Nikon%20-%20D3400%20-%2012bit%2012bit%20compressed%20%28Lossy%20%28type%202%29%29%20%283:2%29.NEF", + "eced190786231f0081261b6c6b425fe23e42a53ade21dec3def5ae8551be71a5", + ), + ( + "raf-fujifilm-x-m1.raf", + "https://raw.pixls.us/getfile.php/842/nice/Fujifilm%20-%20X-M1%20-%2012bit%2012bit%20uncompressed%20%283:2%29.RAF", + "eef4e9ae629d2d81cef5a234fb853c14c88b3c0c12c39d635a1f9fa2a871bec3", + ), + ( + "raf-fujifilm-x-t30.raf", + "https://raw.pixls.us/getfile.php/3068/nice/Fujifilm%20-%20X-T30%20-%2014bit%2014bit%20uncompressed%20%283:2%29.RAF", + "f0353544bc62a6816385de0850e1d37e2f3383892a7eb3928c73e4517fe47ff5", + ), + ( + "nrw-nikon-coolpix-p950.nrw", + "https://raw.pixls.us/getfile.php/3806/nice/Nikon%20-%20COOLPIX%20P950%20-%2012bit%2012bit%20uncompressed%20%284:3%29.NRW", + "fc6136b9e5a7e133d7dc30fe65baa04c09ff8c18ca3c7ad82b4ffdbb71ce5bef", + ), + ( + "raf-fujifilm-finepix-f770exr.raf", + "https://raw.pixls.us/getfile.php/3039/nice/Fujifilm%20-%20FinePix%20F770EXR%20-%2012bit%2012bit%20uncompressed%20%284:3%29.RAF", + "ffc905e325a00c57a5335681654fdf226b724269cd5d26cd976983939f88043d", + ), + ( + "dng-leica-m-monochrom.dng", + "https://raw.pixls.us/getfile.php/974/nice/Leica%20-%20M%20Monochrom%20-%2016bit%20%283:2%29.DNG", + "149d5b3fbd5cd40d51c1f56d5e392a81870d129e047a6c8a596447590e7a0e4f", + ), + ( + "dng-leica-cl.dng", + "https://raw.pixls.us/getfile.php/2489/nice/Leica%20-%20CL%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "1f4cfa565d176d85838d96bd3eb598bbeed719cac8acb9999d5823c527635950", + ), + ( + "dng-leica-m10.dng", + "https://raw.pixls.us/getfile.php/1603/nice/Leica%20-%20M10%20-%2016bit%2016bit%20uncompressed%20%283:2%29.dng", + "26be5e06dfb53a2938dab3ca8f06024533a9c0dd0c652938f39eb48bc325f95e", + ), + ( + "rwl-leica-c-lux.rwl", + "https://raw.pixls.us/getfile.php/3863/nice/Leica%20-%20C-Lux%20-%204:3.RWL", + "286106ec2df65539912709c64e84a9b94686443fe5ea05b8ebada76daa1feb2d", + ), + ( + "arw-sony-zv-1m2.arw", + "https://raw.pixls.us/getfile.php/8461/nice/Sony%20-%20ZV-1M2%20-%2012bit%20%283:2%29.ARW", + "2a45845a93ab5e428868366d6575a264818b184541b1c114ab4de980872a7184", + ), + ( + "dng-leica-m-monochrom-typ-246.dng", + "https://raw.pixls.us/getfile.php/1087/nice/Leica%20-%20M%20Monochrom%20%28Typ%20246%29%20-%2012bit%20%283:2%29.DNG", + "3fb74d1fc402daaa48a9c3f1d6279d7e85fb58dfdaf816f036927d49ab228977", + ), + ( + "arw-sony-zv-e10m2.arw", + "https://raw.pixls.us/getfile.php/8021/nice/Sony%20-%20ZV-E10M2%20-%2012bit%20%284:3%29.ARW", + "49db06da969cf3894cf070d9171fe0ed4d0f125e16a87b5eba7eaec27cd75595", + ), + ( + "arw-sony-dsc-rx1rm3.arw", + "https://raw.pixls.us/getfile.php/8544/nice/Sony%20-%20DSC-RX1RM3%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "6ef9956b343051748a6a16ba9ba7bd0d314a4ae0b4f8d8e2f1c13b4a1e50cfb8", + ), + ( + "arw-sony-ilce-1m2.arw", + "https://raw.pixls.us/getfile.php/7827/nice/Sony%20-%20ILCE-1M2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "7def3c8d80537f79af199052c6e13d86750203122dcb94888ada65291dabb793", + ), + ( + "dng-leica-m8.dng", + "https://raw.pixls.us/getfile.php/2988/nice/Leica%20-%20M8%20-%208bit%208bit%20uncompressed%20%283:2%29.DNG", + "7fdcca2371bb07fbf9483fcb08e3b5ab093eca1ca3924cbc86d6863736c31153", + ), + ( + "arw-sony-dsc-rx100m7a.arw", + "https://raw.pixls.us/getfile.php/8096/nice/Sony%20-%20DSC-RX100M7A%20-%2012bit%20%283:2%29.ARW", + "80adbc5b1cb3342058d5998cf2eda1e6b408565169462d6d26dfe02f0c140b30", + ), + ( + "arw-sony-ilce-7m5.arw", + "https://raw.pixls.us/getfile.php/8845/nice/Sony%20-%20ILCE-7M5%20-%2014bit%20%284:3%29.ARW", + "8c35cd1ab0ca36b487ae50ae5e0333a8e967122700fce5678e1e5b97561896a4", + ), + ( + "arw-sony-ilme-fx2.arw", + "https://raw.pixls.us/getfile.php/8807/nice/Sony%20-%20ILME-FX2%20-%2014bit%20uncompressed%20%283:2%29.ARW", + "924a6e4efd5c068883dd7de2bf2a2d155c7c13bdde668232077bdf9d92a201a8", + ), + ( + "dng-leica-x2.dng", + "https://raw.pixls.us/getfile.php/882/nice/Leica%20-%20X2%20-%2012bit%20%283:2%29.DNG", + "a67eca6271afb10510ffc9cf998b641d0e8a0ebfa4c779a7bc0bfed5d28b62dd", + ), + ( + "dng-leica-q2.dng", + "https://raw.pixls.us/getfile.php/3204/nice/Leica%20-%20Q2%20-%2014bit%2014bit%20uncompressed%20%283:2%29.DNG", + "a71f98405e9e9c1e4ac0f2d2e6c52d65daae3234fc0a9f6eed2a84ee4ce794ad", + ), + ( + "arw-sony-ilce-6400a.arw", + "https://raw.pixls.us/getfile.php/7826/nice/Sony%20-%20ILCE-6400A%20-%2014bit%20%283:2%29.ARW", + "a8a45e9b6c437eba6724fcda24c3efd084c05389711b955d84b2af67635e241b", + ), + ( + "dng-leica-sl-typ-601.dng", + "https://raw.pixls.us/getfile.php/1951/nice/Leica%20-%20SL%20%28Typ%20601%29%20-%2014bit%20%283:2%29.DNG", + "aa9fd9ef937ed34467d6f91bfa2603be00cb47542b7fffd7258b79652d3b7bef", + ), + ( + "dng-leica-m10-r.dng", + "https://raw.pixls.us/getfile.php/7853/nice/Leica%20-%20M10-R%20-%2016bit%20compressed%20%283:2%29.DNG", + "ba35d25d521c7ddbc4feca69c8cdbe071ac65dcd6cf2c93374391bf6f3662b8d", + ), + ( + "dng-leica-m-typ-240.dng", + "https://raw.pixls.us/getfile.php/2317/nice/Leica%20-%20M%20%28Typ%20240%29%20-%2016bit%2016bit%20compressed%20%283:2%29.DNG", + "bf35a4da121f853cb644253b69515b441748f95fcbd6fdea2de025a76e6c24cd", + ), + ( + "dng-leica-sl2.dng", + "https://raw.pixls.us/getfile.php/7872/nice/Leica%20-%20SL2%20-%2014bit%20uncompressed%20%283:2%29.DNG", + "d00f05468793e4509b53230e90dcdc078e823b254eee95f1c68b29e217df99bf", + ), + ( + "dng-leica-m9.dng", + "https://raw.pixls.us/getfile.php/753/nice/Leica%20-%20M9%20-%208bit%208bit%20compressed%20%283:2%29.DNG", + "d1d162fce62210b8951c862189a6098329b5163b7f7492dc397c6e53bba04239", + ), + ( + "dng-leica-q-typ-116.dng", + "https://raw.pixls.us/getfile.php/1797/nice/Leica%20-%20Q%20%28Typ%20116%29%20-%2014bit%20%283:2%29.DNG", + "f7b2a6b6d7684536511cf2db2e2bfa46fae1ad7e52214093714f35e7ca60acbf", + ), + ( + "srw-samsung-nx200.srw", + "https://raw.pixls.us/getfile.php/839/nice/Samsung%20-%20NX200%20-%2012bit%20%283:2%29.srw", + "03563eadfe4ef32b5bf2a2850abb40b29fcc1f8d56b3b2ed92ef307b90d7d9dc", + ), + ( + "orf-olympus-sp550uz.orf", + "https://raw.pixls.us/getfile.php/8609/nice/Olympus%20-%20SP550UZ%20-%2012bit%20%284:3%29.ORF", + "05f49918e1b1894c3abd2b5451b356f7a00adb1c8568abc5babff33fcade1bb2", + ), + ( + "orf-olympus-e-10.orf", + "https://raw.pixls.us/getfile.php/5424/nice/Olympus%20-%20E-10%20-%2016bit%20%284:3%29.ORF", + "2bfdade72439017a60a47aad1e5bbcb1aca36f2be7a21e94a4257a678fb6f4da", + ), + ( + "orf-olympus-c5060wz.orf", + "https://raw.pixls.us/getfile.php/1799/nice/Olympus%20-%20C5060WZ%20-%2012bit%20%284:3%29.ORF", + "3fc12bc3f6320cfab456111d7805b446e329fdd1907680be55e2e163e5e084bd", + ), + ( + "dng-samsung-gx10.dng", + "https://raw.pixls.us/getfile.php/1165/nice/Samsung%20-%20GX10%20-%2012bit%20%283:2%29.DNG", + "44cbb7980b320c0920d999758eb8a3f27e6b98dd14afc3dfc135bc8526abda58", + ), + ( + "orf-olympus-sp565uz.orf", + "https://raw.pixls.us/getfile.php/3438/nice/Olympus%20-%20SP565UZ%20-%2012bit%20%284:3%29.ORF", + "5088771f83ce08a001b44672e1dd9f013e603933c37ce1bfb7030a02abbe801c", + ), + ( + "dng-samsung-gx20.dng", + "https://raw.pixls.us/getfile.php/1537/nice/Samsung%20-%20GX20%20-%2012bit%20%283:2%29.DNG", + "5557016b2712b621befdb05731923562a802beafd13e0f366737390a8f7b3286", + ), + ( + "orf-olympus-e-m5-mark-ii.orf", + "https://raw.pixls.us/getfile.php/2856/nice/Olympus%20-%20E-M5%20Mark%20II%20-%2016bit%2016bit%20%22high-res%22%20%284:3%29.ORF", + "5c42fa75d6b549514b722e2c50726c03e34fac4909ec3640e147ea7fa825fc8d", + ), + ( + "orf-olympus-e-300.orf", + "https://raw.pixls.us/getfile.php/2878/nice/Olympus%20-%20E-300%20-%2016bit%20%284:3%29.ORF", + "6d818dbff78b3e2efa4835f568d04733052b9211e6bc65b0a3748a841170e95b", + ), + ( + "orf-olympus-sh-2.orf", + "https://raw.pixls.us/getfile.php/792/nice/Olympus%20-%20SH-2%20-%2012bit%20%284:3%29.ORF", + "6e6b9b7dd9ded7c4b364b83116c817ca2d6daac15f2e9ef7796159b08c3288a1", + ), + ( + "srw-samsung-nx5.srw", + "https://raw.pixls.us/getfile.php/5684/nice/Samsung%20-%20NX5%20-%2012bit%20%283:2%29.SRW", + "908fc61d5bbc1f0d0a5cea4f4e39e7753060c1af8673f7a4a9199284b70930f7", + ), + ( + "orf-olympus-c7070wz.orf", + "https://raw.pixls.us/getfile.php/780/nice/Olympus%20-%20C7070WZ%20-%2012bit%20%284:3%29.ORF", + "9d55506f9d6ee31d2d4c4b6df0dcd1d016d358b3e5eb4ed31edd7227dfefd5f8", + ), + ( + "orf-olympus-xz-10.orf", + "https://raw.pixls.us/getfile.php/2431/nice/Olympus%20-%20XZ-10%20-%2012bit%20%284:3%29.ORF", + "a25383acad9b5bb0bb404715e0957f2eb80aa1aaad6c448a71025c03cfa631be", + ), + ( + "srw-samsung-wb2000.srw", + "https://raw.pixls.us/getfile.php/4262/nice/Samsung%20-%20WB2000%20-%2014bit%20%284:3%29.SRW", + "b9a1149fb0dda9d23aee0ad8c402bf2f9ba42832366d46c4c1b739c0b0f48497", + ), + ( + "orf-olympus-e-330.orf", + "https://raw.pixls.us/getfile.php/3624/nice/Olympus%20-%20E-330%20-%2016bit%20%284:3%29.ORF", + "ba11eb970ab32e1e9cf3d0176041e4bd11c501d445720c0a344217e785ad65a6", + ), + ( + "orf-olympus-sp570uz.orf", + "https://raw.pixls.us/getfile.php/5491/nice/Olympus%20-%20SP570UZ%20-%2012bit%20%284:3%29.ORF", + "baab92391834076b427e406e98da79aaba48fb87243d6ccae60e08e299cfccf7", + ), + ( + "srw-samsung-nx11.srw", + "https://raw.pixls.us/getfile.php/5985/nice/Samsung%20-%20NX11%20-%2012bit%20%283:2%29.SRW", + "bdce6179b4180478057f4971b512e0303da71528db454cb3f399ab10e05b68fe", + ), + ( + "srw-samsung-nx1000.srw", + "https://raw.pixls.us/getfile.php/2588/nice/Samsung%20-%20NX1000%20-%2012bit%20%283:2%29.SRW", + "beef4d1e6f25ff28fb20bb3ad00ffadb62ab57d7041237e295b0b62f7de5309c", + ), + ( + "srw-samsung-nx1100.srw", + "https://raw.pixls.us/getfile.php/4422/nice/Samsung%20-%20NX1100%20-%2012bit%20%283:2%29.srw", + "d230af65ee55314792c5337ea8e0e757626a8541e7a25ed03d63b8df202ed9cb", + ), + ( + "srw-samsung-nx210.srw", + "https://raw.pixls.us/getfile.php/1831/nice/Samsung%20-%20NX210%20-%2012bit%20%283:2%29.SRW", + "d613503ddf9bedd2d2e84708749b1a94f0cccd4fa15492e01242368c5c07cfad", + ), + ( + "orf-olympus-sp510uz.orf", + "https://raw.pixls.us/getfile.php/2420/nice/Olympus%20-%20SP510UZ%20-%2012bit%20%284:3%29.ORF", + "f0737d78bb2e588963a3d5038fab493cdff1c9c48475704de96d797bbb81e62a", + ), + ( + "orf-olympus-pen-f.orf", + "https://raw.pixls.us/getfile.php/2977/nice/Olympus%20-%20PEN-F%20-%2016bit%20%284:3%29.orf", + "f87a5f3295aeb4d6ab27bb5b41c5e0f3a211ec9b4e3b1d9f105a650bee1a2f13", + ), + ( + "orf-olympus-e-500.orf", + "https://raw.pixls.us/getfile.php/3540/nice/Olympus%20-%20E-500%20-%2016bit%20%284:3%29.ORF", + "fa5a4759b2de2b573f59629ad1baf64ad04a85fb8fe1fb3cbf68e08a42fff4f0", + ), + ( + "cr2-canon-eos-70d.cr2", + "https://raw.pixls.us/getfile.php/784/nice/Canon%20-%20EOS%2070D%20-%20RAW.CR2", + "00e42517013bd043b7145001399bbc320a8ff235a6dcd16033580d8a130bee1e", + ), + ( + "cr2-canon-eos-rebel-t7i.cr2", + "https://raw.pixls.us/getfile.php/3437/nice/Canon%20-%20EOS%20Rebel%20T7i%20-%20RAW%20%283:2%29.cr2", + "03fb55af71f384ada6b6aa0ca93279d48cf5fbd13651a323408d2fd97e1d1f7c", + ), + ( + "cr2-canon-powershot-g9-x.cr2", + "https://raw.pixls.us/getfile.php/2154/nice/Canon%20-%20PowerShot%20G9%20X%20-%20RAW%20%283:2%29.CR2", + "05c041127f4fdecfd49a45da4b12192c8dbe695c3e50427043166fef49b39e83", + ), + ( + "pef-pentax-k-1.pef", + "https://raw.pixls.us/getfile.php/2469/nice/Pentax%20-%20K-1%20-%2014bit%20%283:2%29.PEF", + "068c902e7b38ac31c7c6eca5c55f69ca395ec6aa7170489281b692a893d0fee9", + ), + ( + "cr2-canon-eos-4000d.cr2", + "https://raw.pixls.us/getfile.php/3241/nice/Canon%20-%20EOS%204000D%20-%20RAW%20%283:2%29.CR2", + "06daaf663f42d3f6ceb1a837d3ca4db2cfcb5899888ed13510bfa01e3261a896", + ), + ( + "cr2-canon-eos-1000d.cr2", + "https://raw.pixls.us/getfile.php/1616/nice/Canon%20-%20EOS%201000D%20-%20RAW%20%283:2%29.CR2", + "08b0843a7968315b68bd3c71a96c952ebf275d5b5eba4b6094ae8b103522584c", + ), + ( + "cr2-canon-powershot-g1-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/2186/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "08d861ca7fc2890f0217034970d4c900b09373b421106e4ba080f6904a48b37c", + ), + ( + "cr2-canon-eos-500d.cr2", + "https://raw.pixls.us/getfile.php/911/nice/Canon%20-%20EOS%20500D%20-%20RAW%20%283:2%29.CR2", + "0b57eb8b0dece6f63d4ea61660279488121c9a0aa250113852fa84c051c6da3e", + ), + ( + "pef-pentax-ist-dl.pef", + "https://raw.pixls.us/getfile.php/1083/nice/Pentax%20-%20%2Aist%20DL%20-%2012bit%20%283:2%29.PEF", + "0e6cd3c999a6dbf121591b2c58aaa8216e625f549db46af627f7346ccf1cf662", + ), + ( + "cr2-canon-eos-400d.cr2", + "https://raw.pixls.us/getfile.php/1764/nice/Canon%20-%20EOS%20400D%20-%20RAW%20%283:2%29.CR2", + "0e752ddaf81795885efc6443c78e5fee4d5f8f00c8d979ef20e235424f98d595", + ), + ( + "dng-ricoh-gx200.dng", + "https://raw.pixls.us/getfile.php/2589/nice/Ricoh%20-%20GX200%20-%2012bit%20%284:3%29.DNG", + "0f328905ee6f5b4823040a666103ced651178a13b8ccd0c4c60662f7265dd87f", + ), + ( + "cr2-canon-powershot-s110.cr2", + "https://raw.pixls.us/getfile.php/909/nice/Canon%20-%20PowerShot%20S110%20-%20RAW%20%284:3%29.CR2", + "14e440316c1d2b005a7f5d5c042e063b3e15d34242e38c3a0983db8ef85e4288", + ), + ( + "cr2-canon-eos-kiss-x80.cr2", + "https://raw.pixls.us/getfile.php/3099/nice/Canon%20-%20EOS%20Kiss%20X80%20-%20RAW%20%283:2%29.CR2", + "155878290528ff91f67b4f9ffed5d765e80ede5fb0c36fca40107c0b7f5c1d9d", + ), + ( + "dng-pentax-kp.dng", + "https://raw.pixls.us/getfile.php/1824/nice/Pentax%20-%20KP%20-%2014bit%20%283:2%29.DNG", + "16b09cb3032003989877659ae03cbb6985e3d7e84f1402863ccae8af10033c1c", + ), + ( + "dng-pentax-k-1-mark-ii.dng", + "https://raw.pixls.us/getfile.php/3345/nice/Pentax%20-%20K-1%20Mark%20II%20-%2014bit%20%283:2%29.DNG", + "190d5878675f4de07d246271ba411b152f3317b2d2a0e27b9c827fe7343b28ea", + ), + ( + "dng-pentax-k-3-mark-iii-monochrome.dng", + "https://raw.pixls.us/getfile.php/7081/nice/Pentax%20-%20K-3%20Mark%20III%20Monochrome%20-%2014bit%20%283:2%29.DNG", + "1afc1071451a3a3737c29ae319e1411551b6893d0e6a34db29903318266b0066", + ), + ( + "cr2-canon-eos-600d.cr2", + "https://raw.pixls.us/getfile.php/1586/nice/Canon%20-%20EOS%20600D%20-%20RAW%20%283:2%29.CR2", + "1b0892df5ba5fa418e945f0519d490aeecaaf7e74860a08b5dc47e3e9976a288", + ), + ( + "pef-pentax-k-70.pef", + "https://raw.pixls.us/getfile.php/1141/nice/Pentax%20-%20K-70%20-%2014bit%20%283:2%29.PEF", + "1d64c6aa71680642ba8f00b6f6105df3e66a4534e631f422fbf3316273c4408c", + ), + ( + "dng-ricoh-gr-iiix.dng", + "https://raw.pixls.us/getfile.php/5818/nice/Ricoh%20-%20GR%20IIIx%20-%2014bit%20%283:2%29.DNG", + "1e318af31be4baa9949f0ed4af5cfe8ffb2c180e2c12926cd75ac1d029bb0fae", + ), + ( + "cr2-canon-powershot-g1-x.cr2", + "https://raw.pixls.us/getfile.php/1588/nice/Canon%20-%20PowerShot%20G1%20X%20-%20RAW%20%284:3%29.CR2", + "1e486bbbdfb2c94accf0c5ef4558d1aa484e0ffd36fa03440e3ae34ba2aa338b", + ), + ( + "dng-ricoh-gxr.dng", + "https://raw.pixls.us/getfile.php/2756/nice/Ricoh%20-%20GXR%20-%2012bit%20%284:3%29.DNG", + "219a062f3d1eb8d66c9dd6088eecf495be082cf5c81f88622eb0dd28cc893cec", + ), + ( + "cr2-canon-powershot-g16.cr2", + "https://raw.pixls.us/getfile.php/1114/nice/Canon%20-%20PowerShot%20G16%20-%20RAW%20%284:3%29.CR2", + "2307e3cf643ab2f94d03f899467d8797ce1ef41bbffd6ad50af81bdd0ba34098", + ), + ( + "dng-pentax-k20d.dng", + "https://raw.pixls.us/getfile.php/1143/nice/Pentax%20-%20K20D%20-%2012bit%20%283:2%29.DNG", + "264465ef1d97f1599899778589f07f8aa485b88c0dfc9b1d4c006bf79d44b420", + ), + ( + "cr2-canon-powershot-g3-x.cr2", + "https://raw.pixls.us/getfile.php/1509/nice/Canon%20-%20PowerShot%20G3%20X%20-%20RAW%20%283:2%29.CR2", + "267641913613fd52532279ff16f03c135511fbcab5bbe8b276fc9cf91aebd7ca", + ), + ( + "pef-pentax-ist-ds.pef", + "https://raw.pixls.us/getfile.php/4392/nice/Pentax%20-%20%2Aist%20DS%20-%2012bit%2012bit%20uncompressed%20%283:2%29.pef", + "27809a7d252d515685ed4e6091b70ab25348502baeb75db03ce7b4f1d9549b76", + ), + ( + "cr2-canon-eos-m.cr2", + "https://raw.pixls.us/getfile.php/1224/nice/Canon%20-%20EOS%20M%20-%20RAW%20%283:2%29.CR2", + "27d151efe86f04bea8fcf6ec9f3a722ae3ee24a3f56a6ebbd27ee89b215cf569", + ), + ( + "cr2-canon-eos-1d-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1058/nice/Canon%20-%20EOS-1D%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "2e372710d546c9b8903a343696af4e7fd245794571f64258ab607a89dfa6aa97", + ), + ( + "pef-pentax-k-3-mark-iii.pef", + "https://raw.pixls.us/getfile.php/4677/nice/Pentax%20-%20K-3%20Mark%20III%20-%2014bit%20%283:2%29.PEF", + "351be88038b2dd28af39e11dbece6de74efd71119a9ef667d35cdf02dcff919f", + ), + ( + "cr2-canon-eos-750d.cr2", + "https://raw.pixls.us/getfile.php/1982/nice/Canon%20-%20EOS%20750D%20-%20RAW%20%283:2%29.CR2", + "38cdb173fe1ea083f4d475285d3080db49d7f3c7cdcb49bf1968fceeed66c85c", + ), + ( + "cr2-canon-eos-5ds.cr2", + "https://raw.pixls.us/getfile.php/2083/nice/Canon%20-%20EOS%205DS%20-%20RAW%20%283:2%29.CR2", + "3fdb8b7ada3b4a69ed5756fa814bee9bd51c1c22b40e70252a46890e2855df07", + ), + ( + "dng-canon-powershot-s2-is.dng", + "https://raw.pixls.us/getfile.php/941/nice/Canon%20-%20PowerShot%20S2%20IS%20-%2010bit%2010bit%20DNG%2C%20CHDK%20ver.%201.0.0-1504%20%284:3%29.CR2", + "445764716df165a1facd37c01d67ede44ca49671ed1a6112dd8ff7426324c757", + ), + ( + "cr2-canon-powershot-s100v.cr2", + "https://raw.pixls.us/getfile.php/2492/nice/Canon%20-%20PowerShot%20S100V%20-%20RAW%20%284:3%29.CR2", + "4775f347686f785104ee7f566df158247cdecef4de76fdbfa150e18b6b104c0a", + ), + ( + "cr2-canon-eos-kiss-digital-n.cr2", + "https://raw.pixls.us/getfile.php/1185/nice/Canon%20-%20EOS%20Kiss%20Digital%20N%20-%20RAW%20%283:2%29.CR2", + "49c78e5c36255cdd3f9fd219e86e3325bfa67be4d8d1ab5e21c21c3fd53c294e", + ), + ( + "dng-pentax-k-01.dng", + "https://raw.pixls.us/getfile.php/1008/nice/Pentax%20-%20K-01%20-%2012bit%20%283:2%29.DNG", + "4c9aac2ce2720154616ad7678385d1ec1f626d6464a1d5c6a5a4d821a78a608d", + ), + ( + "cr2-canon-eos-rebel-t5.cr2", + "https://raw.pixls.us/getfile.php/1411/nice/Canon%20-%20EOS%20Rebel%20T5%20-%20RAW%20%283:2%29.CR2", + "4f71c1416037d2212cc4847e978bffe874afc0f653260386e96dff704ca8bf51", + ), + ( + "pef-pentax-k110d.pef", + "https://raw.pixls.us/getfile.php/1309/nice/Pentax%20-%20K110D%20-%2012bit%20%283:2%29.pef", + "4f7c5574b0dbc3c1270b269126c2a3be030a86799f8b544610c74f31ef782f72", + ), + ( + "cr2-canon-powershot-g11.cr2", + "https://raw.pixls.us/getfile.php/2755/nice/Canon%20-%20PowerShot%20G11%20-%20RAW%20%284:3%29.CR2", + "50ef4d6712c7add1c65ef4c48c74ac5017f24ff1789f1d8a8d5d9accd3aab0fa", + ), + ( + "cr2-canon-eos-rebel-sl1.cr2", + "https://raw.pixls.us/getfile.php/1201/nice/Canon%20-%20EOS%20Rebel%20SL1%20-%20RAW%20%283:2%29.CR2", + "56faa7db3947c32c5e6e31bc0db027ebf95163533e043bd8c4f18166a129692c", + ), + ( + "cr2-canon-powershot-g15.cr2", + "https://raw.pixls.us/getfile.php/1986/nice/Canon%20-%20PowerShot%20G15%20-%20RAW%20%284:3%29.CR2", + "5756d1a6522134ee4833a9cbef04eb54b26a1f7935e58063733a264478385a26", + ), + ( + "dng-pentax-q7.dng", + "https://raw.pixls.us/getfile.php/1760/nice/Pentax%20-%20Q7%20-%2012bit%20%284:3%29.DNG", + "577eac8d03419bb82714169dd532d58efc82a0abf928d28e4641f1ed5d56c350", + ), + ( + "dng-pentax-k-s1.dng", + "https://raw.pixls.us/getfile.php/1365/nice/Pentax%20-%20K-S1%20-%2012bit%20%283:2%29.DNG", + "58abc43a8e025dcab0053bcee173d20197aa2b7983e5e62097147a1e8545f111", + ), + ( + "cr2-canon-eos-1d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/4258/nice/Canon%20-%20EOS-1D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "597bb01f2400b04bfe7ee024016992e8e897b7dffaa5e368b8375daf3b32b3ed", + ), + ( + "cr2-canon-eos-m5.cr2", + "https://raw.pixls.us/getfile.php/2935/nice/Canon%20-%20EOS%20M5%20-%20RAW%20%283:2%29.CR2", + "5af02dbad6cd90eeac2b0b1c0be0aeb9a5d2841c6dd984ee415ede9f2566667d", + ), + ( + "cr2-canon-powershot-s100.cr2", + "https://raw.pixls.us/getfile.php/1357/nice/Canon%20-%20PowerShot%20S100%20-%20RAW%20%284:3%29.CR2", + "5d1bb0597da59bc920442c55dbd7f70d090a9aa01b5642607bcdde523ae892c6", + ), + ( + "pef-pentax-ist-d.pef", + "https://raw.pixls.us/getfile.php/1843/nice/Pentax%20-%20%2Aist%20D%20-%2012bit%20%283:2%29.PEF", + "61904b594d16983070f3e56d663ae8f7b246b8bb554c4cf7bd3eb4b9f9a9f3aa", + ), + ( + "dng-pentax-k-30.dng", + "https://raw.pixls.us/getfile.php/3094/nice/Pentax%20-%20K-30%20-%2012bit%20%283:2%29.DNG", + "63eaa1ff93ad7c89947017332b10f78f17cd3853a5b495adf25410edaefbe618", + ), + ( + "cr2-canon-eos-rebel-t4i.cr2", + "https://raw.pixls.us/getfile.php/2203/nice/Canon%20-%20EOS%20Rebel%20T4i%20-%20RAW%20%283:2%29.CR2", + "65257f38bc68a11b9185ac710b3a442a19aa1c082afb01b668bf33e6836b6b2d", + ), + ( + "cr2-canon-powershot-sx50-hs.cr2", + "https://raw.pixls.us/getfile.php/3152/nice/Canon%20-%20PowerShot%20SX50%20HS%20-%20RAW%20%284:3%29.CR2", + "6c2ddd232f5432fb7bd0e28efc12d66995cedcd847f801fd854bfdfb180db50c", + ), + ( + "dng-pentax-k200d.dng", + "https://raw.pixls.us/getfile.php/5246/nice/Pentax%20-%20K200D%20-%2012bit%2012bit%20uncompressed%20%283:2%29.DNG", + "6d13579dd0a8ec784bfa3d8b53af5a3f936171bfa62d74eb96a17236a6fb9472", + ), + ( + "cr2-canon-eos-m2.cr2", + "https://raw.pixls.us/getfile.php/5067/nice/Canon%20-%20EOS%20M2%20-%20RAW%20%283:2%29.cr2", + "6d26eeb1e8a5978c7cb9d8beb6e6e34f3c27847055b76bc7f186503ca44508d0", + ), + ( + "cr2-canon-eos-m3.cr2", + "https://raw.pixls.us/getfile.php/1002/nice/Canon%20-%20EOS%20M3%20-%20RAW%20%283:2%29.CR2", + "6f12a02a21f64b284328ee6d3b2d1bab4439720cad2e881adf3da72592128b11", + ), + ( + "cr2-canon-powershot-g12.cr2", + "https://raw.pixls.us/getfile.php/1767/nice/Canon%20-%20PowerShot%20G12%20-%20RAW%20%284:3%29.cr2", + "6f2750c7cb144eee80a91431e2ce8df66dd229c3e1f1453bf80409ca669a8614", + ), + ( + "dng-pentax-k-5.dng", + "https://raw.pixls.us/getfile.php/1479/nice/Pentax%20-%20K-5%20-%2014bit%20%283:2%29.DNG", + "6f6419209340d07d597ccca66dc367d64cff3308ef2694c8f90c6259f3a5c431", + ), + ( + "cr2-canon-eos-6d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1625/nice/Canon%20-%20EOS%206D%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "712815b910fa473f7aaeb97c0804292d996b16dd6b974a3ab9a7195a2e41d90c", + ), + ( + "cr2-canon-eos-m10.cr2", + "https://raw.pixls.us/getfile.php/1758/nice/Canon%20-%20EOS%20M10%20-%20RAW%20%283:2%29.CR2", + "71e27b9ebe7efba632e666db77b82c5129e0d1a402ff359a2b509774ebeb1b0f", + ), + ( + "dng-pentax-k-x.dng", + "https://raw.pixls.us/getfile.php/830/nice/Pentax%20-%20K-x%20-%2012bit%20%283:2%29.DNG", + "7a594b66b54f99bb5aba4627c78abfc41213d6911ea54e4f5d2dd6c8505f6968", + ), + ( + "cr2-canon-eos-rebel-t5i.cr2", + "https://raw.pixls.us/getfile.php/2651/nice/Canon%20-%20EOS%20Rebel%20T5i%20-%20RAW%20%283:2%29.CR2", + "7c3c9c3b8ec487d56040c21620aafe51e13e2c2fc1e1351929970720e10dda69", + ), + ( + "dng-ricoh-gr.dng", + "https://raw.pixls.us/getfile.php/996/nice/Ricoh%20-%20GR%20-%2012bit%20%284:3%29.DNG", + "7ceec1ea31c56b6e0414202504581b915f17818ffe018b4c8bba1393332c405e", + ), + ( + "dng-pentax-645d.dng", + "https://raw.pixls.us/getfile.php/2401/nice/Pentax%20-%20645D%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "7cfa4a6b3fc459157b0be94effaf98136027b1182dac7da0f91e29987ab6ae6c", + ), + ( + "cr2-canon-eos-rebel-t3.cr2", + "https://raw.pixls.us/getfile.php/3920/nice/Canon%20-%20EOS%20Rebel%20T3%20-%20RAW%20%283:2%29.CR2", + "8106f288e22ca0510b2ebe36e58833afbf847db92e9e1ec6f651605bac0beb24", + ), + ( + "cr2-canon-eos-77d.cr2", + "https://raw.pixls.us/getfile.php/1332/nice/Canon%20-%20EOS%2077D%20-%20RAW%20%283:2%29.CR2", + "811df582d97918987312fa0fceae3ad19a20532a64ebda9782764ac7e629c265", + ), + ( + "cr2-canon-eos-2000d.cr2", + "https://raw.pixls.us/getfile.php/2612/nice/Canon%20-%20EOS%202000D%20-%20RAW%20%283:2%29.CR2", + "84015493df96c877c0387071a627d6940187d75695b0598694901ac0ff83876a", + ), + ( + "cr2-canon-eos-5d-mark-iv.cr2", + "https://raw.pixls.us/getfile.php/980/nice/Canon%20-%20EOS%205D%20Mark%20IV%20-%20RAW%20%283:2%29.CR2", + "85bfec149261ffbae90797e45caae5e204e1e61ac62b92783925b5ee0f267d76", + ), + ( + "cr2-canon-eos-1d-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2037/nice/Canon%20-%20EOS-1D%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "860b5c9791548db8081823e3852c436a439e648ea863b0def05e99aac6e9e726", + ), + ( + "cr2-canon-eos-60d.cr2", + "https://raw.pixls.us/getfile.php/993/nice/Canon%20-%20EOS%2060D%20-%20RAW%20%283:2%29.CR2", + "865236ece3bf1f2f19a15001a8f0f99badf71e34b9eed1fead04d15daf87a154", + ), + ( + "pef-pentax-k100d.pef", + "https://raw.pixls.us/getfile.php/2262/nice/Pentax%20-%20K100D%20-%2012bit%20%283:2%29.PEF", + "86ec2383dbfda3eda38ca913b02874a70248efba6107aa304781c8d3779de8c9", + ), + ( + "cr2-canon-eos-1d-mark-ii-n.cr2", + "https://raw.pixls.us/getfile.php/1781/nice/Canon%20-%20EOS-1D%20Mark%20II%20N%20-%20RAW%20%283:2%29.CR2", + "8bf6591f64b0657bc6133cbd0ee1418ef8e6dd3dd595827f8e9910c0f235843c", + ), + ( + "dng-pentax-k-500.dng", + "https://raw.pixls.us/getfile.php/1458/nice/Pentax%20-%20K-500%20-%2012bit%20%283:2%29.DNG", + "91ccd6f29b8eaad88ae26d1c860ef1113e6dfc1bd2c8f5245b49f3a788295fb1", + ), + ( + "cr2-canon-eos-m100.cr2", + "https://raw.pixls.us/getfile.php/3531/nice/Canon%20-%20EOS%20M100%20-%20RAW%20%283:2%29.CR2", + "941d9e696b1d92b4b25a762474e3c5c127e7405dea099c266fa85e39a2454fcc", + ), + ( + "pef-pentax-k2000.pef", + "https://raw.pixls.us/getfile.php/5239/nice/Pentax%20-%20K2000%20-%2012bit%2012bit%20compressed%20%283:2%29.PEF", + "985003748c094b8d02f2517b4c07d66d48ea5380260235ccb3409f41ceb3ea84", + ), + ( + "dng-pentax-k-50.dng", + "https://raw.pixls.us/getfile.php/2235/nice/Pentax%20-%20K-50%20-%2012bit%20%283:2%29.DNG", + "9ea1dc1f92df19699aa8b620670c64a84d4ead0172a49cbbd47dea9257d14e67", + ), + ( + "cr2-canon-powershot-g5-x.cr2", + "https://raw.pixls.us/getfile.php/4257/nice/Canon%20-%20PowerShot%20G5%20X%20-%20RAW%20%283:2%29.CR2", + "a7d0ac7f3c7ddfa607a6d0a7433ef9ba664d260aa91616cd75cfbdf912b0da00", + ), + ( + "dng-pentax-645z.dng", + "https://raw.pixls.us/getfile.php/2403/nice/Pentax%20-%20645Z%20-%2014bit%2014bit%20compressed%20%284:3%29.DNG", + "a8203686c61962af591b374331b6eb9dace4f2b29fce9f166303fc66fd210bb0", + ), + ( + "dng-pentax-k-5-ii.dng", + "https://raw.pixls.us/getfile.php/873/nice/Pentax%20-%20K-5%20II%20-%2014bit%20%283:2%29.DNG", + "aa41f0d9ab8d3e21e468b4c61d4d1ddae6a4fbe37f629b78159246cbb94dd56b", + ), + ( + "cr2-canon-powershot-g9.cr2", + "https://raw.pixls.us/getfile.php/760/nice/Canon%20-%20PowerShot%20G9%20-%20RAW%20%284:3%29.CR2", + "ad27a4d751984cd709380e3be274a4a5243d36ca89c5fc711736ba8a9f5c932f", + ), + ( + "dng-pentax-q.dng", + "https://raw.pixls.us/getfile.php/1610/nice/Pentax%20-%20Q%20-%2012bit%20%284:3%29.DNG", + "ad79b4ce4b88e5ca8b03d0d5aeaba09f55559695234941a5a6ada0cd4825860d", + ), + ( + "dng-pentax-mx-1.dng", + "https://raw.pixls.us/getfile.php/1731/nice/Pentax%20-%20MX-1%20-%2012bit%20%284:3%29.dng", + "b00f3fd42479bbaba7a00b2b6fa3e5f5d213c51d591adff4f6eb4a768ecd1ce9", + ), + ( + "cr2-canon-eos-5d.cr2", + "https://raw.pixls.us/getfile.php/1346/nice/Canon%20-%20EOS%205D%20-%20RAW%20%283:2%29.CR2", + "b30089172973388d03e192a14a63f750e22914dfc01db5713b6b3bce3c9baeee", + ), + ( + "pef-pentax-k100d-super.pef", + "https://raw.pixls.us/getfile.php/5804/nice/Pentax%20-%20K100D%20Super%20-%2012bit%2012bit%20uncompressed%20%283:2%29.PEF", + "b391c84ff1cc8769ec5214f837d1e4b3ae80374673608601b12ee4484e003ddb", + ), + ( + "cr2-canon-eos-m6.cr2", + "https://raw.pixls.us/getfile.php/1382/nice/Canon%20-%20EOS%20M6%20-%20RAW%20%283:2%29.CR2", + "b3a7a0037600b61d4c26ad03d5cac1801e35fb43a55699db5b77708c96f7338a", + ), + ( + "cr2-canon-powershot-g7-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1682/nice/Canon%20-%20PowerShot%20G7%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "b81acebf5ac49b0389c354942d128d9423fdd59ff92217554f232f94e3e87ef5", + ), + ( + "cr2-canon-eos-7d-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1064/nice/Canon%20-%20EOS%207D%20Mark%20II%20-%20RAW%20%283:2%29.cr2", + "b9f71c56adb6e2861e6653efb312fb5cc88ba4aa17fa809c6a18b8db044360c3", + ), + ( + "dng-ricoh-gr-ii.dng", + "https://raw.pixls.us/getfile.php/1955/nice/Ricoh%20-%20GR%20II%20-%2012bit%20%283:2%29.DNG", + "bc8e00ccfd8e4753e8ee0735255bff4dcca5fb8d1a1352adfa59b86806d03f44", + ), + ( + "cr2-canon-powershot-s120.cr2", + "https://raw.pixls.us/getfile.php/1694/nice/Canon%20-%20PowerShot%20S120%20-%20RAW%20%284:3%29.CR2", + "bcbfadad8ecdbcc783864a2b769aef37588e2a6339b9682dcd9642602bd2011f", + ), + ( + "cr2-canon-powershot-s95.cr2", + "https://raw.pixls.us/getfile.php/1742/nice/Canon%20-%20PowerShot%20S95%20-%20RAW%20%284:3%29.cr2", + "c4a55c61434b8403f948518deca22e8ab755099f124d5cea5bc1cd0ea86711ad", + ), + ( + "cr2-canon-powershot-g1-x-mark-iii.cr2", + "https://raw.pixls.us/getfile.php/2695/nice/Canon%20-%20PowerShot%20G1%20X%20Mark%20III%20-%20RAW%20%283:2%29.CR2", + "ca052ed025432fdacfba7347cb4374fa3b727155912a91c42ed9a275d13bedf5", + ), + ( + "cr2-canon-eos-50d.cr2", + "https://raw.pixls.us/getfile.php/1792/nice/Canon%20-%20EOS%2050D%20-%20RAW%20%283:2%29.CR2", + "ce460cf2b6762c8c22ebeb00e9e1edccd728edcb7c4e82c78f3bce61d21fa88d", + ), + ( + "dng-pentax-k-s2.dng", + "https://raw.pixls.us/getfile.php/1626/nice/Pentax%20-%20K-S2%20-%2012bit%20%283:2%29.DNG", + "d23916a31f09c961e6050e4120bbcd92f7847080f9e40fad8dc7047ead1dec2d", + ), + ( + "cr2-canon-eos-20d.cr2", + "https://raw.pixls.us/getfile.php/1018/nice/Canon%20-%20EOS%2020D%20-%20RAW%20%283:2%29.CR2", + "db78660d48589130d99e0bc621424851eb0f84df052634fb5bc89c7a02fe7033", + ), + ( + "pef-pentax-k-r.pef", + "https://raw.pixls.us/getfile.php/8171/nice/Pentax%20-%20K-r%20-%2012bit%20compressed%20%283:2%29.PEF", + "dd0d7b76d89039995d253adddcfb44d131414fad2179610d9ea4d8d4bb876b7b", + ), + ( + "pef-pentax-kf.pef", + "https://raw.pixls.us/getfile.php/6664/nice/Pentax%20-%20KF%20-%2014bit%20%283:2%29.PEF", + "ddc9cdf399a7210b203dee1765ab9f4165a2e695da59bce6bcabf75b77174a4b", + ), + ( + "cr2-canon-powershot-g7-x.cr2", + "https://raw.pixls.us/getfile.php/1277/nice/Canon%20-%20PowerShot%20G7%20X%20-%20RAW%20%283:2%29.CR2", + "e4b0a64df6f43252ab96b4789f698ca334e86586ab151d1e9f5616b80172abcd", + ), + ( + "cr2-canon-eos-450d.cr2", + "https://raw.pixls.us/getfile.php/1353/nice/Canon%20-%20EOS%20450D%20-%20RAW%20%283:2%29.CR2", + "e975f71429b939701dd618a7bac4709c1409f4bb6aa1c3607ceeaa86031c1c4a", + ), + ( + "cr2-canon-powershot-sx60-hs.cr2", + "https://raw.pixls.us/getfile.php/4942/nice/Canon%20-%20PowerShot%20SX60%20HS%20-%20RAW%20%284:3%29.CR2", + "f005e82fe8579876d7e9d39291c57f4410412904dfff0de5bbad9f32b3189612", + ), + ( + "dng-pentax-k-3-ii.dng", + "https://raw.pixls.us/getfile.php/1363/nice/Pentax%20-%20K-3%20II%20-%2014bit%20%283:2%29.DNG", + "f43b9dc35e4d1b43918d2d4bd0973bc202af48ed24c924e4aff61250c971b171", + ), + ( + "dng-pentax-k-7.dng", + "https://raw.pixls.us/getfile.php/827/nice/Pentax%20-%20K-7%20-%2012bit%20%283:2%29.DNG", + "f441c36678dfca4f38e25a580f4f8485d78d8067a054153bc36be71e56c92386", + ), + ( + "cr2-canon-eos-kiss-x9.cr2", + "https://raw.pixls.us/getfile.php/2062/nice/Canon%20-%20EOS%20Kiss%20X9%20-%20RAW%20%283:2%29.CR2", + "f50cea4641c97c1154a214a686226ba900002f00f4f844af858ef80b9ec8dea6", + ), + ( + "cr2-canon-powershot-g9-x-mark-ii.cr2", + "https://raw.pixls.us/getfile.php/1575/nice/Canon%20-%20PowerShot%20G9%20X%20Mark%20II%20-%20RAW%20%283:2%29.CR2", + "f91f1a3b6fa66b2e15fd26c1bd78e1eb19dfa7e6a3886aff36e7ab96fd1c319c", + ), + ( + "cr2-canon-eos-760d.cr2", + "https://raw.pixls.us/getfile.php/2094/nice/Canon%20-%20EOS%20760D%20-%20RAW%20%283:2%29.cr2", + "f98108c5efd57eef483009cdb490ada2071d1c1b58a82df3de997b4d86d849c2", + ), + ( + "cr2-canon-eos-30d.cr2", + "https://raw.pixls.us/getfile.php/1791/nice/Canon%20-%20EOS%2030D%20-%20RAW%20%283:2%29.CR2", + "fb216f8df8d7abca4eb9d9f8dc594c1d2b69d0a6369fb8b4107743343c37144f", + ), + ("erf-epson-rd1.erf", "https://raw.pixls.us/data/Epson/R-D1/_EPS0672.ERF", "09d8e533d93116294a9f3e161ed868e929454c7b946e083b644bd6bb75bcb5e4"), + ("erf-epson-rd1s.erf", "https://raw.pixls.us/data/Epson/R-D1s/EPSN3575.ERF", "45bb0081baf1c813c7cd9e05b3e6ae734b90aba408dd1195118dc94dc6abbbb9"), + ("erf-epson-rd1x.erf", "https://raw.pixls.us/data/Epson/R-D1x/_EPS1919.ERF", "fa1bff3e0b9b9a14f83b8db9fae022c2aca8d455896bf4f25f2c1a0034e295e6"), + ( + "mef-mamiya-zd.mef", + "https://raw.pixls.us/data/Mamiya/ZD/RAW_MAMIYA_ZD.MEF", + "bcd63507c3c4cc3ea1bad2945e3f88cb4677a1a3c762e1acc38df4445714eb7c", + ), ]; fn cmd_corpus(download: bool) -> Result<(), String> {
Justified Photo Grid of 24 demo photos with ratings and flags, and a sidebar of nested albums under a Travel 2026 folder
Photo Grid with albums nested in a folder, ratings and flags. Demo library.